â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.