Dockerizing Express

A Small, Fast, Production-Worthy Image

Dockerizing Express

A multi-stage Dockerfile, a tight .dockerignore, run as non-root.

4 min read Level 2/5 #express#docker#containers
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 in package-lock.json
  • Layer cachingCOPY package*.json then npm ci BEFORE COPY . ., 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 issues
  • gcr.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 vulnerabilitiesdocker scout or Trivy
  • Limit memorydocker 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
Going Further →