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
.tsfiles. - 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 scriptbuilddipackage.json(biasanyatscatau build tool lain).npm prune --omit=dev— setelah build sukses, hapus devDependency darinode_modules. Ini sangat penting agarnode_modulesyang 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/dannode_modules/(yang sudah di-prune). Source.tstidak 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.excludetest 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 denganapk del .build-depsdi 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.tsfiles 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: falseditsconfig.jsonuntuk 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 dependency —
npm prune --omit=devsetelah build, ataunpm ci --omit=devlangsung. 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 production —
sourceMap: 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 denganapk del .build-depssetelah selesai.- Distroless untuk production mature —
gcr.io/distroless/nodejs20-debian12:nonrootsudah include Node.js + user nonroot. Ukuran kecil, aman, observability diasumsikan solid.- Log ke STDOUT, bukan file — JSON structured log, include trace ID.
console.logdanconsole.errorsudah cukup untuk kebanyakan kasus.- Tag eksplisit, bukan
latest—node: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, bukannpm install—npm cimembacapackage-lock.jsondan install versi yang tepat, penting untuk reproducibility.