Dockerizing Node.js Applications: Production Best Practices 2026
Learn how to containerize Node.js apps for production with optimized image sizes, security hardening, and efficient caching. This guide covers Dockerfile best practices, .dockerignore, non-root users, and deployment patterns.

Docker has become the standard for packaging Node.js applications, but many developers end up with 1GB images, slow rebuilds, and security vulnerabilities. This guide collects battle-tested patterns from production deployments serving millions of requests. By the end, your images will be smaller, faster to build, and more secure.
The Golden Production Dockerfile
Start with this template and customize as needed:
# Build stage
FROM node:22-alpine AS builder
WORKDIR /app
Copy package files for layer caching
COPY package*.json ./
COPY yarn.lock ./
Install dependencies (including dev for build)
RUN npm ci --only=production=false
Copy source code
COPY . .
Build TypeScript, compile assets, etc.
RUN npm run build
Production stage
FROM node:22-alpine
RUN apk add --no-cache dumb-init
Create non-root user
RUN addgroup -g 1001 -S nodejs &&
adduser -S nodejs -u 1001
WORKDIR /app
Copy only needed files from builder
COPY --from=builder --chown=nodejs:nodejs /app/package*.json ./
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
Switch to non-root user
USER nodejs
Expose port
EXPOSE 3000
Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3
CMD node -e “require(‘http’).get(‘http://localhost:3000/health’, ® => {process.exit(r.statusCode === 200 ? 0 : 1)})”
Use dumb-init to handle signals properly
ENTRYPOINT [“dumb-init”, “–”]
CMD [“node”, “dist/server.js”]
Critical Optimizations Explained
1. Multi-stage Builds
Using two stages (builder + production) reduces final image size by 60-80%. Build tools (TypeScript, Webpack, dev dependencies) stay in the builder stage, never reaching production.
Size comparison:
Single-stage: ~450MB
Multi-stage (above): ~95MB
2. Alpine Base Images
node:22-alpine is based on Alpine Linux (5MB) instead of Debian (~180MB). Only the Node.js runtime and essential libraries are included. For native addons, you may need node:22-slim instead.
3. Layer Caching Strategy
Docker caches each RUN, COPY, and ADD instruction. The most cache-unstable operations should go at the bottom:
# ✅ GOOD: Copy package files first (rarely change vs source)
COPY package*.json ./
RUN npm ci
COPY . . # Source changes often, but rebuilds only this layer
❌ BAD: Copy source before npm install
COPY . .
RUN npm ci # Any source change invalidates cache, re-runs npm install
Essential .dockerignore
Prevent secrets and unnecessary files from entering the build context:
node_modules
npm-debug.log
.env
.git
.gitignore
README.md
.github
.nyc_output
coverage
.idea
.vscode
*.md
docker-compose*.yml
Dockerfile
dist # Built in container
.DS_Store
Running as Non-Root User
Never run containers as root. The example above creates a nodejs user with UID 1001. This mitigates container breakout vulnerabilities.
Verify: docker run --rm your-app whoami should output nodejs, not root.
Environment Variables Configuration
Don't bake env vars into the image (except truly static ones like NODE_ENV). Use runtime injection:
# Pass at runtime
docker run -e DATABASE_URL=postgres://... -e API_KEY=secret your-app
Or use env file
docker run --env-file .env.production your-app
For Kubernetes, use Secrets
kubectl create secret generic app-secrets --from-literal=api-key=xyz
Health Checks and Graceful Shutdown
Your app must handle SIGTERM gracefully to avoid dropping requests during scaling or deployment:
// server.js
const server = app.listen(3000);
process.on(‘SIGTERM’, () => {
console.log(‘SIGTERM received, closing server…’);
server.close(() => {
console.log(‘Server closed, exiting’);
process.exit(0);
});
// Force exit after 10 seconds
setTimeout(() => {
console.error(‘Could not close connections in time, forcing exit’);
process.exit(1);
}, 10000);
});
Orchestrators like Kubernetes use health checks to restart unhealthy containers:
app.get('/health', (req, res) => {
// Check database connection, etc.
if (db.connected) {
res.status(200).send('OK');
} else {
res.status(503).send('Unavailable');
}
});
Resource Limits
Always set memory and CPU limits in your orchestration. Node.js has a default heap limit of ~2GB on 64-bit, but you should constrain per container:
# docker-compose.yml
services:
api:
image: your-app:latest
deploy:
resources:
limits:
cpus: '0.5'
memory: 512M
reservations:
cpus: '0.25'
memory: 256M
For Kubernetes:
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
Optimizing Startup Time
Large node_modules slow down container start. Reduce with:
npm ci --only=production(no dev dependencies)- Use
--no-audit --no-fundto skip unnecessary steps - Consider
pnpmoryarnwith faster installs - Use
--max-old-space-sizeto limit memory for embedded devices
Security Scanning
Before pushing to registry, scan for vulnerabilities:
# Using Docker Scout
docker scout quickview your-app:latest
Using Trivy (open source)
trivy image your-app:latest
In CI
- name: Trivy scan
uses: aquasecurity/trivy-action@master
with:
image-ref: your-app:latest
format: ‘sarif’
exit-code: ‘1’
severity: ‘CRITICAL,HIGH’
Building for Different Environments
Use Docker build args for environment-specific configs:
ARG NODE_ENV=production
ENV NODE_ENV=$NODE_ENV
RUN if [ "$NODE_ENV" = "development" ]; then npm install --only=development; fi
Build: docker build --build-arg NODE_ENV=development -t myapp:dev .
Conclusion
The Dockerfile provided in this guide is production-ready for 95% of Node.js applications. Start from this template, then layer on your specific needs (native addons, custom build steps, etc.). Always run as non-root, set resource limits, and implement health checks. Your ops team will appreciate smaller images (<100MB) and your security team will appreciate no root processes. Containerize wisely.
Comments
Join the conversation — sign in to leave a comment.