NestJS Dockerfile: Multi-Stage Builds for Production

Build a NestJS Dockerfile with multi-stage builds, npm ci or pnpm, Prisma, a smaller runtime image, a non-root user, health checks and a CI workflow.

A NestJS service can be easy to run on a laptop and awkward to ship. A naive container may include the TypeScript compiler, test tools, the full source tree, and credentials that never belonged in an image. It can also rebuild dependencies after a one-line code change. When I review a NestJS Dockerfile, I look for those problems before shaving megabytes.

In this guide, I build a production-oriented image for a conventional NestJS application using npm and a committed package-lock.json. The example uses Node 22 on Debian Bookworm slim, compiles TypeScript into dist, keeps development packages out of the runtime dependency tree, and runs as the image's unprivileged node user. I also cover BuildKit caching, health checks, Compose, secrets, a pnpm variant, Prisma and CI.

The goal is a repeatable release, not a clever image-size trick.

Part of my guide to deploying Node.js to production.

TL;DR#

  • Use separate dependency, build, and runtime stages so compilers and test tools do not ship.
  • Copy the package manifests before application source to keep dependency installation cacheable.
  • Measure the naive and multi-stage images on the same builder before claiming a size win.
  • Start from a lockfile with npm ci, then prune development packages before assembling the runtime image.
  • Run as non-root, keep secrets out of build context and layers, and handle SIGTERM deliberately.
  • Add a real health endpoint, scan the final image, and verify its size and startup behavior in CI.

Why a Naive NestJS Dockerfile Is a Problem#

The shortest Dockerfile is not always the simplest release. This single-stage example is common in prototypes because it is easy to understand, but it mixes build-time and run-time concerns:

FROM node:22-bookworm-slim
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["npm", "run", "start:prod"]

With a typical Nest CLI project, npm run build compiles TypeScript and leaves output under dist. However, the image still has the source tree, test files, compiler, Nest CLI, and all development dependencies. COPY . . may also send local node_modules, .git, coverage output, and accidentally included environment files unless the build context is carefully excluded. Installing after copying everything means a source edit can invalidate the dependency install layer.

The base image defaults to root unless the Dockerfile changes users. Running an npm script as PID 1 can also complicate signal delivery, especially if a shell wrapper is introduced. Finally, npm install may resolve a different dependency tree from the committed lockfile. The result can be a large, less reproducible image with a broader attack surface. Build it once and note its size before changing anything: that's the baseline every later improvement is measured against.

A production NestJS Dockerfile should make build and runtime boundaries explicit rather than relying on one broad COPY.

What Are Multi-Stage Builds (and Why They Matter for NestJS)#

A multi-stage build lets Docker use one stage to install and compile everything, then copy only selected outputs into a later stage. Each FROM starts a new stage. Files are not automatically carried forward: a COPY --from=build is an explicit choice about what belongs in the next image. Docker documents this pattern in its multi-stage builds guide.

For NestJS, TypeScript and the Nest CLI are needed to produce JavaScript, but Node.js and production dependencies are generally what the running service needs. Keeping those roles separate is the core of a NestJS Docker multi-stage build. The same separation helps reduce Docker image size and makes it easier to audit the final filesystem.

package.json + package-lock.json
              |
              v
     dependencies stage ----> development target (local only)
              |
              v
       build stage: source -> dist/
              |
              v
 production-dependencies: remove dev packages
              |
              v
 runtime: Node + production node_modules + dist/

The final image is not automatically secure or small just because it has multiple stages. Anything copied into runtime still ships, and a build stage can still leak secrets through logs or exported artifacts. I treat stage boundaries as a way to make the contents intentional, then inspect the resulting image to confirm the boundary did what I expected.

Prerequisites and Project Setup#

The Dockerfile below assumes a standard NestJS project with package.json, package-lock.json, nest-cli.json, tsconfig*.json, and src/main.ts at the build context root. The usual scripts include build: nest build, start:prod: node dist/main, and start:dev: nest start --watch. Confirm the actual compiled entry point before using the final command: monorepos and customized TypeScript output may place it at dist/apps/api/main.js or another path.

The example uses Node 22 because it is an LTS line, on the official Debian slim image. For your service, check its .nvmrc, engines field, native modules, and deployment platform, then keep build and runtime images on compatible Node and operating-system versions. The example uses npm because npm ci installs exactly what the committed lockfile says. For pnpm or Yarn, use that project's committed lockfile and frozen-install command instead.

Add a .dockerignore beside the Dockerfile so unnecessary files never enter the build context:

node_modules
dist
.git
.github
coverage
.nyc_output
*.log
.env
.env.*
!.env.example

Keep .env.example limited to safe dummy values. A small context improves remote-builder uploads and prevents local artifacts from overriding container-generated files. See Docker's build context documentation.

Writing a Production-Ready NestJS Dockerfile#

Here is the complete Dockerfile for a conventional npm project; each stage is explained below it. The development target is for Compose only. Check the service's scripts, generated files, and compiled entry path before release, especially for monorepos, Prisma, or native modules.

# syntax=docker/dockerfile:1.7
FROM node:22-bookworm-slim AS dependencies
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci

FROM dependencies AS development
ENV NODE_ENV=development
COPY . .
CMD ["npm", "run", "start:dev"]

FROM dependencies AS build
COPY . .
RUN npm run build

FROM build AS production-dependencies
ENV NODE_ENV=production
RUN npm prune --omit=dev

FROM node:22-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=production-dependencies --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/package.json ./package.json
COPY --from=build --chown=node:node /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/main.js"]

Stage 1: Install Locked Dependencies#

The dependency stage copies only npm manifests before npm ci. That order lets Docker reuse the install layer when source changes but dependencies do not. npm ci also fails if the lockfile and manifest disagree, avoiding an unnoticed dependency change during release.

The BuildKit cache mount retains npm downloads between builds without adding them to the image. It requires a BuildKit-capable builder; Docker Buildx provides one. The # syntax directive on the first line enables this Dockerfile feature.

Stage 2: Compile TypeScript#

The build stage copies source and runs the project build script. A default Nest CLI project emits JavaScript under dist. Run tests and lint in CI before publishing the image.

Stage 3: Keep Only Runtime Dependencies#

The production-dependencies stage runs npm prune --omit=dev, removing packages listed only in devDependencies while retaining generated artifacts in node_modules. Verify package install scripts and generated clients; Prisma needs special care, covered below.

Stage 4: Assemble the Runtime Image#

The final stage copies only node_modules, package.json, and dist onto a fresh Node image, then switches to the built-in node user. Exec-form CMD sends signals directly to Node. EXPOSE documents a port; it does not publish it.

Build and Run It#

Build from the directory containing the manifests and Dockerfile, and confirm the entry path first:

docker build --target runtime -t nest-api:local .
docker run --rm --init -p 3000:3000 --env-file .env nest-api:local

A project with dist/src/main.js must change the command. Add narrow COPY instructions for required templates, migrations, or assets; do not copy the entire repository into runtime to hide a missing-file problem.

Optimizing Image Size and Build Speed#

Measure before choosing a base image. node:alpine uses musl libc, so native binaries may need compatible builds or extra tools. node:slim uses Debian's glibc and is a practical default. Distroless images include fewer general-purpose tools but make debugging and dependency assembly more deliberate. Test the Node release, architecture, and native modules; see the Node Docker best-practices guide.

BuildKit cache mounts reuse npm downloads; Docker layer caching reuses installation when lockfiles are unchanged. .dockerignore reduces context transfer. These speed builds, while multi-stage builds and production-only dependencies reduce runtime contents.

To see what the multi-stage build actually saves on your service, save the naive Dockerfile from the top of this post as Dockerfile.naive and build both from the same commit, on the same machine and architecture:

# --no-cache on both, so neither build reuses the other's layers.
time docker build --no-cache -f Dockerfile.naive -t nest-api:naive .
time docker build --no-cache --target runtime -t nest-api:multi .

docker image ls nest-api        # the size of each image
docker history nest-api:multi   # which layers account for it

Compare the local sizes docker image ls reports with each other, not with the compressed size a registry shows. If a dependency turns out to be missing at runtime, it belongs in dependencies, not devDependencies.

Using pnpm instead of npm#

The structure stays the same: install from the lockfile, build, install production dependencies only, then copy them into a clean runtime stage. pnpm's own Docker guide keeps its store in a BuildKit cache mount, as below:

# syntax=docker/dockerfile:1.7
FROM node:22-bookworm-slim AS base
RUN corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml ./

FROM base AS dependencies
RUN --mount=type=cache,id=pnpm,target=/var/cache/pnpm \
    pnpm install --store-dir /var/cache/pnpm --frozen-lockfile

FROM dependencies AS build
COPY . .
RUN pnpm run build

FROM base AS production-dependencies
RUN --mount=type=cache,id=pnpm,target=/var/cache/pnpm \
    pnpm install --store-dir /var/cache/pnpm --prod --frozen-lockfile

FROM node:22-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=production-dependencies --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/package.json ./package.json
COPY --from=build --chown=node:node /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/main.js"]

Three details that trip people up:

  • Pin the pnpm version. Corepack installs whatever version the packageManager field in package.json names, so CI and the image use the same pnpm as your laptop. Corepack ships with Node 22, but newer Node releases stop bundling it; there, install pnpm explicitly or start from pnpm's official image.
  • Copy pnpm-workspace.yaml too, if you have one, before the install. It holds settings and workspace patterns, and a frozen install can fail without it.
  • Production dependencies come from a fresh --prod install, not from pruning, so the runtime stage starts from base rather than from build.

In a monorepo, pnpm deploy --filter=<app> --prod <dir> copies one app with only the packages it needs, which is pnpm's answer to npm prune across a workspace.

Security Hardening#

A production-ready Dockerfile is one part of container security. Run as non-root, keep the final filesystem small, and avoid tools the service does not need. Where supported, use a read-only root filesystem and grant writable mounts only to required paths; test this with the real application.

Never pass production credentials through ARG, ENV, a copied .env, or a command that writes them into a layer. Supply them at runtime through the platform's secret mechanism. For private package credentials during builds, use BuildKit secret mounts.

Tags such as node:22-bookworm-slim can move. Pin a tested digest where release control requires it, and automate deliberate updates. Scan the final image with Trivy or Docker Scout; review package presence, severity, reachability, and fixes. A clean scan is not proof of security.

Graceful Shutdown, Health Checks, and Signal Handling#

Orchestrators normally send SIGTERM before force-stopping a container. Nest lifecycle hooks can close database and Redis clients; enable signal handling with enableShutdownHooks() and keep shutdown work within the platform's grace period.

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.enableShutdownHooks();
  await app.listen(process.env.PORT ?? 3000, '0.0.0.0');
}

See Nest's lifecycle events and deployment guide. Exec-form CMD avoids a shell wrapper. Use Docker --init or Compose init: true if child processes need reaping.

Expose a readiness endpoint with NestJS Terminus. Check dependencies that determine whether the service can respond, without doing expensive work on every probe:

HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
  CMD ["node", "-e", "fetch('http://127.0.0.1:3000/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]

This needs Node 22's built-in fetch and a real /health route. Health status alone does not guarantee a restart; configure platform probes and replacement policy too.

Docker Compose for Local Development#

Compose is useful for starting an API with PostgreSQL and Redis without baking local data services into the API image. The development target from the Dockerfile contains the Nest CLI and runs watch mode. Bind-mounting the source makes edits visible, while the separate /app/node_modules volume avoids replacing Linux container dependencies with host dependencies.

services:
  api:
    build:
      context: .
      target: development
    command: npm run start:dev
    init: true
    ports:
      - '3000:3000'
    env_file: .env
    environment:
      DATABASE_URL: postgres://app:${POSTGRES_PASSWORD}@db:5432/app
      REDIS_URL: redis://redis:6379
    volumes:
      - .:/app
      - /app/node_modules
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: app
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U app -d app']
      interval: 5s
      timeout: 3s
      retries: 10
  redis:
    image: redis:7
    healthcheck:
      test: ['CMD', 'redis-cli', 'ping']
      interval: 5s
      timeout: 3s
      retries: 10
volumes:
  postgres-data:

Create a local .env with a development-only POSTGRES_PASSWORD before starting Compose; never commit it. Pin database image versions appropriate to the app and plan upgrades rather than treating these sample tags as immutable. Compose's health-based dependency condition helps local startup ordering, but the NestJS app should still retry transient database connections because containers can restart after startup.

Handling Environment Variables and Secrets#

Use @nestjs/config to validate required values at startup and document optional defaults. Read environment-specific settings at runtime so the same image can move through environments.

Use .env for local Compose only, not as a production secret store. Restrict secret access, rotate exposed credentials, and keep values out of logs and AI prompts. Non-secret settings such as a port can remain ordinary runtime environment variables.

Prisma and TypeORM in Docker#

Prisma's generated client and engine must match the runtime OS and architecture. Generate after copying schema.prisma, then verify the client and engine survive pruning into runtime. Run production migrations as a controlled deployment step rather than from every replica's startup; running database migrations safely in CI/CD shows how.

With the prisma-client-js generator, the query engine is a native binary built for one OS and OpenSSL version. On node:22-bookworm-slim, that's Debian with OpenSSL 3, so ask for that target as well as your laptop's:

generator client {
  provider      = "prisma-client-js"
  binaryTargets = ["native", "debian-openssl-3.0.x"]
}
# In the build stage, after COPY . .
RUN npx prisma generate && npm run build

On Alpine, the targets are linux-musl-openssl-3.0.x and linux-musl-arm64-openssl-3.0.x instead (Prisma's schema reference lists them all). If Prisma warns at startup that it couldn't detect the OpenSSL version, install openssl in the runtime stage with apt-get install -y --no-install-recommends openssl. The newer prisma-client generator, the default in Prisma ORM 7, has no binaryTargets setting at all, so check Prisma's upgrade guide for what it needs at runtime before copying this.

Keep prisma (the CLI) in devDependencies and @prisma/client in dependencies. Pruning then drops the CLI but keeps the client, which is exactly what runtime needs, unless you run migrations from the same image, in which case the CLI has to stay.

For TypeORM, include compiled migration files and metadata if runtime migrations need them. Whichever ORM you use, test the final image against a disposable database to catch missing files and native-library mismatches.

Building and Pushing the Image in CI#

CI should build the deployed Dockerfile, tag images with a commit SHA, scan them, and publish only from a trusted branch. Scope registry permissions and never expose push credentials to untrusted pull requests.

- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
  with:
    registry: ghcr.io
    username: ${{ github.actor }}
    password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: ghcr.io/example/nest-api:${{ github.sha }}
    cache-from: type=gha
    cache-to: type=gha,mode=max

Replace the registry path and scope permissions to the minimum. Build and test, scan the image, then publish; use a protected environment for production deployment. For cache strategy and reusable jobs, see my GitHub Actions pipeline optimization guide and Docker layer caching in GitHub Actions; to release the image to AWS, see deploying to EC2 with GitHub Actions.

Common Mistakes and Lessons Learned#

An image build can pass while runtime lacks a template, Prisma engine, migration, or entry point. Run the final image with production dependencies and representative configuration, then exercise its health endpoint before publishing.

Do not copy macOS node_modules into Linux. Native dependencies may target the wrong OS or architecture. Install them in a compatible container environment and keep build and runtime base families aligned.

Do not optimize only for a smaller dashboard number. Distroless changes debugging; Alpine can complicate native modules; broad cache keys can retain stale assumptions. Choose the simplest option meeting measured size, security, and operations needs.

Avoid unbounded migrations and retry loops at startup. Keep startup predictable, run migrations deliberately, and set bounded shutdown and dependency-retry behavior.

If an AI assistant drafts the Dockerfile, review it for the same mistakes: an outdated base image, COPY . . before the install, a root user, a shell-form CMD, or Alpine for a module that needs glibc. Build it from a clean checkout and run the image before trusting it.

A Production Checklist for Your NestJS Dockerfile#

  • The image uses the Node version supported by the service and its native dependencies.
  • package-lock.json is committed and dependencies install with npm ci.
  • .dockerignore excludes host node_modules, .git, build output, and secrets.
  • Build dependencies and source are absent from the final runtime image.
  • The compiled entry point and required runtime assets exist in the final stage.
  • The process runs as a non-root user and starts with exec-form CMD.
  • Secrets are provided at runtime and never copied into layers.
  • Shutdown hooks close resources within the platform's termination grace period.
  • A health endpoint checks meaningful service readiness without doing expensive work.
  • The final image is scanned and the findings are reviewed, not blindly suppressed.
  • The image is tested from a clean build and tagged with an immutable commit reference.
  • Image size and build time are measured on the same builder before and after changes.

FAQ#

How do I write a Dockerfile for a NestJS app?#

Install locked dependencies with npm ci, compile the service with its Nest build script, and copy only the compiled output and production dependencies into a fresh runtime stage. Confirm the actual entry file under dist, run as a non-root user, and test the built image with the same required runtime configuration it will receive after deployment.

Why use multi-stage builds for NestJS?#

NestJS needs TypeScript and the Nest CLI to compile an application, but the running service usually needs only Node.js, production dependencies, and compiled JavaScript. Multi-stage builds let the image use build tools temporarily and leave them out of the final stage, improving image contents and reducing the packages that need security updates.

Which base image is best for NestJS: Alpine, slim, or distroless?#

There is no universal winner. Debian slim is a practical starting point when native Node packages expect glibc. Alpine uses musl and can be smaller, but native modules need compatibility checks. Distroless reduces included tools and requires more deliberate debugging. Test the exact dependencies, architecture, and operations your service needs.

How do I reduce the size of a NestJS Docker image?#

Use a multi-stage build, install with the lockfile, prune development dependencies, exclude local files through .dockerignore, and copy only runtime assets into the final stage. Measure the result with the same architecture and builder before and after. Do not remove required native libraries or generated clients just to improve the displayed size.

Should I run NestJS as root in Docker?#

Usually not. Create or use an unprivileged runtime user and grant write access only to directories the application needs. Running as non-root limits what a compromised process can change inside the container, although it does not replace least-privilege networking, secret controls, dependency updates, or host isolation.

How do I use pnpm in a NestJS Dockerfile?#

Enable Corepack in the base image so it installs the pnpm version named in package.json's packageManager field. Install with pnpm install --frozen-lockfile in the build stage, run a separate pnpm install --prod --frozen-lockfile for the runtime dependencies, and keep pnpm's store in a BuildKit cache mount so rebuilds don't download everything again.

How do I use Prisma in a NestJS Docker image?#

Run prisma generate in the build stage after copying the schema, and make the generated engine match the runtime image: with the prisma-client-js generator, add debian-openssl-3.0.x to binaryTargets for Debian slim images, or the linux-musl targets for Alpine. Keep @prisma/client in dependencies so pruning doesn't remove it.

Conclusion#

A dependable NestJS container starts with a reproducible dependency install, a clear build boundary, and a runtime stage containing only what the service needs. Multi-stage builds make that structure visible; they do not replace measurement, security review, or deployment testing. Begin with the Dockerfile in this guide, adapt its paths and Node line to your actual service, and record real size and build-time results before claiming an improvement.

For a deployment pattern that pairs Docker with a blue-green release, read my zero-downtime Node.js deployment checklist. You can also browse my projects or connect with me through the contact details on my site.

Muhammad Huzaifa Abdulahad
Written by

Muhammad Huzaifa Abdulahad

Full-Stack Software & DevOps Engineer

I build SaaS platforms with Node.js, NestJS, TypeScript and PostgreSQL, and run them on Linux, Docker, AWS and CI/CD — from the first line of code to production.

Related articles

See all posts →

Have a complex idea? Let's build it together.

From MVPs to scalable products, let's turn your idea into reliable software built for real-world impact.