Dockerizing Express
Containerizing an Express app packages your code, its Node runtime, and every dependency into a single immutable image that runs identically on your laptop, in CI, and on a production cluster. A naive Dockerfile works, but it bloats the image with build tooling, dev dependencies, and a root user — all of which hurt size, startup time, and security. This page builds a production-grade image using a multi-stage build, a slim base image, a non-root runtime user, and a tight .dockerignore.
Why multi-stage builds
A multi-stage Dockerfile defines several FROM stages in one file. Early stages install dependencies and compile assets; the final stage copies only the runtime artifacts it needs. Anything left behind in an earlier stage — npm caches, dev dependencies, git, the source .ts files — never lands in the shipped image. The result is a smaller, faster, more secure container with a reduced attack surface.
Pick a slim base image too. The default node:22 image is Debian-based and large; node:22-slim strips out documentation and extra tooling, while node:22-alpine is smaller still (musl libc). Slim is the safest default because some native modules misbehave against Alpine’s musl.
The .dockerignore file
Before writing the Dockerfile, add a .dockerignore. It works like .gitignore and keeps the build context small, which speeds up builds and prevents secrets or local node_modules from leaking into the image.
node_modules
npm-debug.log
.git
.gitignore
.env
.env.*
Dockerfile
.dockerignore
dist
coverage
.vscode
*.md
Excluding the host
node_modulesis critical: native modules compiled on macOS or Windows will not run inside a Linux container. Always let Docker install dependencies fresh.
A production-ready multi-stage Dockerfile
The build below uses two stages. The deps stage installs only production dependencies against the lockfile, and the runtime stage copies those node_modules plus the application source, then drops to a non-root user.
# syntax=docker/dockerfile:1
# --- Stage 1: install production dependencies ---
FROM node:22-slim AS deps
WORKDIR /app
# Copy only manifests first so this layer is cached unless deps change
COPY package.json package-lock.json ./
# Deterministic, lockfile-only install of prod deps
RUN npm ci --omit=dev
# --- Stage 2: minimal runtime image ---
FROM node:22-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
# Bring over the resolved node_modules from the deps stage
COPY --from=deps /app/node_modules ./node_modules
# Then copy application source (respecting .dockerignore)
COPY . .
# Run as the built-in unprivileged "node" user, never root
USER node
EXPOSE 3000
CMD ["node", "server.js"]
Copying node_modules efficiently
Notice the ordering. Copying package*.json and running npm ci before copying the rest of the source means Docker caches the dependency layer. As long as your lockfile is unchanged, rebuilds reuse that layer and skip the slow install — even when you edit route files. npm ci is used over npm install because it installs exactly what the lockfile specifies and fails if the lockfile is out of sync.
The minimal server.js the image expects:
import express from "express";
const app = express();
app.get("/health", (req, res) => {
res.json({ status: "ok" });
});
// Bind to 0.0.0.0 so the container accepts external connections
const port = process.env.PORT || 3000;
app.listen(port, "0.0.0.0", () => {
console.log(`Listening on ${port}`);
});
Running as a non-root user
The official Node images ship with a pre-created node user (UID 1000). Switching to it with USER node means a container escape cannot trivially gain root on the host. Because node is unprivileged, you must bind to a port >= 1024 (hence 3000) and ensure any files the app writes are owned by that user.
Building and running
# Build the image and tag it
docker build -t my-express-app:1.0 .
# Run it, mapping host port 3000 to the container
docker run --rm -p 3000:3000 --env NODE_ENV=production my-express-app:1.0
Confirm the container responds and that the image stayed small:
Output:
$ curl localhost:3000/health
{"status":"ok"}
$ docker images my-express-app:1.0
REPOSITORY TAG IMAGE ID SIZE
my-express-app 1.0 3f2a9c1e7b04 178MB
TypeScript and a build stage
For a TypeScript project, add a build stage that installs all dependencies, compiles to dist, and lets the runtime stage copy only the compiled output and production dependencies.
FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build # tsc -> ./dist
FROM node:22-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
Best Practices
- Pin a specific base tag (
node:22-slim), notnode:latest, so builds are reproducible. - Order layers from least- to most-frequently changed: manifests and
npm cibefore source code. - Always run
npm ci --omit=devin the runtime stage to exclude dev dependencies. - Drop to the non-root
nodeuser and bind to a port>= 1024. - Keep a thorough
.dockerignoreto shrink build context and avoid leaking.envfiles. - Set
NODE_ENV=productionin the image so Express enables its production optimizations. - Forward
SIGTERMcorrectly (useCMD ["node", ...], not a shell form) so your app can shut down gracefully.