jsguides

Deploying Node.js Applications with Docker

Deploying Node.js applications with Docker eliminates the configuration drift between your laptop and production. Instead of wrestling with server setups and dependency conflicts, you package your application with everything it needs and run it anywhere Docker runs: on a VPS, a container platform, or a full Kubernetes cluster.

This tutorial covers the essential parts of production-grade Node.js Docker deployment: multi-stage builds to keep images lean, non-root users for security, healthchecks so Docker knows when your app is in trouble, and the .dockerignore file that prevents accidentally shipping secrets.

Multi-stage Docker builds

A naive Dockerfile copies everything into the image and runs npm install with all dependencies, including the ones you only need during development. Multi-stage builds fix this by separating the dependency installation step from the final image.

Stage 1 installs all dependencies. Stage 2 copies only what the running application needs, leaving behind build tools, dev dependencies, and source files you don’t want in production.

# Stage 1: Install dependencies
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production

# Stage 2: Production image
FROM node:22-alpine AS production
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
    adduser -S nodejs -u 1001 -G nodejs
RUN chown -R nodejs:nodejs /app

USER nodejs
EXPOSE 3000

# Use tini as init system
RUN npm install -g tini
ENTRYPOINT ["tini", "--"]
CMD ["node", "server.js"]

The --only=production flag tells npm to skip devDependencies. Your final image won’t include your test frameworks, TypeScript compiler, or build tools.

Notice the package*.json copy happens before the source copy. Docker builds layers, and if the package files haven’t changed, it reuses the cached dependency layer. Only when you change a dependency file does Docker reinstall everything. This speeds up rebuilds significantly when you only changed a source file.

Running as a non-root user

By default, Docker runs containers as root. This is a security problem. If an attacker finds a way to escape your container or exploit your application, they have root access to the host. The Principle of Least Privilege says your container should run with only the permissions it actually needs.

Node.js images ship with a node user already created. You can also create your own:

RUN addgroup -g 1001 -S nodejs && \
    adduser -S nodejs -u 1001 -G nodejs

This creates a group with GID 1001 and a user with UID 1001. The -S flag creates a system account with no login shell or home directory. After creating the user and group, switch to them so everything that follows runs with reduced privileges:

USER nodejs

Everything after this line runs as the non-root user with only the permissions that user and group have been granted. If your application writes to the filesystem—for caches, uploads, or logs—those directories need to be owned by the nodejs user or the process will hit permission errors at runtime:

RUN chown -R nodejs:nodejs /app

Global npm packages installed with npm install -g go into a directory the nodejs user can access, so avoid placing global installs in system directories that require root.

Signal Handling and PID 1

When you run docker stop, Docker sends SIGTERM to the process and waits up to 10 seconds for it to shut down gracefully. Here’s the problem: Node.js is not designed to run as PID 1. It doesn’t forward signals to child processes automatically, and in some configurations it doesn’t even respond to SIGTERM correctly.

The solution is to use a tiny init system called Tini. It runs as PID 1, properly forwards signals, and reaps zombie processes.

RUN npm install -g tini
ENTRYPOINT ["tini", "--"]
CMD ["node", "server.js"]

Now when Docker sends SIGTERM, Tini receives it and forwards it to your Node.js process, giving it a chance to close database connections, finish in-flight requests, and exit cleanly.

Healthchecks

Docker can monitor your application’s health using a healthcheck. Without one, Docker only knows if the process is running—not if it’s actually responding to traffic. A crashed app that stays running as a zombie looks healthy to Docker without a healthcheck.

Create a simple healthcheck endpoint in your application:

// healthcheck.mjs
import http from 'http';

const options = {
  hostname: 'localhost',
  port: process.env.PORT || 3000,
  path: '/health',
  method: 'GET',
  timeout: 2000,
};

const req = http.request(options, (res) => {
  process.exit(res.statusCode === 200 ? 0 : 1);
});

req.on('error', () => process.exit(1));
req.on('timeout', () => {
  req.destroy();
  process.exit(1);
});

req.end();

This script makes an HTTP request to the /health endpoint and exits with code 0 if the response is 200. Docker runs it inside the container at the configured interval, so no external monitoring agent is needed.

Add this to your package.json scripts so it gets installed alongside your application code:

{
  "scripts": {
    "start": "node server.js",
    "healthcheck": "node healthcheck.mjs"
  }
}

With the healthcheck script defined in package.json, Docker can invoke it by running node /app/healthcheck.mjs inside the container. Add the HEALTHCHECK instruction to the Dockerfile so the daemon knows how to test liveness and can mark the container unhealthy when the check fails:

HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
  CMD node /app/healthcheck.mjs

The --start-period gives your application time to start up before Docker begins checking, which prevents false unhealthy reports during initialization. The --retries setting controls how many consecutive failed checks trigger an unhealthy status. When you run the container with docker run, Docker shows the health status in docker ps:

docker run -d \
  --name myapp \
  -p 3000:3000 \
  --health-cmd="node /app/healthcheck.mjs" \
  --health-interval=30s \
  --health-retries=3 \
  -e NODE_ENV=production \
  -e PORT=3000 \
  myapp:latest

# Check health status
docker inspect --format='{{.State.Health.Status}}' myapp

Environment variables and build arguments

Docker provides two mechanisms for injecting values into your image: ARG for build-time values that disappear after the build, and ENV for runtime values that stick around when the container runs. Use ARG when you want to parameterize the build itself without baking the value into the final image:

ARG NODE_VERSION=22
FROM node:${NODE_VERSION}-alpine

ENV sets values that persist in the running container and are readable by your application via process.env. These are for configuration that stays the same across all container instances, like the environment name and the default port your server listens on:

ENV NODE_ENV=production
ENV PORT=3000

Never hardcode secrets like API keys or database passwords in your Dockerfile. They’d be visible in the image history and to anyone who can inspect the image. Pass them at runtime with the -e flag, which injects them into the container’s environment without persisting them in any image layer:

docker run -d \
  -e DATABASE_URL="postgresql://user:password@host:5432/mydb" \
  -e SESSION_SECRET="your-secret-here" \
  myapp:latest

For production deployments, use Docker secrets or your platform’s secret management (AWS Secrets Manager, HashiCorp Vault, etc.) instead of plain environment variables.

The .dockerignore File

The build context is everything you send to Docker when you run docker build. If you run the command from your project root, every file in that directory gets sent, including node_modules, .git, .env files, and logs. This slows down builds and risks exposing secrets.

A .dockerignore file tells Docker what to exclude:

.git
.gitignore
*.log
*.md
README.md
.env
.env.*
node_modules
test
coverage
.DS_Store
.vscode
*.tar.gz

This prevents node_modules from being copied into the build context. The Dockerfile installs its own dependencies inside the container, which also ensures those dependencies match your platform. Copying node_modules from a macOS machine into a Linux container can break native modules like bcrypt or sharp that were compiled for Darwin.

Common Mistakes

Copying node_modules from the host. Always run npm ci inside the container. It installs exactly the versions locked in package-lock.json, and it installs for the correct platform.

Running as root. If you see permission errors in your logs, you might be running as root. Add the non-root user as shown above.

Forgetting --init or Tini. Without an init system, docker stop sends signals directly to Node. Depending on your Node version and configuration, this might not work correctly. Tini adds only ~40KB and solves the problem.

Not setting NODE_ENV=production. Without it, npm installs devDependencies. Your image gets bloated with test frameworks you don’t need in production, and in some cases development-only code might execute.

Alpine and native modules. If your app uses packages that compile native code (like bcrypt, sharp, or canvas), the Alpine image’s musl libc may cause issues. Switch to the standard Debian-based Node image instead:

# Use this instead of node:22-alpine if you need native modules
FROM node:22-slim

Summary

Dockerizing a Node.js application for production means going beyond a basic FROM node and CMD node. A production-grade setup uses multi-stage builds to keep images lean, runs as a non-root user for security, includes a healthcheck so Docker can monitor the application’s real status, and uses .dockerignore to prevent shipping secrets or bloated artifacts.

The patterns in this tutorial apply whether you’re deploying to a single VPS or a full Kubernetes cluster. Once your Dockerfile is solid, you can layer on orchestrators, CI/CD pipelines, and cloud-specific configurations without changing how your application ships.

See Also