Deploying Node.js Applications with Docker
Docker takes the headache out of deployment. Instead of wrestling with server configurations and dependency conflicts, you package your application with everything it needs and run it anywhere Docker runs. For Node.js applications, Docker is the standard deployment target whether you’re pushing to 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. After creating them, switch to the user with:
USER nodejs
Everything after this line runs as the non-root user. If your application needs to write to the filesystem (caches, uploads, logs), make sure those directories are owned by the nodejs user:
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();
Add this to your package.json scripts so it gets installed:
{
"scripts": {
"start": "node server.js",
"healthcheck": "node healthcheck.mjs"
}
}
Then reference it in your Dockerfile:
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. The --retries setting controls how many failed checks trigger an unhealthy status. When you run the container, Docker shows the health status:
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
Use ARG for values that change at build time, like the Node version:
ARG NODE_VERSION=22
FROM node:${NODE_VERSION}-alpine
Use ENV for runtime values that your application reads:
ENV NODE_ENV=production
ENV PORT=3000
Never hardcode secrets like API keys or database passwords in your Dockerfile. Pass them at runtime:
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
- Node.js Modules and npm — understanding how Node.js package management works
- Node.js Logging and Monitoring — observability patterns for production Node.js services
- Node.js HTTP Server Tutorial — building the server your Docker container will run