← Back to articles
Backend DockerNode.jsDevOps

Docker for Node.js Developers: From Dev to Production

· 6 min read

“It works on my machine” stopped being an acceptable answer the moment containers became mainstream. Docker packages your Node.js application with its exact runtime, dependencies, and configuration into an image that runs identically everywhere. Your laptop, a colleague’s machine, staging, production, all the same. But writing a Dockerfile that actually produces a small, secure, and fast image requires understanding how layers work, why build order matters, and what belongs in production versus what doesn’t.

A production-ready Dockerfile

Most Node.js Dockerfiles found online are single-stage, bloated, and run as root. A proper production image uses multi-stage builds to separate the build environment from the runtime, keeping the final image small and free of dev dependencies.

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

COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile

COPY . .
RUN pnpm build

# Production stage
FROM node:22-alpine AS production
WORKDIR /app

RUN corepack enable

COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod

COPY --from=build /app/dist ./dist

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

The build stage installs all dependencies and compiles TypeScript. The production stage starts fresh, installs only production dependencies, and copies the compiled output. Dev dependencies like TypeScript, ESLint, and testing libraries never make it into the final image. The USER node directive drops root privileges, and --frozen-lockfile ensures the lockfile is respected exactly.

Layer caching: why order matters

Docker caches each layer. When a layer changes, every layer after it is rebuilt. This means the order of your COPY instructions directly impacts build speed.

# Bad: any file change invalidates the dependency cache
COPY . .
RUN pnpm install

# Good: dependencies are cached until package.json changes
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .

By copying package.json and the lockfile first, Docker reuses the cached pnpm install layer as long as dependencies haven’t changed. Source code changes, which happen constantly, only invalidate the final COPY layer. On a project with hundreds of dependencies, this saves minutes per build.

The .dockerignore file

Without a .dockerignore, Docker sends your entire project directory to the build daemon, including node_modules, .git, test files, and everything else that has no business in an image.

node_modules
.git
.gitignore
*.md
.env*
.next
dist
coverage
.turbo

This reduces the build context size dramatically and prevents accidental inclusion of secrets like .env files. It also stops local node_modules from overwriting the container’s installed dependencies, a subtle bug that causes architecture mismatches on Apple Silicon machines building for Linux.

Environment variables and secrets

Environment variables don’t belong in the image. They belong at runtime, injected by the orchestrator or the docker run command.

# Never do this
ENV DATABASE_URL=postgres://user:password@host:5432/db

# Do this instead: declare without a value
ENV NODE_ENV=production
docker run -e DATABASE_URL=postgres://user:password@host:5432/db myapp

Build-time secrets (like private npm registry tokens) need special handling. Docker BuildKit supports secret mounts that are never persisted in the image layers.

RUN --mount=type=secret,id=npm_token \
  NPM_TOKEN=$(cat /run/secrets/npm_token) pnpm install --frozen-lockfile
docker build --secret id=npm_token,src=.npmrc .

The secret is available during the build step but is not stored in any layer. Anyone pulling the image cannot extract it, unlike ARG or ENV which are visible in the image history.

Health checks

A container that’s running isn’t necessarily healthy. The process might be alive but the event loop could be blocked, the database connection lost, or the app stuck in an error state. Health checks let Docker and orchestrators detect this.

HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
app.get('/health', async (req, res) => {
  try {
    await db.raw('SELECT 1');
    res.status(200).json({ status: 'ok' });
  } catch {
    res.status(503).json({ status: 'unhealthy' });
  }
});

The health endpoint checks what matters: database connectivity, critical service availability, whatever your app needs to function. Docker marks the container as unhealthy after three consecutive failures, and orchestrators like Kubernetes or Docker Swarm can restart it automatically.

Docker Compose for local development

Production images are optimized for size and security. Development needs the opposite: live reload, all dependencies, debugger access, and mounted source code. Docker Compose bridges both worlds.

services:
  app:
    build:
      context: .
      target: build
    volumes:
      - .:/app
      - /app/node_modules
    ports:
      - '3000:3000'
      - '9229:9229'
    environment:
      - NODE_ENV=development
      - DATABASE_URL=postgres://postgres:postgres@db:5432/myapp
    command: pnpm dev
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: myapp
    ports:
      - '5432:5432'
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U postgres']
      interval: 5s
      timeout: 3s
      retries: 5

volumes:
  pgdata:

The target: build directive uses the build stage of the multi-stage Dockerfile, which has all dev dependencies. The volume mount syncs source code for live reload. The anonymous volume for node_modules prevents the host’s modules from overwriting the container’s, avoiding the architecture mismatch problem. Port 9229 exposes the Node.js debugger.

depends_on with service_healthy ensures the app doesn’t start until PostgreSQL is accepting connections. No more “connection refused” errors on startup.

Image size: why it matters

A default node:22 image is over 1GB. Switch to node:22-alpine and it drops to ~130MB. After a proper multi-stage build with only production dependencies, a typical Node.js API image lands between 150-200MB.

# Compare image sizes
docker images
REPOSITORY   TAG       SIZE
myapp        debian    1.2GB
myapp        alpine    180MB
myapp        distroless 95MB

Smaller images mean faster pulls, faster deployments, less storage, and a smaller attack surface. Every megabyte you remove is a binary or library that can’t contain a vulnerability.

For the most security-conscious setups, Google’s distroless images go further by removing the shell, package manager, and everything except the runtime. The tradeoff is that you can’t docker exec into the container for debugging.

Graceful shutdown

When Docker stops a container, it sends SIGTERM and waits 10 seconds before sending SIGKILL. Your app needs to handle SIGTERM to close database connections, finish in-flight requests, and shut down cleanly.

const server = app.listen(3000);

function shutdown() {
  server.close(async () => {
    await db.disconnect();
    process.exit(0);
  });
}

process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);

Without this, active requests get terminated mid-response, database connections leak, and data can be left in an inconsistent state. This is especially critical in orchestrated environments where containers are regularly restarted during deployments.

Conclusion

Docker turns “it works on my machine” into “it works everywhere.” Multi-stage builds keep images lean. Layer ordering keeps builds fast. Health checks keep containers honest. And Docker Compose gives you a full local environment with a single command. The investment in a proper Dockerfile pays for itself on every deployment, every onboarding, and every “can you reproduce this bug” conversation that never needs to happen.