ifact boundaries.
Step-by-Step Implementation
- Define the build stage: Use a full-featured base image with compilers, debuggers, and package managers. Install dependencies, run TypeScript compilation, and generate production-ready output.
- Define the runtime stage: Use a minimal base image (Alpine, Distroless, or scratch). Copy only the compiled JavaScript, necessary
node_modules, and configuration files.
- Enforce layer caching: Separate dependency installation from source code copying. This prevents cache invalidation when application code changes but dependencies remain stable.
- Minimize attack surface: Remove build tools, set non-root execution, and strip unnecessary metadata.
- Validate artifacts: Use
docker scout or trivy to scan the final image before pushing to the registry.
TypeScript Project Context
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
// package.json (relevant scripts)
{
"scripts": {
"build": "tsc --project tsconfig.json",
"start": "node dist/index.js",
"ci:install": "npm ci --omit=dev"
}
}
Production Dockerfile
# syntax=docker/dockerfile:1
ARG NODE_VERSION=18-alpine
# ββββββββββββββββββββββββββββββββββββββββββββββ
# Stage 1: Build & Dependencies
# ββββββββββββββββββββββββββββββββββββββββββββββ
FROM node:18-bullseye AS builder
WORKDIR /app
# Install production and dev dependencies
COPY package.json package-lock.json ./
RUN npm ci
# Copy source and compile
COPY tsconfig.json ./
COPY src/ ./src/
RUN npm run build
# Prune dev dependencies and prepare production node_modules
RUN npm ci --omit=dev --ignore-scripts
# ββββββββββββββββββββββββββββββββββββββββββββββ
# Stage 2: Production Runtime
# ββββββββββββββββββββββββββββββββββββββββββββββ
FROM node:${NODE_VERSION} AS runtime
# Install curl for healthchecks (optional)
RUN apk add --no-cache curl
WORKDIR /home/node/app
# Copy compiled output and production dependencies
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
# Security hardening
RUN chown -R node:node /home/node/app
USER node
# Runtime configuration
ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
CMD ["node", "dist/index.js"]
Architecture Decisions and Rationale
- Explicit stage naming:
builder and runtime provide clarity and enable targeted COPY --from references.
- Dependency separation: Copying
package.json and package-lock.json before source code ensures npm cache layers persist across code changes.
- Deterministic installs:
npm ci guarantees reproducible builds and fails fast on lockfile mismatches, preventing drift between environments.
- Runtime pruning: Running
npm ci --omit=dev in the build stage removes testing frameworks, linters, and type definitions that increase image size without runtime value.
- Non-root execution:
USER node prevents container escape vulnerabilities and aligns with Kubernetes pod security standards.
- Healthcheck integration: Built-in Docker health monitoring enables orchestrators to detect degraded instances without external agents.
Pitfall Guide
1. Copying the entire build context into every stage
Developers frequently use COPY . . in multiple stages, inadvertently copying .git, node_modules, test files, and local configuration into the final image. This breaks layer caching, increases image size, and leaks sensitive data. Always use a .dockerignore file and copy only required artifacts.
2. Ignoring layer caching order
Placing COPY . . before RUN npm ci invalidates the dependency cache on every commit. Docker caches layers sequentially; if a layer changes, all subsequent layers rebuild. Install dependencies first, then copy source code to maximize cache hits.
Build stages share the same filesystem during execution. If you inject secrets via ENV or ARG in the builder stage, they persist in image metadata even if not used in the runtime stage. Use RUN --mount=type=secret (BuildKit) or inject secrets at container runtime, not build time.
4. Forgetting to set WORKDIR before copying
Without an explicit WORKDIR, Docker defaults to /, causing files to scatter across the root filesystem. This breaks relative path resolution, complicates debugging, and violates filesystem hierarchy standards. Always set WORKDIR early in each stage.
5. Over-engineering stage count
Adding stages for linting, testing, and documentation generation sounds thorough but increases build time and complexity. Multi-stage builds should focus on compilation and packaging. Run tests and linting in CI pipeline steps, not inside the Dockerfile. Keep stages to 2-3 maximum unless the build process genuinely requires isolation.
6. Not leveraging BuildKit cache mounts
Traditional Dockerfile caching relies on layer snapshots, which cannot handle dynamic caches like npm or apt. BuildKit's --mount=type=cache enables persistent, stage-scoped caching without bloating the final image. Example: RUN --mount=type=cache,target=/root/.npm npm ci.
7. Skipping non-root enforcement
Running containers as root is a critical security anti-pattern. Even with multi-stage builds, omitting USER node leaves the container vulnerable to privilege escalation exploits. Always switch to a non-privileged user after copying artifacts and setting permissions.
Production Best Practices
- Pin base images to specific SHA digests for supply chain immutability.
- Use
docker buildx build --load for local development and --push for CI.
- Validate images with
docker scout cves and docker scout recommendations before registry pushes.
- Keep
.dockerignore synchronized with .gitignore plus Docker-specific exclusions (Dockerfile, docker-compose.yml, *.log).
- Test multi-architecture builds early using
buildx to avoid runtime failures on ARM/AMD clusters.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Monorepo with shared TypeScript packages | Multi-stage with workspace-aware npm ci + turbo/next.js caching | Prevents redundant dependency installation and leverages incremental builds | Reduces CI time by 35-50%, lowers registry storage |
| High-security compliance (PCI/DSS) | Multi-stage + Distroless base + non-root + SHA-pinned images | Eliminates shell access, reduces attack surface to runtime binary only | Increases build complexity slightly, reduces compliance audit failures |
| Rapid prototyping / local dev | Single-stage with volume mounts + nodemon | Prioritizes developer iteration speed over image optimization | Higher runtime footprint, acceptable for non-production environments |
| Edge/IoT deployment | Multi-stage + Alpine or scratch + static binary compilation | Minimizes memory and storage constraints on resource-limited hardware | Requires cross-compilation setup, drastically reduces distribution bandwidth |
Configuration Template
# syntax=docker/dockerfile:1
# Production-grade multi-stage Dockerfile for TypeScript/Node.js
# Requires Docker BuildKit: DOCKER_BUILDKIT=1
ARG NODE_VERSION=18-alpine
ARG APP_PORT=3000
# ββββββββββββββββββββββββββββββββββββββββββββββ
# Stage 1: Dependency Resolution & Compilation
# ββββββββββββββββββββββββββββββββββββββββββββββ
FROM node:18-bullseye AS builder
WORKDIR /app
# Install all dependencies (dev + prod) for compilation
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
# Copy TypeScript config and source
COPY tsconfig.json ./
COPY src/ ./src/
# Compile and generate production output
RUN npm run build
# Install production-only dependencies
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev --ignore-scripts
# ββββββββββββββββββββββββββββββββββββββββββββββ
# Stage 2: Minimal Production Runtime
# ββββββββββββββββββββββββββββββββββββββββββββββ
FROM node:${NODE_VERSION} AS runtime
# Install minimal runtime utilities
RUN apk add --no-cache curl tini
WORKDIR /home/node/app
# Copy only compiled artifacts and production dependencies
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
# Filesystem permissions and security hardening
RUN chown -R node:node /home/node/app && \
chmod -R 550 /home/node/app
USER node
ENV NODE_ENV=production
ENV PORT=${APP_PORT}
EXPOSE ${APP_PORT}
# Tini acts as PID 1 to handle signals and zombie processes
ENTRYPOINT ["/sbin/tini", "--"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:${APP_PORT}/health || exit 1
CMD ["node", "dist/index.js"]
Quick Start Guide
- Enable BuildKit: Set
DOCKER_BUILDKIT=1 in your shell or Docker daemon configuration to unlock cache mounts and improved layer handling.
- Create
.dockerignore: Add node_modules, dist, .git, *.md, Dockerfile, docker-compose*.yml, and local environment files to prevent context bloat.
- Run the build: Execute
docker build -t myapp:latest --target runtime . to compile and package the application using the multi-stage pipeline.
- Validate the output: Run
docker run -p 3000:3000 myapp:latest and verify the health endpoint. Use docker scout cves myapp:latest to confirm CVE reduction.
- Push to registry: Tag and push with
docker tag myapp:latest registry.example.com/myapp:v1.0.0 && docker push registry.example.com/myapp:v1.0.0. Integrate the build command into your CI pipeline with cache persistence.