TypeScript #
TypeScript is often seen as “just Node.js with typing”. As a result, Docker strategies for TypeScript are often blindly equated with JavaScript, even though in production TypeScript has an explicit build phase that must be handled correctly. TypeScript needs compilation (tsc, tsup, esbuild) before it can run, and if this build phase isn’t strictly separated from runtime, TypeScript images bloat easily.
This article discusses in detail, rationally, and production-oriented how to build small, secure, maintainable TypeScript Docker images. We’ll look at why TypeScript images bloat easily, the right strategies (multi-stage with tsc/esbuild, dev vs prod dependencies, distroless), and when to stop optimizing.
1. The Reality of TypeScript Image Sizes #
| Setup | Image Size |
|---|---|
node:latest + ts-node | 500-700 MB |
node:alpine + npm install (everything) | 300-450 MB |
node:slim + multi-stage + npm ci --omit=dev | 150-200 MB |
| Multi-stage + distroless Node.js | 60-110 MB |
| Bundling (esbuild) + distroless | 30-60 MB |
Insight: The difference between 700 MB and 30 MB is more than 20x. An optimal TypeScript image can be as small as a Go image — but only with strict build boundary discipline.
2. Why TypeScript Images Bloat Easily #
TypeScript Can’t Run Directly #
Unlike JavaScript, which Node.js can run directly, TypeScript must first be transpiled into JavaScript. The tools involved:
tsc— the official TypeScript compiler.ts-node— a TypeScript runtime for development.tsx— a faster ts-node alternative.esbuild,swc,tsup— fast transpilers for building.
All of these are only needed at build time, not runtime. But without disciplined multi-stage builds, they end up in the final image.
node_modules Contains Two Worlds
#
Node.js packages have two categories in package.json:
{
"dependencies": {
"express": "^4.18.0",
"pg": "^8.11.0"
},
"devDependencies": {
"typescript": "^5.4.0",
"@types/node": "^20.0.0",
"ts-node": "^10.9.0",
"jest": "^29.0.0"
}
}
dependencies— needed at runtime.devDependencies— development only (TypeScript compiler, type definitions, test runners, linters, formatters).
Without proper filtering, npm install installs both, and the runtime image fills up with development tools.
Build Tools Leaking into Runtime #
When TypeScript is built:
- Source
.tsfiles. - Source maps (if generated).
- Type definition files (
.d.ts). - Build configuration (
tsconfig.json). - Test files.
- Mock files.
All of these usually exist in the project, and without filtering, can end up in the runtime image.
Native Modules #
Packages like bcrypt, sharp, canvas, and other node-gyp-based libraries have native components compiled at install time. This pulls compilers and header files into the build stage — and, if handled wrongly, into runtime too.
3. The Main Principle: Built JavaScript, Not TypeScript Source #
An ideal TypeScript runtime image contains only: the Node.js runtime + built JavaScript + production dependencies.
Full stop. No tsc, no ts-node, no .ts source, no type definitions, no test runners, no dev dependencies.
4. Multi-Stage Build Strategies #
4.1 The Basic Pattern: tsc + Distroless #
# syntax=docker/dockerfile:1.7
# ==== Stage 1: Build ====
FROM node:20-alpine AS builder
WORKDIR /app
# Cache the dependency layer
COPY package.json package-lock.json ./
RUN npm ci
# Copy source and build
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
# Remove dev dependencies
RUN npm prune --omit=dev
# ==== Stage 2: Runtime ====
FROM gcr.io/distroless/nodejs20-debian12:nonroot
WORKDIR /app
# Copy build output + production dependencies
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
ENV NODE_ENV=production
USER nonroot:nonroot
EXPOSE 3000
CMD ["dist/index.js"]
Typical size: 60-110 MB.
Key explanations:
Stage 1: Build
node:20-alpine— Node.js 20 on Alpine. Slim enough for building.npm ci— installs all dependencies (including dev) to run the build.npm run build— runs thebuildscript inpackage.json(usuallytscor another build tool).npm prune --omit=dev— after a successful build, removes devDependencies fromnode_modules. This is critical so thenode_modulescopied to runtime only contains production dependencies.
Stage 2: Runtime
gcr.io/distroless/nodejs20-debian12:nonroot— Node.js 20 on distroless. Only runtime essentials, no shell.- Only the
dist/output andnode_modules/(already pruned) are copied..tssource isn’t included. NODE_ENV=production— some libraries automatically disable dev features when this env is set.
When to use: The default for production TypeScript services. Distroless + npm prune is a solid combination.
4.2 The Official TypeScript Compiler Pattern #
If the project uses tsc directly (not another build tool), configure tsconfig.json to output to the directory that will be copied:
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}
Important:
outDir: ./dist— output to the directory that will be copied.rootDir: ./src— constrain sources to a specific directory.excludetest files and config not needed in production.
4.3 The Bundler Pattern (esbuild / tsup) #
For smaller images, use a bundler that produces a single JavaScript file. This eliminates node_modules entirely from runtime (except for dynamic imports or certain built-in modules).
# ==== Stage 1: Build ====
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
# Build with esbuild
RUN npx esbuild src/index.ts \
--bundle \
--platform=node \
--target=node20 \
--outfile=dist/app.js \
--minify \
--external:./native-modules
# ==== Stage 2: Runtime ====
FROM gcr.io/distroless/nodejs20-debian12:nonroot
WORKDIR /app
COPY --from=builder /app/dist/app.js ./app.js
USER nonroot:nonroot
EXPOSE 3000
CMD ["app.js"]
Typical size: 30-60 MB (for applications with minimal dependencies).
Notes on bundling:
--external— the flag for modules that must not be bundled (e.g. native modules or dynamic requires).--platform=node— target Node.js (not the browser).--minify— compress the output.- Dynamic imports must be handled: if the code uses non-static
import('module'), the bundler won’t include it.
When to use: Small microservices, serverless functions, CLI tools, applications with minimal dependencies.
When not to: Applications with many dynamic imports, plugin systems, or large dependencies impractical to bundle.
4.4 The Native Module Pattern (sharp, bcrypt) #
Native modules have special challenges: they’re compiled for the target OS. The build stage must match the runtime OS, or use --platform with npm.
FROM node:20-alpine AS builder
WORKDIR /app
# Install build tools for native modules
RUN apk add --no-cache --virtual .build-deps \
python3 \
make \
g++
COPY package.json package-lock.json ./
RUN npm ci
# Build TypeScript
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
# Rebuild native modules for the runtime platform (if needed)
RUN npm rebuild sharp
# Remove dev dependencies and build tools
RUN npm prune --omit=dev
RUN apk del .build-deps
# ==== Runtime ====
FROM gcr.io/distroless/nodejs20-debian12:nonroot
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
ENV NODE_ENV=production
USER nonroot:nonroot
EXPOSE 3000
CMD ["dist/index.js"]
Important:
--virtual .build-deps— a temporary group, removed withapk del .build-depsat the end.npm rebuild— sometimes needed to ensure binaries match the runtime.- For distroless runtimes, the OS libraries needed by native modules must be copied manually from the build stage.
5. Separating Production and Dev Dependencies #
5.1 The package.json Strategy
#
{
"dependencies": {
"express": "^4.18.0",
"pg": "^8.11.0",
"winston": "^3.10.0"
},
"devDependencies": {
"typescript": "^5.4.0",
"@types/node": "^20.10.0",
"@types/express": "^4.17.0",
"ts-node": "^10.9.0",
"jest": "^29.7.0",
"eslint": "^8.55.0",
"prettier": "^3.1.0"
}
}
Important: npm install (the default) installs both. For production, you must use npm ci --omit=dev or npm prune --omit=dev.
5.2 Lock Files Are Mandatory #
package-lock.json must be committed. npm ci (not npm install) reads the lock file and installs the exact versions. This ensures reproducible builds.
# Generate the lock file (once)
npm install
# In CI/Docker, always use npm ci
npm ci
5.3 Multi-Target Builds #
For a development image with all tooling, and a slim production image:
FROM node:20-alpine AS base
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM base AS dev
# Dev has all tooling
COPY . .
CMD ["npm", "run", "dev"]
FROM base AS prod
# Prod: build and prune
COPY tsconfig.json ./
COPY src ./src
RUN npm run build && npm prune --omit=dev
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/index.js"]
Build:
docker build --target dev -t myapp:dev .
docker build --target prod -t myapp:prod .
6. TypeScript Build Optimizations #
6.1 Production tsconfig.json #
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"sourceMap": false,
"declaration": false,
"removeComments": true,
"incremental": false
},
"include": ["src/**/*"],
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts",
"**/__tests__/**",
"**/__mocks__/**",
"scripts/**"
]
}
Key points:
sourceMap: false— source maps don’t go to production. This saves size and prevents reverse engineering.declaration: false—.d.tsfiles don’t ship (for production that doesn’t publish libraries).removeComments: true— comments are stripped from the output.incremental: false— disable incremental builds for clean rebuilds.
6.2 Build Scripts in package.json #
{
"scripts": {
"build": "tsc -p tsconfig.json",
"build:watch": "tsc -w -p tsconfig.json",
"build:prod": "tsc -p tsconfig.prod.json"
}
}
6.3 Building with esbuild (10-100x faster) #
npx esbuild src/index.ts \
--bundle \
--platform=node \
--target=node20 \
--format=cjs \
--minify \
--outfile=dist/app.js
Or with tsup:
{
"scripts": {
"build": "tsup src/index.ts --format cjs --target node20 --minify"
}
}
7. Source Maps and Security #
Source maps in production are a trade-off:
- Pro: easier debugging, stack traces can be resolved.
- Con: source code can be reconstructed from source maps (a security risk), larger images.
Production recommendations:
sourceMap: falseintsconfig.jsonas the production default.- If you need debugging, upload source maps to Sentry or an error tracking service, don’t bundle them in the image.
- Or keep source maps in separate storage mounted during incidents.
8. Production Logging #
TypeScript/Node.js defaults console.log to STDOUT and console.error to STDERR. For production, structured logging is better:
// Winston JSON logger
import winston from 'winston';
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.json(),
transports: [
new winston.transports.Console({
stream: process.stdout,
}),
],
});
logger.info('request received', {
method: 'GET',
path: '/api/users',
status: 200,
durationMs: 45,
});
Principles:
- Log to STDOUT (not files).
- JSON format for log aggregators.
- Include trace IDs for distributed tracing.
- Log level via env var.
9. Healthchecks and Signal Handling #
Healthchecks #
Create a /health endpoint in the application:
import express from 'express';
const app = express();
app.get('/health', async (req, res) => {
try {
// Check dependencies (database, cache, etc.)
await db.ping();
res.status(200).json({ status: 'ok' });
} catch (error) {
res.status(503).json({ status: 'error', message: error.message });
}
});
For images with a shell (alpine), a Dockerfile HEALTHCHECK:
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget --quiet --tries=1 --spider http://localhost:3000/health || exit 1
For distroless, HEALTHCHECK is difficult — move it to the orchestrator.
Signal Handling #
Node.js handles SIGTERM by default, but make sure the shutdown handler is clean:
const server = app.listen(3000);
const shutdown = (signal: string) => {
console.log(`Received ${signal}, shutting down gracefully...`);
server.close((err) => {
if (err) {
console.error('Error during shutdown:', err);
process.exit(1);
}
process.exit(0);
});
// Force exit after 30 seconds
setTimeout(() => {
console.error('Forced shutdown after timeout');
process.exit(1);
}, 30000).unref();
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
Also make sure CMD uses exec form (CMD ["node", "dist/index.js"]) so signals reach Node.js directly.
10. Security Hardening #
Non-Root Users #
# For distroless, nonroot already exists
USER nonroot:nonroot
# For alpine
RUN addgroup -g 1001 -S appgroup \
&& adduser -u 1001 -S appuser -G appgroup
USER appuser
Don’t Expose .env
#
.env must be mounted at runtime, never baked into the image.
# docker-compose.yml
services:
app:
env_file: .env
Make sure .env is in .dockerignore:
.env
.env.*
Vulnerability Scanning #
- name: Build
run: docker build -t myapp:${{ github.sha }} .
- name: Scan
run: trivy image --exit-code 1 --severity CRITICAL myapp:${{ github.sha }}
Node.js images pull in many transitive deps, and CVEs appear regularly. Rebuild images periodically.
11. Anti-Patterns to Avoid #
✗ Using ts-node in Production
#
// ✗ ts-node sits in the runtime image, and .ts source must come along
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm install
CMD ["npx", "ts-node", "src/index.ts"]
Solution: Compile to JavaScript in the build stage, run the JS in runtime.
✗ Copying node_modules Whole
#
// ✗ node_modules contains unneeded devDependencies
FROM node:20-alpine AS builder
WORKDIR /app
COPY . .
RUN npm install
// node_modules now has TypeScript, ts-node, jest, etc.
FROM node:20-alpine
COPY --from=builder /app .
CMD ["node", "dist/index.js"]
// node_modules still has devDependencies
Solution: npm prune --omit=dev after the build, or install directly with --omit=dev.
✗ TypeScript Source in Runtime #
// ✗ .ts and .d.ts sources go into the runtime image
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci
CMD ["node", "dist/index.js"]
Solution: Copy only dist/ (the output) and package.json to runtime.
✗ Source Maps in Production #
// ✗ Source maps leak to runtime, larger image
{
"compilerOptions": {
"sourceMap": true
}
}
Solution: sourceMap: false for production builds.
✗ Tags Without Versions #
// ✗ Non-deterministic builds
FROM node:latest
Solution: Pin the tag: node:20.11.1-alpine3.20.
✗ Using npm install in CI
#
# ✗ May install versions different from the lock file
npm install
Solution: Always use npm ci in CI/Docker.
12. Production-Grade TypeScript Dockerfile Examples #
12.1 The Standard Version (tsc + distroless) #
# syntax=docker/dockerfile:1.7
# ==== Stage 1: Build ====
FROM node:20.11.1-alpine3.20 AS builder
WORKDIR /app
# Cache dependencies
COPY package.json package-lock.json ./
RUN npm ci
# Build TypeScript
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
# Remove devDependencies after the build
RUN npm prune --omit=dev
# ==== Stage 2: Runtime ====
FROM gcr.io/distroless/nodejs20-debian12:nonroot
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
ENV NODE_ENV=production
USER nonroot:nonroot
EXPOSE 3000
CMD ["dist/index.js"]
12.2 The Bundling Version (esbuild) #
# syntax=docker/dockerfile:1.7
# ==== Stage 1: Build ====
FROM node:20.11.1-alpine3.20 AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
# Bundle with esbuild
RUN npx esbuild src/index.ts \
--bundle \
--platform=node \
--target=node20 \
--format=cjs \
--minify \
--outfile=dist/app.js
# ==== Stage 2: Runtime ====
FROM gcr.io/distroless/nodejs20-debian12:nonroot
WORKDIR /app
COPY --from=builder /app/dist/app.js ./app.js
USER nonroot:nonroot
EXPOSE 3000
CMD ["app.js"]
13. When to Use Which Strategy #
| Condition | Choice | Reason |
|---|---|---|
| Standard REST API | tsc + distroless | Reliable, reasonable size |
| Small microservices | esbuild + distroless | Minimal size |
| Apps with dynamic imports | tsc + distroless | Bundlers can’t handle dynamic imports |
| Apps with native modules | tsc + distroless (audit) | Build tools for native, slim runtime |
| Serverless (Lambda, Cloud Run) | esbuild + distroless | Cold-start time is critical |
| Large monoliths | tsc + alpine | Needs a shell for debugging |
14. TypeScript Dockerfile Review Checklist #
BASE IMAGE:
□ Explicit tag (node:20.11.1-alpine3.20, not latest)
□ Runtime stage uses distroless or alpine
□ Not node:latest (too large)
BUILD:
□ Multi-stage build
□ npm ci (not npm install)
□ package-lock.json committed
□ Copy package.json first, then source code (caching)
□ tsc / esbuild / tsup for the build
□ npm prune --omit=dev after the build
TYPESCRIPT:
□ outDir: ./dist
□ exclude test files and config
□ sourceMap: false for production
□ declaration: false for production
□ Separate tsconfig.json for dev and prod
RUNTIME:
□ USER nonroot
□ NODE_ENV=production
□ Only dist/ + node_modules/ + package.json (not source)
□ Logs to STDOUT
□ Signal handling (SIGTERM handler)
□ CMD in exec form
SIZE:
□ < 150 MB for tsc + distroless
□ < 70 MB for esbuild + distroless
□ docker history shows no odd layers
SECURITY:
□ No secrets in the image
□ Strict .dockerignore
□ .env excluded
□ Image scanned with trivy/grype
□ Non-root user
□ Base image up to date
DEPENDENCY:
□ Production vs dev dependencies separated
□ Regular dependency audits
□ Native modules handled correctly
Summary #
- Slim TypeScript images are very possible — TypeScript can be as slim as Go if the Dockerfile manages the build vs runtime boundary correctly.
- Size reality: 30-60 MB (esbuild bundling), 60-110 MB (tsc + distroless), 150-200 MB (multi-stage + alpine), 300-450 MB (alpine without optimization), 500-700 MB (
node:latest— anti-pattern).- Multi-stage builds are mandatory — the TypeScript compiler (tsc) and other build tools must stop at the build stage. Runtime only runs the built JavaScript.
- Separate dev vs prod dependencies —
npm prune --omit=devafter the build, ornpm ci --omit=devdirectly. Dev dependencies (typescript, ts-node, jest, eslint) must not enter runtime.- Bundling with esbuild for the smallest images (30-60 MB). Fits microservices and serverless, but not apps with complex dynamic imports.
- Production tsconfig.json —
sourceMap: false,declaration: false, exclude test files. Source maps in production = security risk and size bloat.- Native modules need build tools — install
python3,make,g++in the build stage, remove withapk del .build-depswhen done.- Distroless for mature production —
gcr.io/distroless/nodejs20-debian12:nonrootalready includes Node.js + the nonroot user. Small, secure, observability assumed solid.- Log to STDOUT, not files — JSON structured logs, include trace IDs.
console.logandconsole.errorare enough for most cases.- Explicit tags, not
latest—node:20.11.1-alpine3.20. Build reproducibility matters for auditing and rollbacks.- Slim images need solid observability — JSON logs, metrics endpoints, healthchecks, and graceful shutdown. Distroless enforces this discipline.
- Use
npm ci, notnpm install—npm cireadspackage-lock.jsonand installs the exact versions, important for reproducibility.