Skip to content
ExponaBLOG
NESTJS · DAY 078 / 100

Dockerizing a NestJS app: a hands-on guide for engineers

Small images, fast builds, secure defaults.

Expona Admin7 min read

The challenge of shipping NestJS in containers

Getting a NestJS application from your laptop into production via Docker shouldn't mean shipping a 500MB image with build tools you'll never use at runtime. Dockerizing a NestJS app: a hands-on guide for engineers means striking the balance between build speed, image size, and production safety—without cutting corners on security or observability.

We've seen too many teams either bloat their images with unnecessary dependencies or strip them down so aggressively that they break at 3am in production. In this guide, we'll walk through the patterns that work: multi-stage builds, smart caching, Prisma engine handling, and running containers as non-root users. These aren't optional niceties—they're the foundation of reliable containerized applications.

Multi-stage builds: separate concerns, shrink images

A multi-stage Dockerfile treats the build process and runtime environment as distinct phases. In the first stage, we compile TypeScript, run tests, and install all dependencies including dev tools. In the second, we copy only what's needed—the compiled JavaScript and production dependencies—and discard everything else.

Here's a concrete example:

typescript
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev

FROM node:22-alpine
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package*.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/main.js"]

The first stage (named build) handles everything: it installs dependencies, transpiles TypeScript via npm run build, and uses npm prune --omit=dev to remove development-only packages like @nestjs/testing, typescript, and ESLint. The second stage starts fresh with a clean Alpine image and copies only the dist folder, pruned node_modules, and package files.

This approach typically cuts image size by 60–70%. A full-stack image might be 800MB; after multi-staging, you're often at 200–250MB. For teams running dozens of container instances, that's real bandwidth and storage savings, plus faster deploys and rollbacks.

One detail: always use COPY --from=build to reference the earlier stage by name. If you only copy /app wholesale, you'll accidentally include build artifacts and cache that have no place in production.

Cache dependencies: order matters

Docker builds run layer by layer, caching each step. If your build step changes only your application code, but you've copied the entire source tree before running npm ci, Docker will re-run the install even though package-lock.json hasn't moved. That's wasted time on every build.

Reorder your Dockerfile so that package files arrive first:

typescript
FROM node:22-alpine AS build
WORKDIR /app

# Copy package files first
COPY package*.json ./

# Install dependencies (cached unless package files change)
RUN npm ci

# Copy source code (changes frequently)
COPY . .

# Build and prune
RUN npm run build && npm prune --omit=dev

Now, when you edit src/users/users.service.ts, Docker's layer cache skips the npm ci step and jumps straight to COPY . . and RUN npm run build. On a typical development loop—edit, commit, push to CI, build—you'll see build times drop from 3–4 minutes to 30–45 seconds.

This is especially valuable in CI/CD pipelines where you run dozens of builds per day. It's also why teams using monorepos with shared dependencies benefit enormously: a single cached node_modules layer can be reused across multiple service images.

Prisma engines: match the build platform to the runtime

Prisma bundles native binary engines for database access. If you're building on macOS (arm64) but deploying to Linux (x86_64), Prisma will try to load the wrong engine at runtime, and your app will crash with an obscure error about missing libraries.

The safest approach is to run prisma generate in the build stage, ensuring the engines are compiled for the Linux target:

typescript
FROM node:22-alpine AS build
WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .

# Generate Prisma client for Linux/x86_64 before build
RUN npx prisma generate

RUN npm run build && npm prune --omit=dev

FROM node:22-alpine
WORKDIR /app

COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package*.json ./

# Include the generated Prisma client
COPY --from=build /app/prisma ./prisma

USER node
EXPOSE 3000
CMD ["node", "dist/main.js"]

The key steps here: call npx prisma generate in the build stage after dependencies are installed but before compilation. Then copy the generated .prisma/client directory (and the original prisma schema folder) to the runtime stage.

If you're using a custom engine binary location (via PRISMA_ENGINES_CHECKPOINT_MIRROR or a corporate mirror), ensure it's set consistently across both stages. Better yet, bake it into your .npmrc or Docker build arguments so there's a single source of truth.

Running as non-root: one line, serious security

By default, Docker containers run as root. If an attacker gains access to your container, they have system-level privileges. The Node.js Alpine image includes a built-in node user, and switching to it requires just one line:

typescript
FROM node:22-alpine
WORKDIR /app

COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package*.json ./

# Switch to non-root user
USER node

EXPOSE 3000
CMD ["node", "dist/main.js"]

This sounds minimal, but it's a fundamental defense-in-depth control. If your application has a vulnerability or an attacker finds their way into the container, they're confined to the permissions of the node user, which can't modify system files, install packages, or escalate privileges (absent a kernel exploit, which is rare and usually patched quickly).

Important: set USER node after all your COPY operations. The node user typically doesn't have write permissions to /app during the build, so if you switch users too early, file operations will fail.

If your app needs to write logs or temporary files, ensure those directories are either world-writable or owned by the node user. You can use RUN mkdir -p /app/logs && chown node:node /app/logs before switching users.

Putting it together: a production-ready Dockerfile

Here's a complete example that combines all four principles:

typescript
# Build stage
FROM node:22-alpine AS build
WORKDIR /app

# Copy package files first for cache efficiency
COPY package*.json ./
COPY tsconfig*.json ./

# Install dependencies
RUN npm ci

# Copy source
COPY . .

# Generate Prisma client for the target platform
RUN npx prisma generate

# Build and remove dev dependencies
RUN npm run build
RUN npm prune --omit=dev

# Runtime stage
FROM node:22-alpine
WORKDIR /app

# Copy dist and pruned dependencies from build stage
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./
COPY --from=build /app/prisma ./prisma

# Set non-root user before exposing ports
USER node

# Health check (optional but recommended)
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD node -e "require('http').get('http://localhost:3000/health', (r) => {if (r.statusCode !== 200) throw new Error(r.statusCode)})"

EXPOSE 3000

CMD ["node", "dist/main.js"]

Notice the optional HEALTHCHECK directive. It tells your orchestrator (Kubernetes, ECS, Docker Compose) how to verify your app is alive. This is invaluable for automated restarts and load balancer health checks.

Common mistakes to avoid

Copying everything in one stage: Skipping multi-stage builds means your image includes all TypeScript source, test files, and dev dependencies. It's bloated and slower to pull across the network.

Running as root: We mentioned this, but it bears repeating. There's no performance penalty for running as node, and the security benefit is substantial. Make it a habit, not an afterthought.

Ignoring Prisma engines on cross-platform builds: If you develop on M1 Mac and deploy to a Linux CI runner, you must regenerate Prisma in the build stage. Skipping this causes runtime crashes that are often hard to debug because they're silent at startup.

Using npm install instead of npm ci: npm ci (clean install) respects lockfiles and is deterministic. npm install can update transitive dependencies, causing non-reproducible builds. Always use npm ci in Dockerfiles.

Forgetting to copy package.json to the runtime stage: Some tools and libraries check for the package.json at runtime. Include it even if your app doesn't strictly need it; it's only kilobytes and prevents surprises.

Key takeaways

  • Multi-stage builds separate build and runtime concerns, cutting image sizes by 60–70% and improving deployment speed.
  • Order your Dockerfile layers so package files are copied before source code, letting Docker cache the expensive npm ci step.
  • Run prisma generate in the build stage to ensure engines are compiled for your target platform, avoiding cross-platform runtime crashes.
  • Use USER node to run containers as non-root—it's a one-line security win with no downside.
  • Combine these patterns into a standard template for your team; consistency prevents accidental regressions and makes code review faster.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles