TypeScript #

TypeScript sering dianggap “hanya Node.js dengan typing”. Akibatnya, strategi Docker untuk TypeScript sering disamakan mentah-mentah dengan JavaScript, padahal di production, TypeScript punya fase build eksplisit yang harus diperlakukan dengan benar. TypeScript butuh kompilasi (tsc, tsup, esbuild) sebelum bisa dijalankan, dan jika fase build ini tidak dipisahkan dengan tegas dari runtime, image TypeScript akan mudah membengkak.

Artikel ini membahas secara mendetail, rasional, dan production-oriented bagaimana membangun Docker image TypeScript yang kecil, aman, dan maintainable. Kita akan lihat kenapa image TypeScript mudah membengkak, strategi yang benar (multi-stage dengan tsc/esbuild, dev vs prod dependency, distroless), dan kapan harus berhenti optimasi.

1. Realita Ukuran Image TypeScript #

Setup Ukuran Image
node:latest + ts-node 500-700 MB
node:alpine + npm install (semua) 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: Selisih antara 700 MB dan 30 MB adalah lebih dari 20x lipat. Image TypeScript yang optimal bisa sekecil image Go — tapi hanya dengan disiplin build boundary yang ketat.

2. Kenapa Image TypeScript Mudah Membengkak? #

TypeScript Tidak Bisa Langsung Dijalankan #

Tidak seperti JavaScript yang bisa langsung dijalankan Node.js, TypeScript harus di-transpile dulu menjadi JavaScript. Tools yang terlibat:

  • tsc — TypeScript compiler resmi.
  • ts-node — runtime TypeScript untuk development.
  • tsx — alternatif ts-node yang lebih cepat.
  • esbuild, swc, tsup — transpiler cepat untuk build.

Semua ini hanya dibutuhkan saat build, bukan saat runtime. Tapi tanpa multi-stage build yang disiplin, mereka akan ikut masuk image akhir.

node_modules Mengandung Dua Dunia #

Package Node.js punya dua kategori di 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 — dibutuhkan saat runtime.
  • devDependencies — hanya untuk development (TypeScript compiler, type definition, test runner, linter, formatter).

Tanpa filter yang tepat, npm install akan install keduanya, dan image runtime akan penuh dengan tool development.

Build Tool Bocor ke Runtime #

Saat TypeScript di-build:

  • Source .ts files.
  • Source maps (kalau di-generate).
  • Type definition files (.d.ts).
  • Build configuration (tsconfig.json).
  • Test files.
  • Mock files.

Semua ini biasanya ada di project, dan tanpa filter, bisa ikut masuk runtime image.

Native Module #

Package seperti bcrypt, sharp, canvas, node-gyp-based libraries punya native component yang di-compile saat install. Ini menarik compiler dan header file ke build stage — dan kalau di-handle salah, ke runtime juga.

3. Prinsip Utama: JavaScript Hasil Build, Bukan Source TypeScript #

Runtime image TypeScript idealnya hanya berisi: Node.js runtime + JavaScript hasil build + production dependency.

Titik. Tidak ada tsc, tidak ada ts-node, tidak ada source .ts, tidak ada type definition, tidak ada test runner, tidak ada dev dependency.

4. Strategi Multi-Stage Build #

4.1 Pola Dasar: tsc + Distroless #

# syntax=docker/dockerfile:1.7

# ==== Stage 1: Build ====
FROM node:20-alpine AS builder

WORKDIR /app

# Cache dependency layer
COPY package.json package-lock.json ./
RUN npm ci

# Copy source dan build
COPY tsconfig.json ./
COPY src ./src
RUN npm run build

# Hapus dev dependency
RUN npm prune --omit=dev

# ==== Stage 2: Runtime ====
FROM gcr.io/distroless/nodejs20-debian12:nonroot

WORKDIR /app

# Copy output build + production dependency
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"]

Ukuran tipikal: 60-110 MB.

Penjelasan penting:

Stage 1: Build

  • node:20-alpine — image Node.js 20 di Alpine. Sudah cukup ramping untuk build.
  • npm ci — install semua dependency (termasuk dev) untuk bisa menjalankan build.
  • npm run build — menjalankan script build di package.json (biasanya tsc atau build tool lain).
  • npm prune --omit=devsetelah build sukses, hapus devDependency dari node_modules. Ini sangat penting agar node_modules yang di-copy ke runtime hanya berisi production dependency.

Stage 2: Runtime

  • gcr.io/distroless/nodejs20-debian12:nonroot — Node.js 20 di distroless. Hanya runtime essentials, tanpa shell.
  • Hanya copy output dist/ dan node_modules/ (yang sudah di-prune). Source .ts tidak ikut.
  • NODE_ENV=production — beberapa library otomatis disable fitur dev saat env ini di-set.

Kapan pakai: Default untuk production service TypeScript. Distroless + npm prune adalah kombinasi yang solid.

4.2 Pola dengan TypeScript Compiler Resmi #

Jika project pakai tsc langsung (bukan build tool lain), konfigurasi tsconfig.json harus di-set untuk output ke direktori yang akan di-copy:

{
  "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"]
}

Penting:

  • outDir: ./dist — output ke direktori yang akan di-copy.
  • rootDir: ./src — batasi source dari direktori tertentu.
  • exclude test files dan config yang tidak perlu di production.

4.3 Pola dengan Bundler (esbuild / tsup) #

Untuk image lebih kecil, gunakan bundler yang menghasilkan single file JavaScript. Ini mengeliminasi node_modules sepenuhnya dari runtime (kecuali untuk dynamic import atau built-in module tertentu).

# ==== 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 dengan 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"]

Ukuran tipikal: 30-60 MB (untuk aplikasi dengan dependency minimal).

Catatan untuk bundling:

  • --external — flag untuk module yang tidak boleh di-bundle (misal native module atau dynamic require).
  • --platform=node — target Node.js (bukan browser).
  • --minify — kompres output.
  • Dynamic import harus di-handle: jika kode pakai import('module') yang tidak statis, bundler tidak akan menyertakannya.

Kapan pakai: Microservice kecil, serverless function, CLI tools, aplikasi dengan dependency minimal.

Kapan tidak: Aplikasi dengan banyak dynamic import, plugin system, atau dependency besar yang tidak praktis di-bundle.

4.4 Pola dengan Native Module (sharp, bcrypt) #

Native module punya tantangan khusus: mereka di-compile untuk OS target. Build stage harus sama OS-nya dengan runtime, atau pakai --platform di npm.

FROM node:20-alpine AS builder

WORKDIR /app

# Install build tools untuk native module
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 module untuk runtime platform (jika perlu)
RUN npm rebuild sharp

# Hapus dev dependency dan 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"]

Penting:

  • --virtual .build-deps — group sementara, dihapus dengan apk del .build-deps di akhir.
  • npm rebuild — kadang perlu untuk memastikan binary cocok dengan runtime.
  • Untuk distroless runtime, library OS yang dibutuhkan native module harus di-copy manual dari build stage.

5. Memisahkan Production dan Dev Dependency #

5.1 Strategi package.json #

{
  "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"
  }
}

Penting: npm install (default) akan install kedua-duanya. Untuk production, harus pakai npm ci --omit=dev atau npm prune --omit=dev.

5.2 Lock File Wajib #

package-lock.json harus di-commit. npm ci (bukan npm install) membaca lock file dan install versi yang tepat. Ini memastikan build yang reproducible.

# Generate lock file (sekali)
npm install

# Di CI/Docker, selalu pakai npm ci
npm ci

5.3 Multi-Target Build #

Untuk development image yang punya semua tooling, dan production image yang ramping:

FROM node:20-alpine AS base
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

FROM base AS dev
# Dev punya semua tooling
COPY . .
CMD ["npm", "run", "dev"]

FROM base AS prod
# Prod: build dan 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. Optimasi TypeScript Build #

6.1 tsconfig.json untuk Produksi #

{
  "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/**"
  ]
}

Kunci:

  • sourceMap: false — source map tidak ikut production. Ini menghemat ukuran dan mencegah reverse engineering.
  • declaration: false.d.ts files tidak ikut (untuk production yang tidak publish library).
  • removeComments: true — komentar di-strip dari output.
  • incremental: false — disable incremental build untuk clean rebuild.

6.2 Build Script di package.json #

{
  "scripts": {
    "build": "tsc -p tsconfig.json",
    "build:watch": "tsc -w -p tsconfig.json",
    "build:prod": "tsc -p tsconfig.prod.json"
  }
}

6.3 Build dengan esbuild (10-100x lebih cepat) #

npx esbuild src/index.ts \
  --bundle \
  --platform=node \
  --target=node20 \
  --format=cjs \
  --minify \
  --outfile=dist/app.js

Atau dengan tsup:

{
  "scripts": {
    "build": "tsup src/index.ts --format cjs --target node20 --minify"
  }
}

7. Source Map dan Security #

Source map di production adalah trade-off:

  • Pro: debugging lebih mudah, stack trace bisa di-resolve.
  • Kontra: source code bisa di-rekonstruksi dari source map (security risk), image lebih besar.

Rekomendasi untuk production:

  • sourceMap: false di tsconfig.json untuk default production.
  • Jika butuh debugging, upload source map ke Sentry atau error tracking service, jangan bundle ke image.
  • Atau simpan source map di storage terpisah yang di-mount saat incident.

8. Production Logging #

TypeScript/Node.js default console.log ke STDOUT, console.error ke STDERR. Untuk production, structured logging lebih baik:

// 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,
});

Prinsip:

  • Log ke STDOUT (bukan file).
  • Format JSON untuk log aggregator.
  • Include trace ID untuk distributed tracing.
  • Log level via env var.

9. Healthcheck dan Signal Handling #

Healthcheck #

Buat endpoint /health di aplikasi:

import express from 'express';

const app = express();

app.get('/health', async (req, res) => {
  try {
    // Cek dependency (database, cache, dll)
    await db.ping();
    res.status(200).json({ status: 'ok' });
  } catch (error) {
    res.status(503).json({ status: 'error', message: error.message });
  }
});

Untuk image yang punya shell (alpine), HEALTHCHECK di Dockerfile:

HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD wget --quiet --tries=1 --spider http://localhost:3000/health || exit 1

Untuk distroless, HEALTHCHECK sulit — pindahkan ke orchestrator.

Signal Handling #

Node.js default sudah handle SIGTERM, tapi pastikan shutdown handler bersih:

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 setelah 30 detik
  setTimeout(() => {
    console.error('Forced shutdown after timeout');
    process.exit(1);
  }, 30000).unref();
};

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

Pastikan juga CMD dalam exec form (CMD ["node", "dist/index.js"]) agar signal sampai langsung ke Node.js.

10. Security Hardening #

Non-Root User #

# Untuk distroless, sudah ada nonroot
USER nonroot:nonroot

# Untuk alpine
RUN addgroup -g 1001 -S appgroup \
 && adduser -u 1001 -S appuser -G appgroup
USER appuser

Jangan Expose .env #

.env harus di-mount saat runtime, tidak pernah di-bake ke image.

# docker-compose.yml
services:
  app:
    env_file: .env

Pastikan .env di .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 image menarik banyak transitive dep, dan CVE muncul berkala. Rebuild image secara berkala.

11. Anti-Pattern yang Harus Dihindari #

✗ Pakai ts-node di Production #

// ✗ ts-node ada di image runtime, dan source .ts harus ikut
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm install
CMD ["npx", "ts-node", "src/index.ts"]

Solusi: Compile ke JavaScript di build stage, jalankan JS di runtime.

✗ Copy node_modules Utuh #

// ✗ node_modules berisi devDependency yang tidak dibutuhkan
FROM node:20-alpine AS builder
WORKDIR /app
COPY . .
RUN npm install
// node_modules sekarang punya TypeScript, ts-node, jest, dll

FROM node:20-alpine
COPY --from=builder /app .
CMD ["node", "dist/index.js"]
// node_modules masih punya devDependency

Solusi: npm prune --omit=dev setelah build, atau install langsung dengan --omit=dev.

✗ Source TypeScript Masuk Runtime #

// ✗ Source .ts dan .d.ts ikut ke image runtime
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci
CMD ["node", "dist/index.js"]

Solusi: Copy hanya dist/ (output) dan package.json ke runtime.

✗ Source Map di Production #

// ✗ Source map bocor ke runtime, image lebih besar
{
  "compilerOptions": {
    "sourceMap": true
  }
}

Solusi: sourceMap: false untuk production build.

✗ Tag Tanpa Versi #

// ✗ Build tidak deterministik
FROM node:latest

Solusi: Pin tag: node:20.11.1-alpine3.20.

✗ Pakai npm install di CI #

# ✗ Bisa install versi yang tidak sama dengan lock file
npm install

Solusi: Selalu pakai npm ci di CI/Docker.

12. Contoh Dockerfile TypeScript Production-Grade #

12.1 Versi Standar (tsc + distroless) #

# syntax=docker/dockerfile:1.7

# ==== Stage 1: Build ====
FROM node:20.11.1-alpine3.20 AS builder

WORKDIR /app

# Cache dependency
COPY package.json package-lock.json ./
RUN npm ci

# Build TypeScript
COPY tsconfig.json ./
COPY src ./src
RUN npm run build

# Hapus devDependency setelah 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 Versi Bundling (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 dengan 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. Kapan Pakai Strategi Mana #

Kondisi Pilihan Alasan
REST API standar tsc + distroless Reliable, ukuran wajar
Microservice kecil esbuild + distroless Ukuran minimal
Aplikasi dengan dynamic import tsc + distroless Bundler tidak bisa handle dynamic
Aplikasi dengan native module tsc + distroless (audit) Build tool untuk native, runtime ramping
Serverless (Lambda, Cloud Run) esbuild + distroless Cold start time kritis
Monolith besar tsc + alpine Butuh shell untuk debugging

14. Checklist Review Dockerfile TypeScript #

BASE IMAGE:
  □ Tag eksplisit (node:20.11.1-alpine3.20, bukan latest)
  □ Runtime stage pakai distroless atau alpine
  □ Bukan node:latest (terlalu besar)

BUILD:
  □ Multi-stage build
  □ npm ci (bukan npm install)
  □ package-lock.json di-commit
  □ Copy package.json dulu, baru source code (cache)
  □ tsc / esbuild / tsup untuk build
  □ npm prune --omit=dev setelah build

TYPESCRIPT:
  □ outDir: ./dist
  □ exclude test files dan config
  □ sourceMap: false untuk production
  □ declaration: false untuk production
  □ tsconfig.json terpisah untuk dev dan prod

RUNTIME:
  □ USER nonroot
  □ NODE_ENV=production
  □ Hanya dist/ + node_modules/ + package.json (bukan source)
  □ Log ke STDOUT
  □ Signal handling (SIGTERM handler)
  □ CMD dalam exec form

UKURAN:
  □ < 150 MB untuk tsc + distroless
  □ < 70 MB untuk esbuild + distroless
  □ docker history tidak ada layer aneh

KEAMANAN:
  □ Tidak ada secret di image
  □ .dockerignore tegas
  □ .env di-exclude
  □ Image di-scan trivy/grype
  □ Non-root user
  □ Base image up-to-date

DEPENDENCY:
  □ Production vs dev dependency terpisah
  □ Audit dependency berkala
  □ Native module di-handle dengan benar

Ringkasan #

  • Image TypeScript ramping sangat mungkin — TypeScript bisa se-ramping Go jika Dockerfile mengelola boundary build vs runtime dengan benar.
  • Realita ukuran: 30-60 MB (esbuild bundling), 60-110 MB (tsc + distroless), 150-200 MB (multi-stage + alpine), 300-450 MB (alpine tanpa optimasi), 500-700 MB (node:latest — anti-pattern).
  • Multi-stage build wajib — TypeScript compiler (tsc) dan build tool lain harus berhenti di build stage. Runtime hanya menjalankan JavaScript hasil build.
  • Pisahkan dev vs prod dependencynpm prune --omit=dev setelah build, atau npm ci --omit=dev langsung. Dev dependency (typescript, ts-node, jest, eslint) tidak boleh masuk runtime.
  • Bundling dengan esbuild untuk image paling kecil (30-60 MB). Cocok untuk microservice dan serverless, tapi tidak untuk aplikasi dengan dynamic import kompleks.
  • tsconfig.json productionsourceMap: false, declaration: false, exclude test files. Source map di production = security risk dan ukuran membengkak.
  • Native module butuh build tool — install python3, make, g++ di build stage, hapus dengan apk del .build-deps setelah selesai.
  • Distroless untuk production maturegcr.io/distroless/nodejs20-debian12:nonroot sudah include Node.js + user nonroot. Ukuran kecil, aman, observability diasumsikan solid.
  • Log ke STDOUT, bukan file — JSON structured log, include trace ID. console.log dan console.error sudah cukup untuk kebanyakan kasus.
  • Tag eksplisit, bukan latestnode:20.11.1-alpine3.20. Build reproducibility penting untuk audit dan rollback.
  • Image ramping butuh observability solid — log JSON, metrics endpoint, healthcheck, dan graceful shutdown. Distroless memaksa disiplin ini.
  • Pakai npm ci, bukan npm installnpm ci membaca package-lock.json dan install versi yang tepat, penting untuk reproducibility.

← Sebelumnya: PHP   Berikutnya: Ruby →

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