A brand-new contributor runs git clone and then waits fourteen minutes before the app answers on localhost — a first-build interval that is the single largest fixed cost inside your time-to-first-PR number. This guide treats that interval as a measurable quantity and drives it down with three levers that attack it independently: a prebuilt dev image so the fresh clone pulls instead of builds, warm dependency caches so no package manager resolves a tree from cold, and a parallel bootstrap so independent setup steps overlap instead of queueing.

Diagnostic

The fresh-clone cost is invisible on the maintainer's laptop because that machine has already paid it: the base image sits in the local Docker store, node_modules is populated, the pip wheel cache is warm, and BuildKit has every layer memoized. The only honest measurement reproduces the new-hire path — a pristine checkout in a directory that has never built this project, with the Docker builder and every host cache purged first. Time the whole bootstrap, then break it into phases so you know which stage owns the minutes.

#!/usr/bin/env bash
set -euo pipefail
# fresh-clone-timer.sh — measure bootstrap from a genuinely cold state.
work=$(mktemp -d)
docker builder prune -af >/dev/null 2>&1 || true
docker image rm ghcr.io/org/app-dev:latest >/dev/null 2>&1 || true
git clone --quiet "$(git remote get-url origin)" "$work/app"
start=$(date +%s)
make -C "$work/app" bootstrap >/dev/null
echo "first_build_seconds=$(( $(date +%s) - start ))"
rm -rf "$work"

A single total tells you a problem exists but not where the time went, so attribute each phase before you touch anything. Wrap every stage of the bootstrap in the same date arithmetic and print one line per phase; the phase that dominates decides which lever below you pull first.

#!/usr/bin/env bash
set -euo pipefail
# phase-timer.sh — attribute fresh-build seconds to each stage.
phase() { local label=$1; shift; local s=$(date +%s); "$@" >/dev/null 2>&1; \
  echo "${label}_seconds=$(( $(date +%s) - s ))"; }
phase image_build docker compose build
phase deps_install npm ci
phase asset_build  npm run build
phase stack_up     docker compose up -d --wait

Expected BAD output — the build and install phases dominate, and every phase ran back-to-back:

image_build_seconds=402
deps_install_seconds=286
asset_build_seconds=101
stack_up_seconds=51
first_build_seconds=840

The phase timings are the whole diagnosis. image_build at 402 seconds means the fresh clone is compiling an image from a Dockerfile that the maintainer never rebuilds; deps_install at 286 means the package manager is resolving and downloading a tree from cold; and the fact that the four numbers sum to the total means nothing overlapped. Each of those is a separate lever, and the chart below shows how the measured seconds distribute across them.

Fresh-build seconds by phase Horizontal bar chart of measured seconds for image build, dependency install, asset build, and stack startup. Fresh-Build Time by Phase (seconds) image build 402s deps install 286s asset build 101s stack up 51s
Image build and dependency install own 82% of the 840-second fresh build — the two phases the prebuilt image and warm cache target directly.

Root cause

A fresh clone is slow because it does from scratch, and in strict sequence, work that the rest of the team never repeats. Three properties of the default setup path compound. First, the image is built rather than pulled: the Dockerfile is evaluated on every new machine, so base layers are re-fetched, system packages reinstalled, and any RUN that compiles a native extension runs in full. Second, the caches are cold: npm ci or pip install resolves the lockfile against an empty local cache and downloads every artifact over the network, and BuildKit has no prior layer to reuse because the builder was pristine. Third, the bootstrap is serial: make bootstrap runs image build, then install, then asset compile, then startup, so phases that share no data still wait for one another even though a modern laptop has cores sitting idle.

None of the three is visible on a warm machine, which is why they survive code review. The maintainer who wrote the Dockerfile has its layers cached; the engineer who added a dependency already downloaded it; the person who wrote make bootstrap never notices the serial ordering because their warm run finishes in seconds regardless. The measurement above is the only thing that surfaces the cost, and each of the three properties maps to exactly one lever: pull instead of build, prime the cache instead of resolving cold, and overlap instead of queue. The comparison below contrasts the untouched cold path against the primed path the resolution produces.

Cold clone path versus primed clone path Comparison of the default from-scratch serial bootstrap against a prebuilt, cached, parallel bootstrap. Cold Path vs Primed Path Default cold path image built from scratch caches resolved from empty phases run one after another idle cores, full network pull ≈ 840s Primed path image pulled prebuilt deps baked and cache-mounted independent steps overlap cores saturated, few misses ≈ 150s
The three properties of the cold path — built, cold, serial — each invert into one lever of the primed path.

Resolution

  1. Publish a prebuilt dev image so a fresh clone pulls layers instead of building them.
  2. Bake dependencies into that image and add a BuildKit cache mount so any residual build reuses work.
  3. Parallelize the independent bootstrap steps so image pull, host install, and codegen overlap.
  4. Re-time a fresh clone to confirm the interval dropped.

Start by making the image a pulled artifact. Build it once in CI, tag it by digest, and reference that digest from Compose so the contributor's docker compose up fetches a finished image rather than evaluating the Dockerfile. Keep a build block as a fallback for anyone who needs to rebuild, but wire cache_from to the same registry so even that path is warm. This is the local-rebuild discipline covered in optimizing Docker Compose for fast local rebuilds, applied to the very first build instead of the hundredth.

# docker-compose.yml — pull a prebuilt image; build only as a warm-cache fallback
services:
  app:
    image: ghcr.io/org/app-dev@sha256:2b1e9c4a7f0d6e3b8c5a1f4d7e0b3c6a9d2f5e8b1c4a7f0d3e6b9c2a5f8e1b4d
    build:
      context: .
      cache_from:
        - ghcr.io/org/app-dev:buildcache
    depends_on:
      db:
        condition: service_healthy
    ports:
      - "3000:3000"
  db:
    image: postgres:16-alpine
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U dev"]
      interval: 5s
      retries: 5

Next, make the image carry a warm cache instead of an empty one. Bake the resolved dependency tree into a builder stage keyed on the lockfile, and expose a BuildKit cache mount so the one command that still has to run — an incremental npm ci when the lockfile moved — replays from a persistent cache rather than the network. The --mount=type=cache line survives across builds on the same machine, and the baked deps layer ships inside the pulled image, so a fresh clone inherits both.

# syntax=docker/dockerfile:1.7
FROM node:20-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci --prefer-offline --no-audit

FROM node:20-bookworm-slim AS dev
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
CMD ["npm", "run", "dev"]

Then collapse the serial bootstrap. The three heaviest cold steps — pulling images, installing any host-side tooling, and running codegen or asset build — touch different resources (network, disk, CPU) and share no inputs, so they can run concurrently and be joined with wait. Only after they finish do you bring the stack up and apply migrations, because those depend on the earlier steps. docker compose up -d --wait then starts the services and blocks until every healthcheck passes, so the script returns exactly when the app is reachable.

#!/usr/bin/env bash
set -euo pipefail
# make bootstrap — overlap independent setup, then start the stack.
docker compose pull --quiet & pull_pid=$!
npm ci --prefer-offline --no-audit & deps_pid=$!
npm run codegen & gen_pid=$!
# Fail fast if any parallel job failed, preserving its exit code.
for pid in "$pull_pid" "$deps_pid" "$gen_pid"; do wait "$pid"; done
docker compose up -d --wait
docker compose exec -T app npm run migrate
echo "app ready on http://localhost:3000"

Finally, shrink the clone itself for very large histories. A blobless partial clone fetches commit and tree objects immediately and defers file blobs until checkout, which cuts the transfer for repositories with heavy binary history without changing the working tree the contributor ends up with.

#!/usr/bin/env bash
set -euo pipefail
git clone --filter=blob:none "[email protected]:org/app.git"
Three levers of the primed bootstrap A left-to-right flow from pulling a prebuilt image, through a warm baked cache, to a parallel bootstrap that ends at a ready app. Pull, Warm, Parallelize Pull image prebuilt by digest Warm cache baked + cache mount Parallel bootstrap overlap then --wait Each lever removes one property of the cold path independently.
The three levers are additive: apply the prebuilt image alone and the build phase vanishes; add the warm cache and install collapses; parallelize and the remainder overlaps.

Expected output

After the three levers, a fresh clone pulls a finished image, inherits a warm cache, and overlaps the residual work. The phase timer shows the build phase gone, install reduced to a cache replay, and the parallel wall-clock far below the sum of its parts:

image_pull_seconds=44
deps_install_seconds=19
asset_build_seconds=0
stack_up_seconds=48
first_build_seconds=151

asset_build_seconds=0 because codegen ran concurrently under the & job and was already joined before the stack came up; deps_install_seconds=19 is a cache replay rather than a cold resolve; and first_build_seconds=151 is well under the sum of the phases because the independent steps overlapped. The 840-second cold build is now roughly two and a half minutes, and the entire difference is auditable phase by phase against the diagnostic you recorded first.

Prevention

  1. Rebuild and push the prebuilt dev image from CI whenever the lockfile or Dockerfile changes, so the published digest never lags the source.
  2. Reference the image by @sha256: digest in Compose, not a floating tag, so every clone provisions the identical warm artifact and any drift is a reviewed commit.
  3. Gate first-build time in CI with the fresh-clone timer, failing the build above an SLA, exactly as you would for a time-to-first-PR regression after a dependency upgrade.

The image-publishing job is the load-bearing piece: if the prebuilt image is not rebuilt when the lockfile moves, the fresh clone pulls a stale image and then re-installs the delta on every startup, quietly reintroducing the cold-install cost. Trigger the rebuild on the paths that invalidate it and push the digest that Compose references.

# .github/workflows/publish-dev-image.yml — keep the prebuilt image current
name: Publish Dev Image
on:
  push:
    paths: ["package-lock.json", "Dockerfile", ".github/workflows/publish-dev-image.yml"]
jobs:
  build-push:
    runs-on: ubuntu-latest
    permissions:
      packages: write
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ghcr.io/org/app-dev:latest
          cache-from: type=registry,ref=ghcr.io/org/app-dev:buildcache
          cache-to: type=registry,ref=ghcr.io/org/app-dev:buildcache,mode=max

Pair the publish job with a scheduled first-build measurement on a clean runner. Because CI runners are warm-cache friendly by design, the timer must purge the builder and remove the pulled image first — otherwise it measures the runner's happy path and never catches the regression a new hire would hit. Keeping the image current and the timer honest is what makes the two-minute build a property you keep rather than one you rediscover. To connect the fresh-build number back to the onboarding funnel it feeds, wire the timer's output into your onboarding health-check script so a contributor sees a slow first build before it costs them an afternoon.

Decide whether to pull or rebuild the dev image A decision on whether the published image digest matches the lockfile, leading to a fast pull or a triggered rebuild. Is the Published Image Current? Digest matches lockfile? checked in CI on push Yes clone pulls a warm image No rebuild, push, bump digest
A stale digest is the one failure that silently reintroduces cold-install cost, so the publish job keeps it current.

Platform caveats

macOS (Docker Desktop): the file-sharing layer taxes bind mounts, so a baked node_modules inside the image beats a host-mounted one for first build; keep dependencies in the image and mount only source. Confirm the prebuilt image carries an arm64 layer or the pull falls back to an emulated amd64 build that erases the win. WSL2: clone into the Linux filesystem, never /mnt/c — a fresh npm ci and image extraction over the Windows mount inflates first build several-fold and defeats the warm cache entirely. Apple Silicon (ARM64): publish a multi-arch image with docker buildx build --platform linux/amd64,linux/arm64 so Apple-silicon contributors pull a native image instead of compiling under emulation on their first build.

Rollback

If the prebuilt image or parallel bootstrap misbehaves — a corrupt pushed layer, or a race exposed by overlapping steps — fall back to the from-scratch serial path in one step by forcing a local build and a sequential bootstrap:

#!/usr/bin/env bash
set -euo pipefail
docker compose build --no-cache app   # ignore the prebuilt image, build locally
BOOTSTRAP_PARALLEL=0 make bootstrap    # run phases sequentially via the guard flag

Frequently Asked Questions

Why is the fresh build slow when it is fast on my machine?

Your machine is warm. The base image is in your local Docker store, node_modules is populated, the package cache is full, and BuildKit has every layer memoized, so the build, resolve, and download steps never run. A fresh clone starts from an empty builder and empty caches, so all of them execute in full and in sequence. Measure with the fresh-clone timer — a temp directory, docker builder prune -af, and the pulled image removed — or you will never observe the interval a new hire actually pays.

Should I bake dependencies into the image or mount a cache volume?

Bake them into the image for the first build and use a BuildKit cache mount for the incremental case. A baked node_modules layer ships inside the pulled image, so a fresh clone inherits a warm tree with zero install. A --mount=type=cache persists across builds on the same machine and covers the one command that still runs when the lockfile moved — an incremental npm ci that replays from cache instead of the network. The two are complementary: baking wins the cold first build, the cache mount wins every rebuild after.

Is parallelizing the bootstrap safe, or does it introduce races?

It is safe as long as you only overlap steps that share no inputs. Pulling images, installing host tooling, and running codegen touch different resources and no shared state, so they run concurrently and join with wait. Steps with a dependency edge — bringing the stack up, then migrating — must stay ordered, which is why the script joins the parallel jobs before docker compose up -d --wait and runs migrations last. The for pid in ...; do wait "$pid"; done loop also propagates any failed job's exit code, so a broken parallel step fails the bootstrap instead of being silently ignored.

How do I keep the prebuilt image from going stale?

Rebuild and push it from CI on every change to the lockfile or Dockerfile, and reference it by @sha256: digest rather than a floating tag. A digest pin means a fresh clone provisions the exact warm artifact CI published, and a lockfile change that has not yet been rebuilt is visible as a digest that no longer matches — a reviewed commit, not a silent drift. Without the publish-on-change job, the image lags the source and the clone re-installs the delta on startup, quietly reintroducing the cold-install cost the image was meant to remove.