A Small, Fast, Production-Worthy Image
Dockerizing Express
A multi-stage Dockerfile, a tight .dockerignore, run as non-root.
What you'll learn
- Write a multi-stage Dockerfile
- Set up a .dockerignore
- Run as the `node` user
A minimal, secure-by-default Dockerfile for an Express app.
Dockerfile
# 1) Build stage: install all deps, build the app
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build # if you have a build step (e.g., TS)
# 2) Runtime stage: prod deps only
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"] What’s Happening
- Multi-stage — dev deps (TypeScript, Vitest) don’t ship to prod
USER node— don’t run as root (security best practice)npm ci— installs exactly what’s inpackage-lock.json- Layer caching —
COPY package*.jsonthennpm ciBEFORECOPY . ., so Docker caches the install layer when only source changes
.dockerignore
node_modules
.git
.env
.env.*
!.env.example
dist
coverage
*.log
.DS_Store
.vscode Without this, COPY . . would ship your local node_modules and
.env into the image. Slow build, broken runtime, leaked secrets.
Build + Run
docker build -t my-api .
docker run -p 3000:3000 \
-e DATABASE_URL=postgres://... \
-e JWT_SECRET=... \
my-api Smaller Bases
node:22-alpine is ~50MB. Even smaller options:
node:22-slim(Debian) — ~80MB, fewer musl-vs-glibc issuesgcr.io/distroless/nodejs22— minimal, no shell (great for prod, painful to debug)
Alpine + native deps can be a pain (gyp, sharp). When in doubt: slim.
Signals
Containers send SIGTERM. Your app must handle it:
process.on("SIGTERM", () => server.close(() => process.exit(0))); Without this handler, container orchestrators wait 10s, then
SIGKILL. Connections drop.
docker-compose for Local Dev
services:
api:
build: .
ports: ["3000:3000"]
env_file: .env
depends_on: [db, redis]
volumes:
- .:/app
- /app/node_modules
db:
image: postgres:16
environment:
POSTGRES_DB: app
POSTGRES_USER: app
POSTGRES_PASSWORD: app
ports: ["5432:5432"]
redis:
image: redis:7
ports: ["6379:6379"] docker-compose up and your full stack is running.
Production Concerns
- Tag images with git commit SHAs, never just
latest - Scan for vulnerabilities —
docker scoutor Trivy - Limit memory —
docker run --memory=512m(prevents OOM killing other services) - Health check in the Dockerfile:
HEALTHCHECK --interval=30s --timeout=3s \
CMD wget -qO- http://localhost:3000/healthz || exit 1