Infrastructure

DockerMulti-StageBuildsforTypeScript

Build smaller, more secure Docker images for TypeScript projects using multi-stage builds. Covers the builder/production pattern, health checks, native dependencies, and layer caching.

A multi-stage Docker build compiles TypeScript in one stage and runs the JavaScript output in another. The final image has no TypeScript compiler, no dev dependencies, and no source files — just the compiled code and production dependencies.

This pattern cuts image size by 60-80% and reduces the attack surface.

The Pattern

# Stage 1: Build
FROM node:20-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npx tsc

# Stage 2: Production
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist

HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
  CMD node -e "fetch('http://localhost:3100/health').then(r=>{if(!r.ok)throw 1}).catch(()=>process.exit(1))"

CMD ["node", "dist/index.js"]

What Each Stage Does

Builder stage:

  1. Installs all dependencies (including devDependencies like typescript and @types/*)
  2. Copies source and compiles with npx tsc
  3. Produces dist/ with compiled JavaScript

Production stage:

  1. Starts from a clean node:20-slim image
  2. Installs only production dependencies (--omit=dev)
  3. Copies the compiled dist/ from the builder stage
  4. TypeScript, type definitions, and source files are never included

Health Check

The health check tells Docker (and orchestrators like Docker Compose, Kubernetes, or Proxmox) whether the container is actually working:

HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
  CMD node -e "fetch('http://localhost:3100/health').then(r=>{if(!r.ok)throw 1}).catch(()=>process.exit(1))"

This hits a /health endpoint every 30 seconds. If it fails 3 times, Docker marks the container as unhealthy. Your server needs a health endpoint:

app.get("/health", (_, res) => res.json({ status: "ok" }));

No external tools needed — Node.js 20 has fetch() built in.

Docker Compose

services:
  mcp-server:
    build: .
    ports:
      - "3100:3100"
    environment:
      - MCP_AUTH_TOKEN=${MCP_AUTH_TOKEN}
    restart: unless-stopped

.dockerignore

Keep the build context small:

node_modules
dist
.git
*.md
.env
.env.local

Never send node_modules to the Docker build context. The builder stage installs its own.

Native Dependencies

If your project uses native modules (like better-sqlite3), both stages must use compatible base images. Don't mix node:20-slim and node:20-alpine — the native binaries compiled on one won't run on the other.

For native modules on slim:

FROM node:20-slim AS builder
RUN apt-get update && apt-get install -y python3 build-essential && rm -rf /var/lib/apt/lists/*

You only need build tools in the builder stage. The compiled .node files copy to production without needing the compiler.

Configuration Notes

  • Layer caching: The COPY package*.json + RUN npm ci pattern ensures dependencies are cached by Docker's layer system. Dependencies only reinstall when package.json or package-lock.json changes, not when source code changes.
  • npm ci vs npm install: Always use npm ci in Docker. It installs from the lockfile exactly, is faster, and won't modify package-lock.json.
  • Image size: node:20-slim is ~200MB. node:20-alpine is ~140MB but uses musl instead of glibc, which breaks some native modules. Slim is the safer default.
  • Non-root user: For additional security, add USER node before the CMD. The node user already exists in the official Node images.
  • Build args: If your TypeScript build needs env vars (like API URLs baked in at compile time), use ARG in the builder stage, not ENV. Build args don't persist to the final image.