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:
- Installs all dependencies (including devDependencies like
typescriptand@types/*) - Copies source and compiles with
npx tsc - Produces
dist/with compiled JavaScript
Production stage:
- Starts from a clean
node:20-slimimage - Installs only production dependencies (
--omit=dev) - Copies the compiled
dist/from the builder stage - 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 cipattern ensures dependencies are cached by Docker's layer system. Dependencies only reinstall whenpackage.jsonorpackage-lock.jsonchanges, not when source code changes. npm civsnpm install: Always usenpm ciin Docker. It installs from the lockfile exactly, is faster, and won't modifypackage-lock.json.- Image size:
node:20-slimis ~200MB.node:20-alpineis ~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 nodebefore the CMD. Thenodeuser 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
ARGin the builder stage, notENV. Build args don't persist to the final image.