Back to monorepo orchestration Target affected workspaces Configure turbo pipelines Speed up type-checking

Building Docker Images for One Workspace with turbo prune

Building a Docker image for one application in a monorepo usually starts badly: COPY . . sends the entire repository into the build context, pnpm install installs every package's dependencies, and any change anywhere in the repository invalidates the dependency layer, so every build reinstalls from scratch. turbo prune --docker fixes all three, and it does so without changing how the repository is organised or how developers work locally. It produces a minimal subset of the workspace containing only the target application and the internal packages it depends on, split into a manifests-only folder for the install layer and a full-source folder for the build layer. This guide builds a production Dockerfile around it and explains each layer's caching behaviour.

The problem with building from the whole repository

A naive Dockerfile for apps/api:

FROM node:22-slim
WORKDIR /repo
COPY . .
RUN corepack enable && pnpm install --frozen-lockfile
RUN pnpm turbo run build --filter=@acme/api
CMD ["node", "apps/api/dist/server.js"]

It works, and it has three costs that grow with the repository:

  1. Every change invalidates the install layer. Because COPY . . comes before pnpm install, editing a README in apps/web changes the layer's inputs, so Docker reinstalls every dependency.
  2. The image contains everything — every application's source and dependencies — making it large and widening its attack surface.
  3. The build context is huge, slowing every build before it starts.

The dependency layout that makes this hard is covered in Workspace Symlinks vs Hard Links.

What turbo prune produces

pnpm turbo prune @acme/api --docker
Output of turbo prune --docker The out directory contains a json folder with only package manifests, a full folder with complete source, and a pruned lockfile, all limited to the api app and its internal dependencies. out/ json/ manifests only: install layer package.json root apps/api/package.json packages/db/package.json internal dependency full/ full source: build layer apps/api/src/... and packages/db/src/... pnpm-lock.yaml pruned to the api's closure pnpm-workspace.yaml
json/ feeds the install layer; full/ feeds the build layer; the pruned lockfile covers only what the api needs.

Only packages in @acme/api's dependency graph are included — apps/web, packages/ui and their dependencies are gone — and the lockfile is pruned to match, so a frozen install succeeds with exactly the needed packages. The split between json/ and full/ is what makes Docker layer caching effective: manifests change rarely, source changes constantly.

A production Dockerfile

FROM node:22-slim AS base
ENV PNPM_HOME=/pnpm PATH=/pnpm:$PATH
RUN corepack enable

# 1. Prune the workspace to the target app
FROM base AS pruner
WORKDIR /repo
COPY . .
RUN pnpm dlx turbo@2 prune @acme/api --docker

# 2. Install dependencies from manifests only (cached until manifests change)
FROM base AS installer
WORKDIR /repo
COPY --from=pruner /repo/out/json/ .
COPY --from=pruner /repo/out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
    pnpm install --frozen-lockfile

# 3. Add source and build
COPY --from=pruner /repo/out/full/ .
RUN pnpm turbo run build --filter=@acme/api
RUN pnpm --filter @acme/api deploy --prod --legacy /out/api

# 4. Minimal runtime image
FROM node:22-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=installer /out/api ./
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

How the layers cache:

Docker layer caching with turbo prune The pruner stage copies the repository and prunes; the install layer depends only on manifests and the pruned lockfile; the build layer depends on source; the runtime stage copies only deploy output. pruner: COPY . . + prune cheap; output is deterministic reruns on any change, but only prunes install from out/json manifests + pruned lockfile cached unless deps change copy out/full + build source changes start here pnpm deploy --prod self-contained folder runtime: copy /out/api no source, no dev deps
Source edits rebuild from the build layer; only dependency changes reach the install layer.

The pruner stage copies everything and reruns whenever anything changes, but pruning is fast and its outputs only change when the API's dependency graph or source changes. Because the installer stage copies only out/json and the pruned lockfile, Docker reuses the install layer as long as those files are byte-identical — edits to apps/web or to the API's source never trigger a reinstall.

The final pnpm deploy step copies internal packages in as real files, so the runtime image needs no workspace structure at all. That step is covered in Deploying a Single Package with pnpm deploy.

Rebuild time after a source-only change Example Docker rebuild times after editing one API source file for a naive whole-repo Dockerfile and for the pruned multi-stage Dockerfile. COPY . . then install 212 s turbo prune, cached install 41 s
With pruning, a source change skips the dependency install entirely.

The build context and .dockerignore

Even with pruning inside the Dockerfile, the pruner stage starts with COPY . ., so whatever the build context contains is sent to the Docker daemon on every build. A good .dockerignore keeps that fast and prevents local artefacts from leaking into images:

**/node_modules
**/dist
**/.next
**/.turbo
**/coverage
.git
*.log
.env*

Excluding node_modules matters most: a host node_modules copied into a Linux image contains platform-specific binaries for the wrong operating system and breaks native packages at runtime. Excluding .env* files keeps local secrets out of image layers. Excluding .git shrinks the context, but note that tools which read git metadata during the build (for example, to embed a commit SHA) then need the value passed in as a build argument instead.

Building several applications from one repository

Repositories with several deployable applications repeat the same Dockerfile shape with a different target. Rather than copying the file, parameterise it with a build argument for the package name and use it in the prune and build steps:

ARG APP=@acme/api
RUN pnpm dlx turbo@2 prune ${APP} --docker

Then build each image with docker build --build-arg APP=@acme/web -f Dockerfile ., or describe all targets in a docker-bake.hcl file so docker buildx bake builds them in parallel with shared cache. Combine it with affected detection so CI only builds images for applications whose pruned graph changed: turbo run build --filter=...[origin/main] --dry=json lists affected packages, and a short script maps them to image targets.

Native dependencies and target platforms

Because dependencies are installed inside the image, native packages are built or downloaded for the image's platform, which is correct. Two details still need attention. If you build multi-architecture images with buildx (for example, linux/amd64 and linux/arm64), each platform runs its own install inside emulation or on native builders; emulated installs of large native dependencies are slow, so prefer native arm64 builders where available. And if your runtime image uses a different base from the build stage — Alpine at runtime, Debian for building — native binaries built against glibc will not load on musl. Keep the build and runtime stages on the same libc family.

Remote caching inside Docker builds

The turbo run build step inside the image can use a remote cache, so unchanged internal packages are replayed rather than rebuilt. Pass the credentials as build secrets rather than build arguments, which would be stored in image history:

RUN --mount=type=secret,id=turbo_token,env=TURBO_TOKEN \
    TURBO_TEAM=acme pnpm turbo run build --filter=@acme/api
docker build --secret id=turbo_token,env=TURBO_TOKEN -t acme/api .

Remote cache configuration is covered in Remote Caching Setup.

Worked example: a build that reinstalled on every commit

A team's API image took nearly four minutes to build on every commit, even for one-line changes, because the Dockerfile copied the whole repository before installing. After switching to turbo prune --docker with the four-stage layout above, source-only changes rebuild in about forty seconds — most of it the TypeScript build — and the runtime image shrank from 1.2 GB to 190 MB because it no longer contained the web applications or any dev dependencies. A CI step that runs the container and hits its health endpoint was added so a missing runtime file fails the build instead of the deploy.

Prevention and CI/CD guardrails

  • Copy only out/json before installing, so source edits cannot invalidate the dependency layer.
  • Use BuildKit cache mounts for the pnpm store so even a changed lockfile reinstalls quickly.
  • Pass cache tokens as build secrets, never as ARG values.
  • Smoke-test the image by starting it in CI before pushing.

Frequently Asked Questions

Does turbo prune work with npm and Yarn workspaces? Yes. It prunes package-lock.json and yarn.lock as well as pnpm-lock.yaml, using whichever the workspace declares.

Why not use pnpm deploy alone? pnpm deploy produces the runtime folder but still needs a full workspace to build from. turbo prune shrinks what goes into the build stage and makes the install layer cacheable; the two combine well.

Do I need the pruner stage, or can I prune outside Docker? You can run turbo prune in CI before docker build and use out/ as the build context, which also shrinks the context. The in-Dockerfile pruner is more self-contained; both work.

Why does the pruned lockfile differ from the original? It contains only the entries needed by the pruned packages. That is intentional: installing from it cannot pull in dependencies of applications that are not in the image. Never commit the pruned lockfile; it is a build artefact.

Can I use this with a remote cache but no Docker cache? Yes. Even when the install layer cannot be cached — for example, on ephemeral builders without layer caching — the remote cache still lets the build step replay unchanged internal packages, which is often the larger saving.

How do I include files outside package folders, such as a shared config? turbo prune copies the root manifest, workspace file and lockfile. Other root-level files used by the build — a base tsconfig, an ESLint config — must be copied explicitly in the build stage, or moved into a workspace package so pruning includes them automatically.

Related

CI/CD Pipeline Optimization for Monorepos