Onboarding Architecture & Friction Mapping
A new contributor should go from git clone to a running stack and a first merged change without a single message asking "what am I missing?" Most teams fall far short of that because onboarding friction is invisible until it costs a day: a container that exits on boot, a service that cannot resolve its database, a runtime that drifts a patch version from staging. This guide treats local environment provisioning as a measurable engineering discipline. It covers the failure points that block first boot, the dependency maps that explain what a service actually needs, the parity checks that keep local execution honest against staging, the metrics that quantify the cost of friction, and the README-driven automation that collapses setup into one command. It pairs closely with containerized local environment patterns and with environment sync, secrets, and CI parity.
Strategic Overview
Onboarding friction is a systems problem disguised as a documentation problem. Teams reach for a longer README when the real defect is that the environment cannot be reproduced deterministically: the daemon versions differ, the base images float on latest, the seed data is a Slack attachment from last quarter, and the one person who knows the incantation is on vacation. Adding prose to that situation only lengthens the failure. The durable fix is to treat the local environment as a build artifact with a contract — an input set (repository, lockfiles, pinned images, declared environment variables) that produces a known-good output (a running stack that passes a health check) — and to make every deviation from that contract visible and enforceable.
This page is the top of that topic. It frames five engineering concerns and links each to a focused sub-topic that carries the deep implementation detail. The first concern, first-boot failures, is where new contributors lose their first hour; it maps to a catalogue of deterministic symptoms and fixes. The second, dependency mapping, answers the question "what does this service actually need to start?" and turns an opaque stack into an explicit graph. The third, runtime parity, closes the gap between a laptop and staging so that "works on my machine" stops being an accepted excuse. The fourth, time-to-first-PR, gives you the single number that tells you whether any of this work is paying off. The fifth, README-driven automation, collapses the whole sequence into one command that CI exercises on every push so it cannot silently rot.
Read top to bottom, the sections build on each other: you diagnose what breaks, you map what depends on what, you pin what must stay identical, you measure the result, and you automate the whole path so the next contributor never has to repeat your investigation. Each section ends with a diagnostic command you can run today, and a link to the sub-topic that expands it into a full workflow with platform-specific edge cases. The goal throughout is a self-serve environment: a contributor clones, runs one target, and is writing code within minutes rather than filing a support ticket.
There is an ordering rationale worth making explicit, because it also tells you where to spend effort first. Friction compounds: a contributor who cannot get the stack to boot never reaches the point of discovering a parity bug, and a team that has not instrumented onboarding cannot tell whether last quarter's README rewrite helped. So the highest-leverage work is almost always at the bottom of the stack — the deterministic first-boot triage and the reproducible environment contract — because everything else assumes those hold. Measurement sits deliberately in the middle rather than at the end: you instrument early so that every subsequent fix has a number attached to it, and you can defend the time spent on environment work with evidence instead of anecdote. A useful mental model is a funnel where each stage sheds contributors who give up: raising the conversion rate at the widest, earliest stage — first boot — returns more than polishing the narrowest.
Diagnosing First-Boot Failures
The first hour of onboarding is dominated by a handful of recurring breakages: containers that exit immediately, services that cannot resolve each other over the Compose network, ports already in use, and bind-mounted files the container user cannot write. Each has a deterministic signature and a deterministic fix, so the right move is to script the triage rather than answer it in Slack every week. Catalogue these against common local failure points so a new engineer can self-serve the diagnosis before asking for help.
An exit-on-startup container almost always fails for one of three reasons: the entrypoint command returns non-zero, a required environment variable is unset so the process aborts during config parsing, or the image expects a writable path that the mount makes read-only. The fastest discriminator is the exit code paired with the last twenty log lines. Exit code 1 is a generic application error — read the logs. Exit code 137 is a SIGKILL, almost always the out-of-memory killer, which points at the VM memory allocation rather than the code. Exit code 127 means the shell could not find the binary named in the entrypoint, which usually means a typo or a missing build stage.
#!/usr/bin/env bash
set -euo pipefail
# First-boot triage: surface exited/unhealthy containers and the reason
docker compose ps -a --format '{{.Name}} {{.State}} {{.Status}}'
docker compose ps -a --filter status=exited --format '{{.Name}}' \
| while read -r svc; do
[ -n "$svc" ] || continue
echo "=== last logs: $svc ==="
docker compose logs --tail 20 "$svc"
done
Service-to-service DNS failures are the next most common class. On a Compose network, a service reaches another by its service name, not localhost; a connection string pointing at 127.0.0.1:5432 will resolve to the container itself, not the database, and fail with connection-refused. The fix is to use the service name (db:5432) and to gate startup on readiness rather than mere existence — depends_on with a condition: service_healthy waits for the dependency's healthcheck to pass, whereas a bare depends_on only waits for the container to be created. Port conflicts announce themselves with bind: address already in use and are resolved by finding the offending listener with lsof -i :PORT or by remapping the host side of the port to a free number while leaving the container side fixed. Bind-mount permission errors — EACCES on a write — come from a UID mismatch between the host file owner and the container process user, which the cross-cutting section below addresses directly.
The reason to script this triage rather than document it is that a diagnosis a contributor reads is a diagnosis they can get wrong, while a diagnosis they run is deterministic. A triage script encodes the maintainer's tacit knowledge — that exit 137 means memory, that a connection-refused to 127.0.0.1 means a misaddressed service — into a tool that prints the answer directly. That is the difference between an onboarding process that scales with the team and one that scales with the maintainer's availability. When you write the script, prefer machine-readable output at the boundaries: docker compose ps --format json piped through jq lets you assert on .State and .Health programmatically, so the same script that a human runs for a readable summary can also power a verify-onboarding gate in CI. Keep the classification logic in one place; a triage script that lives in the repo and is exercised by CI cannot drift the way a wiki page silently does, because a change to the failure modes that breaks the script fails a build and gets fixed.
One subtlety worth flagging: an exited container and an unhealthy container are different states with different causes. An exited container ran its entrypoint to completion (or to a crash) and stopped — the fix is in the logs. An unhealthy container is still running but its healthcheck command keeps returning non-zero, which usually means the process started but is not yet serving, or is serving on a different port or path than the healthcheck probes. Distinguish them early, because tailing the logs of an unhealthy-but-running container often shows nothing wrong at the process level while the healthcheck definition itself carries the bug — a wrong port, a missing curl in a minimal image, or a start_period too short for a slow migration.
Mapping Service Dependencies
A service rarely fails alone. It fails because something it depends on never came up, came up in the wrong order, or formed a circular wait with another build target. Making that graph explicit turns "the stack is broken" into "the auth service is missing a depends_on: db." Build the adjacency list from your Compose file and lockfiles, then keep it current as the canonical answer to "what does this service need?" The full extraction and rendering workflow lives under dependency tree visualization.
#!/usr/bin/env bash
set -euo pipefail
# Emit a service -> dependency adjacency list straight from Compose
docker compose config --format json \
| jq -r '
.services
| to_entries[]
| .key as $svc
| (.value.depends_on // {} | keys[]? ) as $dep
| "\($svc) -> \($dep)"
'
Rebuilding the graph from the Compose file and lockfiles rather than maintaining it by hand is the whole point. A hand-drawn architecture diagram is stale the moment someone adds a service and forgets to update the wiki; a graph generated from docker compose config is correct by construction because it reads the same declaration Compose itself uses to start the stack. Generate it in CI and diff it against the committed version, and any structural change to the stack that a contributor did not intend to make shows up as a diff to review. This is the same discipline as a lockfile: the graph is a derived artifact you check in so that changes to it are visible, reviewable, and attributable to a commit.
The adjacency list is the raw material; the value is in what you compute from it. Three properties matter. First, the graph must be acyclic. A cycle — auth waits on billing, billing waits on auth — means no valid startup order exists, and Compose will either deadlock or start both before either dependency is satisfied. Detect cycles by attempting a topological sort of the adjacency list; if the sort cannot consume every node, the remaining nodes form the cycle. Second, the declared graph must match the runtime graph. A service that opens a TCP connection to Redis but omits depends_on: redis will start successfully most of the time and fail intermittently under a cold boot when Redis lags — the worst kind of flake because it is timing-dependent. Third, leaf dependencies (databases, caches, message brokers) should carry healthchecks so that dependents can wait on readiness, not just creation.
Enforcing Runtime Parity
"Works on my machine" is not a personality trait; it is an unpinned base image, a runtime that drifted a patch version, or an environment variable that exists in staging and nowhere else. Parity means local execution shares an explicit contract with staging: identical image digests, identical runtime versions, identical declared environment. Pin digests, capture both runtimes, and diff them before the difference becomes a production incident. The script templates and thresholds belong to runtime parity frameworks.
#!/usr/bin/env bash
set -euo pipefail
# Compare local and staging runtime fingerprints
LOCAL=$(node -p 'JSON.stringify({v:process.version,arch:process.arch})')
STAGING=$(ssh staging "node -p 'JSON.stringify({v:process.version,arch:process.arch})'")
if [ "$LOCAL" != "$STAGING" ]; then
echo "DRIFT: local=$LOCAL staging=$STAGING" >&2
exit 1
fi
echo "Runtime parity OK: $LOCAL"
The diff-and-fail pattern in the script above is deliberately blunt: it captures a fingerprint on each side and exits non-zero on any difference. Blunt is correct here because a partial parity check gives false confidence — knowing the Node major version matches tells you nothing if the architecture differs or a native module was built against a different libc. Fingerprint the things that actually change behaviour: the runtime version to the patch level, the CPU architecture, the libc flavour (glibc versus musl, which silently breaks native addons), and the relevant locale and timezone settings that affect string collation and date parsing. Serialize them to a canonical JSON string so the comparison is a single equality check rather than a field-by-field walk that someone will forget to extend.
Parity has three layers, and each drifts for a different reason. Image parity drifts when a Dockerfile references a floating tag: FROM node:20 resolves to whatever the registry currently tags as 20, which changes without notice, so two contributors who built a week apart can be running different patch releases. Pin by digest — FROM node:20.11.1-bookworm@sha256:… — so the input is byte-identical everywhere. Dependency parity drifts when a lockfile is absent or ignored; npm install resolves the newest satisfying version, whereas npm ci installs exactly what the lockfile records and fails if the lockfile and manifest disagree. Configuration parity drifts most insidiously, because a variable that exists only in staging produces no local error at all — the code path that reads it simply never runs locally. The defense is a declared schema of required variables validated at boot, so an absent variable fails fast with a named error rather than a null-pointer three requests later.
Measuring Time-to-First-PR
If you cannot measure onboarding, you cannot tell whether a change helped or hurt. Time-to-first-PR (TTFPR) is the durable proxy: the wall-clock interval from a contributor's first clone to their first merged, human-authored pull request. Instrument the boundaries deterministically, exclude bots and CI commits, and report the median and p90 per cohort so outliers surface. The boundary definitions and dashboards are detailed in time-to-first-PR metrics.
#!/usr/bin/env bash
set -euo pipefail
# Record an onboarding milestone with a UTC timestamp
EVENT_TYPE="${1:?usage: record-event <clone|first-pr>}"
TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ)
REPO_URL=$(git remote get-url origin 2>/dev/null || echo unknown)
curl -fsS -X POST https://metrics.internal/api/v1/onboarding \
-H 'Content-Type: application/json' \
-d "{\"event\":\"${EVENT_TYPE}\",\"ts\":\"${TIMESTAMP}\",\"repo\":\"${REPO_URL}\"}"
The instrumentation itself must be trustworthy, which mostly means resisting the temptation to measure something easier than what you care about. Commit count, lines changed, and CI runs are all cheap to collect and all misleading: a contributor can rack up commits on a branch that never merges, and a bot can outpace every human on raw commit volume. The boundary that matters is the first merged, human-authored pull request, because that is the first moment the contributor delivered value the team accepted. Record the clone boundary at the point the contributor first fetches the repository — a server-side hook on the Git host is more reliable than asking people to run a script — and record the PR boundary from the merge event, filtered to exclude accounts flagged as bots and commits whose author matches a CI identity. Store both as UTC timestamps so daylight-saving transitions and travelling contributors do not corrupt the interval.
Report the median, not the mean. Onboarding times are heavily right-skewed — most contributors are productive within a day, but a handful hit an environment wall and take a week, and a mean lets those tails hide the typical experience. The median tells you what a normal contributor faces; the p90 tells you how bad the wall is for the unlucky. Track both per cohort (by hire month or by team) so a regression introduced in June does not average away against a smooth May. When a cohort's p90 spikes, the friction map above is your diagnostic index: correlate the spike with a change to the base image, a new required service, or a removed seed script, and you can usually name the exact commit that raised the cost. The bar chart below shows a representative before-and-after: instrumenting the boundaries, then fixing the top two friction points surfaced by the p90, cut the median from three days to under half a day.
Automating Setup From the README
Every friction point above collapses if the repository ships a single bootstrap command that provisions toolchains, generates .env, starts the stack, and self-verifies. The README stops being prose to read and becomes automation to run: one make target, one health check, zero tribal knowledge. Treat the documented commands as the source of truth and execute them in CI so they never rot. The patterns for this live under README-driven automation.
# Makefile — one-command onboarding
.PHONY: bootstrap
bootstrap:
@cp -n .env.example .env || true
@docker compose up -d --wait
@./scripts/health-check.sh
@echo "Environment ready. Open a PR."
The bootstrap target has to be idempotent — running it a second time must not clobber a contributor's edited .env or fail on already-running containers. That is why the copy uses cp -n (no-clobber) and why docker compose up -d --wait is safe to re-run: it reconciles the running state to the declared state rather than assuming a clean slate. The --wait flag is the linchpin. Without it, up -d returns as soon as containers are created, so a naive health check races the still-booting database and fails intermittently. With --wait, Compose blocks until every service with a healthcheck reports healthy (or a service exits), which means the health-check script that follows runs against a genuinely ready stack. The final ingredient is executing this same target in CI on every pull request: a workflow that checks out the repo, runs make bootstrap, and asserts the health check passes guarantees the documented path still works. When a dependency is added or a variable becomes required, the CI run fails on the exact commit that broke onboarding, so the README can never quietly drift out of sync with the code.
Cross-Cutting Concerns
The same host-level caveats recur across every section above, so treat them as a shared checklist for any onboarding change. A fix that works on a maintainer's Linux workstation can fail on a new hire's Mac or a Windows machine running WSL2, and those failures are exactly the kind that a well-meaning README omits because the author never hit them. Encoding the caveats as explicit conditionals in the bootstrap scripts — detect the host, set the right UID, choose the right filesystem path — is what makes onboarding truly self-serve across a heterogeneous team.
macOS (Docker Desktop): bind-mounted files keep host ownership (commonly UID
501), so a container expecting UID1000hitsEACCESon writes; passuser: "${HOST_UID}:${HOST_GID}". VirtioFS I/O is slow for large dependency trees — allocate ≥8GB to the VM. WSL2: keep the repository on the Linux filesystem (~/code, not/mnt/c) ornpm ciand file-watchers degrade 40–60%. Normalize line endings withcore.autocrlf=falseso.envand shell scripts parse. Apple Silicon (ARM64): pinplatform: linux/amd64only for images lacking an arm64 manifest; otherwise prefer native multi-arch images, since QEMU emulation masks architecture-specific bugs and slows healthcheck polling.
Two of these caveats interact and deserve a note. On Apple Silicon under Docker Desktop, an amd64 image runs under QEMU emulation, which is not only slower but can change timing enough that a healthcheck which passes natively times out under emulation — so a contributor on an M-series Mac may see a "healthy" service on Linux report "unhealthy" locally purely because the emulated startup exceeded the healthcheck's start_period. Widen the start_period generously rather than shortening the retry interval. On WSL2, the filesystem caveat compounds with line endings: a shell script checked out on /mnt/c with CRLF endings fails with an opaque bad interpreter error because the shebang line carries a trailing carriage return, and the fix (core.autocrlf=false plus a .gitattributes marking scripts as text eol=lf) belongs in the repository, not in each contributor's local config where it will be forgotten.
Verification Suite
Wire every concern above into a single target so any contributor — or CI job — can confirm the environment is sound in seconds. A verification target is distinct from the bootstrap target: bootstrap brings the environment up, verification asserts that it is correct. Running verification in CI on every pull request is what converts all of the discipline above from advice into an enforced contract, because a change that breaks parity, adds an unmet dependency, or removes a required variable fails the check on the exact commit that introduced it.
# Makefile — onboarding verification
.PHONY: verify-onboarding
verify-onboarding:
@set -e; \
docker compose config --quiet; \
docker compose ps --format '{{.Name}} {{.State}}' | grep -qv exited; \
./scripts/parity-check.sh --mode ci; \
./scripts/health-check.sh; \
echo "Onboarding baseline OK"
Read the target as four assertions in dependency order. docker compose config --quiet validates that the Compose file itself parses and that every variable interpolation resolves — a broken YAML or an undefined ${VAR} fails here before any container starts, which is the cheapest possible failure. The ps check confirms no service has exited, catching the first-boot failures from the opening section. The parity-check.sh --mode ci step compares the local runtime fingerprint against the recorded baseline so a drifted image or runtime version fails the build rather than a production request. The final health-check.sh exercises the application-level readiness endpoints that a Compose healthcheck cannot express — a database migration applied, a queue reachable, a seed row present. Order matters: each assertion is cheaper and more fundamental than the one after it, so the suite fails fast on the most basic problem rather than burning minutes booting a stack that a syntax error already doomed.
Make the suite the single definition of "the environment is correct," and resist the urge to let it diverge between local and CI. If a contributor can pass verify-onboarding locally but the CI job runs a different set of checks, you have two contracts and the drift you were trying to eliminate reappears at the process level. Parameterize the differences instead — the --mode ci flag on the parity check is exactly this: the same script, with CI supplying the recorded baseline it compares against, so local and CI assert the same property against the same source of truth. When the suite grows, keep every new assertion idempotent and side-effect-free so it can run repeatedly without leaving state behind, and keep its output specific enough that a failure names the fix, not just the symptom.
Frequently Asked Questions
Why does docker compose up -d succeed but my health check still fails?
Because -d returns as soon as containers are created, not when they are ready. A database can take several seconds to accept connections after its container exists, so a health check that runs immediately races the boot and fails intermittently. Add --wait (docker compose up -d --wait), which blocks until every service with a healthcheck reports healthy or a service exits. Pair that with depends_on: { db: { condition: service_healthy } } so dependents also wait on readiness rather than mere creation.
What exactly should time-to-first-PR measure, and why the median?
Measure the wall-clock interval from a contributor's first git clone to their first merged, human-authored pull request, excluding bot and CI commits. Report the median rather than the mean because onboarding times are right-skewed: most contributors are productive within a day, but a few hit an environment wall and take a week, and a mean lets those tails hide the typical experience. Track the p90 alongside the median per cohort so a regression in one month does not average away against a smoother month.
How do I pin an image so two developers get byte-identical builds?
Reference the image by digest, not by a floating tag. FROM node:20 resolves to whatever the registry currently tags as 20, which changes without notice, so two people who build a week apart can run different patch releases. Use FROM node:20.11.1-bookworm@sha256:... so the input is fixed everywhere, and install dependencies with npm ci instead of npm install so the lockfile is honored exactly and a mismatch between lockfile and manifest fails the build.
Why does my container get EACCES writing to a bind mount on macOS?
Bind-mounted files keep their host ownership. On macOS the host user is commonly UID 501, but the container process often runs as UID 1000, so a write to the mounted path is denied. Pass user: "${HOST_UID}:${HOST_GID}" in the Compose service and export those variables in your shell (export HOST_UID=$(id -u) HOST_GID=$(id -g)) so the container process matches the file owner. The same pattern fixes UID mismatches on Linux hosts whose developers do not all share UID 1000.