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 #

SetupImage Size
node:latest + ts-node500-700 MB
node:alpine + npm install (everything)300-450 MB
node:slim + multi-stage + npm ci --omit=dev150-200 MB
Multi-stage + distroless Node.js60-110 MB
Bundling (esbuild) + distroless30-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 .ts files.
  • 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 the build script in package.json (usually tsc or another build tool).
  • npm prune --omit=devafter a successful build, removes devDependencies from node_modules. This is critical so the node_modules copied 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 and node_modules/ (already pruned) are copied. .ts source 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.
  • exclude test 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 with apk del .build-deps at 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.ts files 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: false in tsconfig.json as 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 #

ConditionChoiceReason
Standard REST APItsc + distrolessReliable, reasonable size
Small microservicesesbuild + distrolessMinimal size
Apps with dynamic importstsc + distrolessBundlers can’t handle dynamic imports
Apps with native modulestsc + distroless (audit)Build tools for native, slim runtime
Serverless (Lambda, Cloud Run)esbuild + distrolessCold-start time is critical
Large monolithstsc + alpineNeeds 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 dependenciesnpm prune --omit=dev after the build, or npm ci --omit=dev directly. 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.jsonsourceMap: 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 with apk del .build-deps when done.
  • Distroless for mature productiongcr.io/distroless/nodejs20-debian12:nonroot already includes Node.js + the nonroot user. Small, secure, observability assumed solid.
  • Log to STDOUT, not files — JSON structured logs, include trace IDs. console.log and console.error are enough for most cases.
  • Explicit tags, not latestnode: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, not npm installnpm ci reads package-lock.json and installs the exact versions, important for reproducibility.

← Previous: PHP   Next: Ruby →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact