NodeJSContainerizing with Docker

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