I get asked about this a lot, so here's my take.
Everyone's first Dockerfile looks the same. Five lines, FROM node, copy everything, npm install, done. And it works! On your laptop. In development. With nobody watching. The moment you try to ship that to a staging environment, you start running into problems: the image is over a gigabyte, it's running as root, every tiny code change rebuilds all your dependencies, and your security team is sending you Slack messages you'd rather not read. The gap between "it runs in Docker" and "it runs in Docker well" is bigger than you'd expect.
The Dockerfile Everyone Starts With (And Why It's Not Enough)
You've probably seen this before. Maybe you wrote it yourself. No judgment.
# The naive approach - DO NOT use in production
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]
This copies your entire project directory (including node_modules, .git, test files, that .env you forgot about) into the image. It uses the full node:20 base image, which is Debian-based and around a gigabyte. It installs dev dependencies you don't need at runtime. And the process runs as root inside the container, which means if someone finds an exploit in your app, they own the entire container.
Compare that to a production setup. This is longer, yes, but every line is doing something that matters:
# Production Dockerfile
FROM node:20-alpine AS base
WORKDIR /app
# Dependencies stage
FROM base AS deps
COPY package*.json ./
RUN npm ci --only=production
# Build stage (if you have a build step)
FROM base AS build
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production stage
FROM base AS production
RUN addgroup -g 1001 -S nodejs \
&& adduser -S nodeapp -u 1001
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package*.json ./
USER nodeapp
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "dist/server.js"]
The difference in image size alone is staggering. The naive version: ~1.1GB. This version: around 180MB. That affects pull times, storage costs, and how fast your containers start during scaling events. But size is only part of the story. The multi-stage approach is what really makes this work, and it's worth understanding why each stage exists.
Multi-Stage Builds: What They Are and Why You Should Care
The idea behind multi-stage builds is simple: you can use different base images for building your app and running it. The build environment might need TypeScript, dev dependencies, testing tools -- all sorts of stuff that has no business being in your production container. With multi-stage builds, you install all of that in a temporary stage, compile your code, and then copy only the output into a clean final image. The build tools never make it into production.
In the Dockerfile above, there are four stages, and each has a specific job. The base stage sets up Alpine Linux with Node, which is the minimal foundation. The deps stage installs only production dependencies using npm ci (not npm install -- more on that in a second). The build stage installs everything, including dev dependencies, because it needs them to compile TypeScript or run whatever your build step is. And the production stage cherry-picks just the compiled output and production node_modules from the previous stages.
Why npm ci instead of npm install? Because ci installs exactly what's in your lockfile. No resolving, no updating, no surprises. It's faster and it's reproducible. If your lockfile says [email protected], that's what you get, every single time. npm install might decide to update a sub-dependency, and suddenly your container is slightly different from what you tested.
Quick note on Alpine: it uses
muslinstead ofglibc, which can cause issues with native modules that expect glibc. If you're using packages likesharp,bcrypt, orcanvas, test thoroughly with Alpine before committing to it. If you hit problems,node:20-slim(Debian-based but stripped down) is a reasonable fallback at around 250MB.
Development Setup with Docker Compose
Your development environment should feel nothing like production to work in, but it should behave like production under the hood. Docker Compose handles that balancing act. You get hot reloading and debug ports, but the services (database, cache, message queue) are the same ones running in prod. No more "it works with my local Mongo install" situations.
version: '3.8'
services:
app:
build:
context: .
target: base
volumes:
- .:/app
- /app/node_modules
ports:
- "3000:3000"
- "9229:9229"
environment:
- NODE_ENV=development
command: npx nodemon --inspect=0.0.0.0 server.js
mongodb:
image: mongo:7
ports:
- "27017:27017"
volumes:
- mongo-data:/data/db
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
mongo-data:
Notice the target: base in the app service. That tells Compose to build only up to the base stage of your multi-stage Dockerfile, which gives you a development container without running the full production build. The volume mount (.:/app) maps your local files into the container so changes show up instantly. The anonymous volume for /app/node_modules prevents your local node_modules from overwriting the container's -- this matters when you're developing on macOS but the container is Linux, since native modules won't be compatible across OS boundaries.
Port 9229 is for the Node.js debugger. With --inspect=0.0.0.0, you can attach VS Code's debugger directly to the process inside the container. I set this up once per project and never think about it again. It saves a surprising amount of time over console.log debugging.
Graceful Shutdown: The Thing Nobody Sets Up Until It's Too Late
When Docker stops a container, it sends a SIGTERM signal and waits (by default, 10 seconds) for the process to exit. If it doesn't exit in time, Docker sends SIGKILL and the process dies immediately. That means in-flight HTTP requests get dropped, database connections don't close cleanly, and any pending writes to external services vanish into the void.
I learned this the hard way on a payment processing service. During a deploy, a container was killed mid-request, and a customer got charged but our database never recorded it. Took hours to sort out. Since then, I handle SIGTERM in every Node.js service I write:
const server = app.listen(3000);
process.on('SIGTERM', () => {
console.log('SIGTERM received. Starting graceful shutdown...');
server.close(() => {
console.log('HTTP server closed.');
// Close database connections
mongoose.connection.close(false, () => {
console.log('MongoDB connection closed.');
process.exit(0);
});
});
// Force shutdown after 8 seconds if graceful fails
setTimeout(() => {
console.error('Forced shutdown after timeout.');
process.exit(1);
}, 8000);
});
The server.close() call stops accepting new connections but lets in-flight requests finish. Then you close your database connections and exit cleanly. The 8-second timeout is a safety net -- if something hangs, you still exit before Docker's 10-second kill timer. In your Dockerfile or Compose config, you can extend Docker's stop timeout with stop_grace_period if your requests take longer to drain.
And remember what I mentioned about npm start -- this is exactly why it matters. If npm is PID 1 instead of your Node process, SIGTERM goes to npm, your signal handler never fires, and you get a hard kill every time.
Layer Caching, .dockerignore, Security, and Health Checks
I'm grouping these together because they're all things people skip and then regret later.
Layer caching is one of Docker's best features, and it's easy to accidentally break. Docker caches each instruction in your Dockerfile as a layer. If nothing in a layer changed, Docker reuses the cached version. The trick is ordering your instructions so that things that change rarely (like package.json) come before things that change often (like your source code):
# Good - uses Docker cache
COPY package*.json ./
RUN npm ci
COPY . .
# Bad - busts cache on every code change
COPY . .
RUN npm ci
In the bad version, changing a single line of your source code invalidates the COPY . . layer, which means npm ci runs again even though your dependencies haven't changed. In the good version, npm ci only reruns when package.json or package-lock.json changes. On a project with 500+ dependencies, that's the difference between a 10-second build and a 2-minute build.
.dockerignore keeps junk out of your build context. Without it, Docker sends your entire directory to the daemon, including node_modules (which gets reinstalled anyway), .git (which can be huge), and any secrets lying around:
node_modules
.git
.env
*.md
tests
coverage
.github
dist
Security stuff you should just do by default: create a non-root user with adduser and switch to it with USER. Pin your base image to a specific version (node:20.11-alpine, not node:latest). Run docker scout cves on your built images to catch known vulnerabilities. Never bake secrets into the image -- use environment variables or Docker secrets at runtime.
And health checks. Add a /health endpoint to your app:
app.get('/health', (req, res) => {
res.status(200).json({
status: 'healthy',
uptime: process.uptime(),
timestamp: Date.now()
});
});
Then wire it up in your Dockerfile with HEALTHCHECK. This tells Docker (and whatever orchestrator you're using -- ECS, Kubernetes, Swarm) whether your container is actually able to serve traffic. Without it, a container that's technically running but stuck in a crash loop still gets requests routed to it.
Logging and Environment Variables Inside Containers
Logging inside Docker is different from logging to a file on a server. The standard practice is to write everything to stdout and stderr, and have Docker's logging driver handle the rest. If you're using Winston or Pino and writing to files inside the container, stop. Those files disappear when the container restarts, and you can't easily aggregate them across multiple instances.
const pino = require('pino');
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
transport: process.env.NODE_ENV === 'development'
? { target: 'pino-pretty' }
: undefined
});
Write to stdout in production. Use pretty-printing only in development. Docker captures stdout automatically and forwards it to whatever logging driver you've configured -- json-file, fluentd, CloudWatch, whatever. This way docker logs my-container just works, and so does any centralized logging setup.
For environment variables, use a .env file with Docker Compose during development, but never copy .env files into your production image. In production, inject environment variables through your orchestrator (ECS task definitions, Kubernetes ConfigMaps/Secrets, etc.). I also add a startup check that verifies required environment variables exist before the app starts serving traffic:
const required = ['DATABASE_URL', 'JWT_SECRET', 'REDIS_URL'];
const missing = required.filter(key => !process.env[key]);
if (missing.length > 0) {
console.error(`Missing required env vars: ${missing.join(', ')}`);
process.exit(1);
}
This fails fast and loudly instead of crashing ten minutes later when someone hits an endpoint that needs the database connection string. Small thing, saves a lot of debugging time.
One last thing, and this is the gotcha that gets people: don't use npm start in your CMD instruction. Use node server.js directly. When you use npm start, npm spawns a shell process that spawns your Node process. That extra layer means SIGTERM signals from Docker (sent during graceful shutdown) go to npm, not to your app. Your app never gets the chance to close connections and finish in-flight requests. It just gets killed after the timeout. Use node directly, or use dumb-init / tini as your entrypoint if you need proper signal handling.
Comments (1)
Switched from the basic Dockerfile to the multi-stage setup and our image went from 1.2GB to 190MB. Build times also dropped because of the layer caching. Should have done this ages ago.