Containerizing with Docker
Docker packages your app, its runtime, and its system dependencies into a portable image that runs identically on any machine — your laptop, CI, staging, and production. "It works on my machine" becomes "the same image runs everywhere." For Node, containerization means a consistent Node version, a reproducible install, a lean production image, and a single artifact to build once and ship anywhere. This page covers writing a production-quality Dockerfile: multi-stage builds to keep images small, correct npm ci usage, running as a non-root user, and the .dockerignore patterns that matter.
A production-quality Dockerfile
Dockerfile
Text
# ── Stage 1: build ─────────────────────────────────────────────────────────── FROM node:20-alpine AS builder WORKDIR /app # Install deps BEFORE copying source — maximizes layer cache (re-runs only # when package*.json changes, not on every source change): COPY package.json package-lock.json ./ RUN npm ci # exact versions from lock file; no devDeps later COPY tsconfig.json ./ COPY src ./src RUN npm run build # tsc → dist/ # ── Stage 2: production image ───────────────────────────────────────────────── FROM node:20-alpine AS production WORKDIR /app ENV NODE_ENV=production COPY package.json package-lock.json ./ RUN npm ci --omit=dev # production deps only — no typescript, ts-node, etc. COPY --from=builder /app/dist ./dist # only the compiled output # Security: run as non-root user: USER node EXPOSE 3000 CMD ["node", "dist/index.js"]
Multi-stage builds give a lean image: build in stage 1 with all tools, copy only the compiled output into a minimal stage 2
A **multi-stage build** is the key pattern for lean Node images. **Stage 1** (builder) installs everything — including devDependencies like TypeScript — and compiles the source. **Stage 2** (production) starts from a fresh Alpine image, installs *only* production dependencies (`--omit=dev`), and copies just the compiled `dist/` from the builder. The final image never contains TypeScript, ts-node, test libraries, or any dev tooling — only what's needed to run. Typical result: a 200–400 MB "install everything" build layer produces a 80–150 MB production image. Alpine (`node:20-alpine`) is significantly smaller than the Debian-based `node:20` image, cutting further. Build lean — every megabyte in the image is pulled on every deploy, stored in every registry, and transferred on every scale-out event.
The .dockerignore file
.dockerignore
Text
node_modules # never copy — always reinstall in the container dist # built inside the container; don't bring the local one .git # not needed at runtime — keeps context small .env # NEVER include secrets in the image *.md coverage .nyc_output __tests__
NEVER copy `.env` into an image — secrets baked into a Docker layer are extractable from the registry by anyone with access
A Docker image is an archive of layers, and **anything copied into it persists in those layers forever** — even if you delete it in a later `RUN` step, the data remains in the earlier layer and is extractable with `docker save`. This means a `.env` file copied into an image bakes your database password, JWT secret, and API keys into every image you push — readable by anyone who can pull it from the registry, now or in the future. Add `.env` to **`.dockerignore`** and inject secrets at **runtime** via environment variables passed to `docker run -e` or your orchestrator's secrets mechanism — they're never persisted in the image. The same applies to AWS credentials files, SSH keys, and any other secret: `.dockerignore` is a security control, not just a build-speed optimization.
Layer caching — keeping builds fast
Text
# The order of COPY and RUN determines cache effectiveness. # Docker re-runs a layer when its INPUTS change. # ✅ Copy package files first — deps layer is cached unless package.json changes: COPY package.json package-lock.json ./ RUN npm ci # cached if package*.json unchanged COPY src ./src # changing source only invalidates layers FROM HERE RUN npm run build # ❌ If you copy src first, every source change invalidates the npm ci layer too.
Copy `package*.json` and `npm ci` before your source code — deps are cached unless the lock file changes
Docker rebuilds a layer when anything that came before it in the file changes. The most impactful ordering decision in a Node Dockerfile is **copy the package files and run `npm ci` *before* copying the source**. Because `node_modules` changes only when `package.json` or `package-lock.json` changes — not when you edit `src/index.ts` — the dependency install layer is **cached on every normal build**, and only invalidated on actual dependency changes. Flip the order (copy all source first), and *every* code change triggers a full `npm ci`, turning a 5-second build into a 2-minute one. This pattern is the single most important Docker performance optimization for Node apps. Also use `npm ci` (not `npm install`) — it installs exact versions from the lock file, produces reproducible builds, and errors if the lock file is out of sync.
Run as a non-root user
Text
# The official node images create a 'node' user — switch to it: USER node # Or create your own minimal user: RUN addgroup -S appgroup && adduser -S appuser -G appgroup USER appuser
Never run Node as root in a container — a compromised process has root access to the host if not namespaced properly
By default, processes in a Docker container run as **root** (uid 0), and while Docker namespacing limits access to the host, a compromised root container process has a much larger blast radius — it can write anywhere in the filesystem, install packages, and in misconfigured environments may escape the container entirely. Running as a **non-root user** (`USER node`) is simple, costs nothing, and is the standard security baseline. The official `node:` images include a `node` user for this purpose. Make sure your `WORKDIR` and any files are owned by that user (`COPY --chown=node:node` or `RUN chown -R node /app`) so the process can read and write what it needs. In Kubernetes and ECS you can enforce non-root at the pod/task level as an additional layer, but fixing it in the Dockerfile ensures no deployment target runs root.
Next
Run your whole stack locally with one command: [Docker Compose](/nodejs/docker-compose).