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:
- Every change invalidates the install layer. Because
COPY . .comes beforepnpm install, editing a README inapps/webchanges the layer's inputs, so Docker reinstalls every dependency. - The image contains everything — every application's source and dependencies — making it large and widening its attack surface.
- 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
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 /repo/out/json/ .
COPY /repo/out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN \
pnpm install --frozen-lockfile
# 3. Add source and build
COPY /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 /out/api ./
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
How the layers cache:
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.
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 \
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/jsonbefore 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
ARGvalues. - 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 covers CI speed techniques beyond Docker.
- Deploying a Single Package with pnpm deploy produces the runtime folder used in the last stage.
- Turborepo Pipeline Configuration defines the build task run inside the image.
- Fixing Missing Platform Binaries in optionalDependencies explains why dependencies must be installed inside the image.