Lompat ke konten
·23 menit membaca

Docker untuk Node.js: Setup Production-Ready yang Tidak Dibahas Orang

Multi-stage builds, pengguna non-root, health checks, manajemen secrets, dan optimasi ukuran image. Pola Docker yang saya gunakan untuk setiap deployment Node.js produksi.

Bagikan:X / TwitterLinkedIn

Sebagian besar Dockerfile Node.js di produksi itu buruk. Bukan "sedikit kurang optimal" buruk. Maksud saya berjalan sebagai root, mengirim image 600MB dengan devDependencies terpasang, tanpa health check, dan secrets yang di-hardcode di environment variables yang bisa dibaca siapa saja dengan docker inspect.

Saya tahu karena saya menulis Dockerfile seperti itu. Selama bertahun-tahun. Semuanya berjalan, jadi saya tidak pernah mempertanyakannya. Kemudian suatu hari audit keamanan menandai container kami berjalan sebagai PID 1 root dengan akses tulis ke seluruh filesystem, dan saya menyadari bahwa "berjalan" dan "production-ready" adalah standar yang sangat berbeda.

Ini adalah setup Docker yang sekarang saya gunakan untuk setiap proyek Node.js. Ini bukan teoritis. Ini menjalankan layanan di balik situs ini dan beberapa situs lain yang saya kelola. Setiap pola di sini ada karena saya pernah terkena dampak buruknya atau menyaksikan orang lain terkena.

Mengapa Dockerfile Anda Saat Ini Mungkin Salah#

Izinkan saya menebak seperti apa Dockerfile Anda:

dockerfile
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["node", "server.js"]

Ini adalah "hello world"-nya Dockerfile. Ini berfungsi. Tapi juga memiliki setidaknya lima masalah yang akan merugikan Anda di produksi.

Berjalan sebagai Root#

Secara default, image Docker node berjalan sebagai root. Itu berarti proses aplikasi Anda memiliki hak root di dalam container. Jika seseorang mengeksploitasi kerentanan di aplikasi Anda — bug path traversal, SSRF, atau dependency dengan backdoor — mereka memiliki akses root ke filesystem container, bisa memodifikasi binary, menginstal paket, dan berpotensi melakukan eskalasi lebih lanjut tergantung konfigurasi runtime container Anda.

"Tapi container itu terisolasi!" Sebagian. Container escape itu nyata. CVE-2024-21626, CVE-2019-5736 — ini adalah kebocoran container di dunia nyata. Berjalan sebagai non-root adalah langkah pertahanan berlapis. Tidak ada biayanya dan menutup seluruh kelas serangan.

Menginstal devDependencies di Produksi#

npm install tanpa flag menginstal semuanya. Test runner, linter, build tools, type checker — semuanya berada di image produksi Anda. Ini membengkakkan image Anda ratusan megabyte dan meningkatkan permukaan serangan. Setiap paket tambahan adalah potensi kerentanan yang akan ditandai Trivy atau Snyk.

COPY Semua#

COPY . . menyalin seluruh direktori proyek Anda ke dalam image. Termasuk .git (yang bisa sangat besar), file .env (yang berisi secrets), node_modules (yang akan Anda instal ulang), file test, dokumentasi, konfigurasi CI — semuanya.

Tanpa Health Checks#

Tanpa instruksi HEALTHCHECK, Docker tidak tahu apakah aplikasi Anda benar-benar melayani traffic. Proses bisa berjalan tapi deadlock, kehabisan memori, atau terjebak dalam infinite loop. Docker akan melaporkan container sebagai "running" karena proses belum berhenti. Load balancer Anda terus mengirim traffic ke container zombie.

Tanpa Strategi Layer Caching#

Menyalin semuanya sebelum menginstal dependensi berarti mengubah satu baris kode sumber membatalkan cache npm install. Setiap build menginstal ulang semua dependensi dari awal. Di proyek dengan dependensi berat, itu 2-3 menit waktu terbuang per build.

Mari kita perbaiki semuanya.

Multi-Stage Builds: Keuntungan Terbesar#

Multi-stage builds adalah perubahan paling berdampak yang bisa Anda lakukan pada Dockerfile Node.js. Konsepnya sederhana: gunakan satu tahap untuk membangun aplikasi Anda, lalu salin hanya artefak yang diperlukan ke image final yang bersih dan minimal.

Berikut perbedaannya dalam praktik:

dockerfile
# Tahap tunggal: ~600MB
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]
 
# Multi-stage: ~150MB
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
 
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
CMD ["node", "dist/server.js"]

Tahap builder memiliki semuanya: Node.js lengkap, npm, build tools, kode sumber, devDependencies. Tahap runner hanya memiliki apa yang diperlukan saat runtime. Tahap builder dibuang sepenuhnya — tidak masuk ke image final.

Perbandingan Ukuran Nyata#

Saya mengukur ini pada proyek Express.js API yang sebenarnya dengan sekitar 40 dependensi:

PendekatanUkuran Image
node:20 + npm install1.1 GB
node:20-slim + npm install420 MB
node:20-alpine + npm ci280 MB
Multi-stage + alpine + production deps saja150 MB
Multi-stage + alpine + deps yang dipangkas95 MB

Itu pengurangan 10x dari pendekatan naif. Image yang lebih kecil berarti pull yang lebih cepat, deployment yang lebih cepat, dan permukaan serangan yang lebih kecil.

Mengapa Alpine?#

Alpine Linux menggunakan musl libc alih-alih glibc, dan tidak menyertakan cache package manager, dokumentasi, atau sebagian besar utilitas yang Anda temukan di distribusi Linux standar. Image dasar node:20-alpine berukuran sekitar 50MB dibandingkan 350MB untuk node:20-slim dan lebih dari 1GB untuk node:20 penuh.

Trade-off-nya adalah beberapa paket npm dengan native bindings (seperti bcrypt, sharp, canvas) perlu dikompilasi terhadap musl. Dalam kebanyakan kasus ini berjalan lancar — npm akan mengunduh prebuilt binary yang benar. Jika Anda menemui masalah, Anda bisa menginstal build dependencies di tahap builder:

dockerfile
FROM node:20-alpine AS builder
RUN apk add --no-cache python3 make g++
# ... sisa build

Build tools ini hanya ada di tahap builder. Tidak ada di image final Anda.

Dockerfile Produksi Lengkap#

Berikut Dockerfile yang saya gunakan sebagai titik awal untuk setiap proyek Node.js. Setiap baris disengaja.

dockerfile
# ============================================
# Tahap 1: Instal dependensi
# ============================================
FROM node:20-alpine AS deps
 
# Keamanan: buat direktori kerja sebelum yang lain
WORKDIR /app
 
# Instal dependensi berdasarkan lockfile
# Salin HANYA file package terlebih dahulu — ini penting untuk layer caching
COPY package.json package-lock.json ./
 
# ci lebih baik dari install: lebih cepat, lebih ketat, dan reproducible
# --omit=dev mengecualikan devDependencies dari tahap ini
RUN npm ci --omit=dev
 
# ============================================
# Tahap 2: Build aplikasi
# ============================================
FROM node:20-alpine AS builder
 
WORKDIR /app
 
# Salin file package dan instal SEMUA dependensi (termasuk dev)
COPY package.json package-lock.json ./
RUN npm ci
 
# SEKARANG salin kode sumber — perubahan di sini tidak membatalkan cache npm ci
COPY . .
 
# Build aplikasi (kompilasi TypeScript, Next.js build, dll.)
RUN npm run build
 
# ============================================
# Tahap 3: Runner produksi
# ============================================
FROM node:20-alpine AS runner
 
# Tambahkan label untuk metadata image
LABEL maintainer="your-email@example.com"
LABEL org.opencontainers.image.source="https://github.com/yourorg/yourrepo"
 
# Keamanan: instal dumb-init untuk penanganan sinyal PID 1 yang benar
RUN apk add --no-cache dumb-init
 
# Keamanan: atur NODE_ENV sebelum yang lain
ENV NODE_ENV=production
 
# Keamanan: gunakan pengguna non-root
# Image node sudah menyertakan pengguna 'node' (uid 1000)
USER node
 
# Buat direktori app yang dimiliki pengguna node
WORKDIR /app
 
# Salin dependensi produksi dari tahap deps
COPY --from=deps --chown=node:node /app/node_modules ./node_modules
 
# Salin aplikasi yang sudah di-build dari tahap builder
COPY --from=deps --chown=node:node /app/package.json ./
COPY --from=builder --chown=node:node /app/dist ./dist
 
# Expose port (hanya dokumentasi — tidak mempublikasikannya)
EXPOSE 3000
 
# Health check: curl tidak tersedia di alpine, gunakan node
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
  CMD node -e "require('http').get('http://localhost:3000/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })"
 
# Gunakan dumb-init sebagai PID 1 untuk menangani sinyal dengan benar
ENTRYPOINT ["dumb-init", "--"]
 
# Jalankan aplikasi
CMD ["node", "dist/server.js"]

Izinkan saya menjelaskan bagian yang tidak langsung jelas.

Mengapa Tiga Tahap Alih-alih Dua?#

Tahap deps menginstal hanya dependensi produksi. Tahap builder menginstal semuanya (termasuk devDependencies) dan membangun aplikasi. Tahap runner menyalin deps produksi dari deps dan kode yang sudah di-build dari builder.

Mengapa tidak menginstal production deps di tahap builder? Karena tahap builder memiliki devDependencies tercampur. Anda harus menjalankan npm prune --production setelah build, yang lebih lambat dan kurang reliable dibanding instalasi dependensi produksi yang bersih.

Mengapa dumb-init?#

Ketika Anda menjalankan node server.js di container, Node.js menjadi PID 1. PID 1 memiliki perilaku khusus di Linux: tidak menerima signal handler default. Jika Anda mengirim SIGTERM ke container (yang dilakukan docker stop), Node.js sebagai PID 1 tidak akan menanganinya secara default. Docker menunggu 10 detik, lalu mengirim SIGKILL, yang langsung menghentikan proses tanpa cleanup — tidak ada graceful shutdown, tidak menutup koneksi database, tidak menyelesaikan request yang sedang berjalan.

dumb-init bertindak sebagai PID 1 dan meneruskan sinyal dengan benar ke aplikasi Anda. Proses Node.js Anda menerima SIGTERM seperti yang diharapkan dan bisa shutdown dengan anggun:

javascript
// server.js
const server = app.listen(3000);
 
process.on('SIGTERM', () => {
  console.log('SIGTERM diterima, shutdown dengan anggun');
  server.close(() => {
    console.log('Server HTTP ditutup');
    // Tutup koneksi database, flush log, dll.
    process.exit(0);
  });
});

Alternatifnya adalah flag --init di docker run, tapi memasukkannya ke image berarti berfungsi terlepas dari bagaimana container dijalankan.

File .dockerignore#

Ini sama pentingnya dengan Dockerfile itu sendiri. Tanpanya, COPY . . mengirim semuanya ke Docker daemon:

# .dockerignore
node_modules
npm-debug.log*
.git
.gitignore
.env
.env.*
!.env.example
Dockerfile
docker-compose*.yml
.dockerignore
README.md
LICENSE
.github
.vscode
.idea
coverage
.nyc_output
*.test.ts
*.test.js
*.spec.ts
*.spec.js
__tests__
test
tests
docs
.husky
.eslintrc*
.prettierrc*
tsconfig.json
jest.config.*
vitest.config.*

Setiap file di .dockerignore adalah file yang tidak akan dikirim ke build context, tidak akan masuk ke image Anda, dan tidak akan membatalkan layer cache ketika berubah.

Layer Caching: Berhenti Menunggu 3 Menit Per Build#

Docker membangun image dalam layer. Setiap instruksi membuat layer. Jika layer belum berubah, Docker menggunakan versi yang di-cache. Tapi inilah detail pentingnya: jika sebuah layer berubah, semua layer berikutnya dibatalkan.

Inilah mengapa urutan instruksi sangat penting.

Urutan yang Salah#

dockerfile
COPY . .
RUN npm ci

Setiap kali Anda mengubah file apa pun — satu baris di satu file sumber — Docker melihat bahwa layer COPY . . berubah. Ini membatalkan layer itu dan semua setelahnya, termasuk npm ci. Anda menginstal ulang semua dependensi di setiap perubahan kode.

Urutan yang Benar#

dockerfile
COPY package.json package-lock.json ./
RUN npm ci
COPY . .

Sekarang npm ci hanya berjalan ketika package.json atau package-lock.json berubah. Jika Anda hanya mengubah kode sumber, Docker menggunakan kembali layer npm ci yang di-cache. Di proyek dengan 500+ dependensi, ini menghemat 60-120 detik per build.

Cache Mount untuk npm#

Docker BuildKit mendukung cache mount yang mempertahankan cache npm antar build:

dockerfile
RUN --mount=type=cache,target=/root/.npm \
    npm ci --omit=dev

Ini menyimpan cache unduhan npm antar build. Jika dependensi sudah diunduh di build sebelumnya, npm menggunakan versi yang di-cache alih-alih mengunduhnya lagi. Ini sangat berguna di CI di mana Anda sering melakukan build.

Untuk menggunakan BuildKit, atur environment variable:

bash
DOCKER_BUILDKIT=1 docker build -t myapp .

Atau tambahkan ke konfigurasi Docker daemon:

json
{
  "features": {
    "buildkit": true
  }
}

Menggunakan ARG untuk Cache Busting#

Terkadang Anda perlu memaksa layer untuk di-rebuild. Misalnya, jika Anda menarik tag latest dari registry dan ingin memastikan mendapat versi terbaru:

dockerfile
ARG CACHE_BUST=1
RUN npm ci

Build dengan nilai unik untuk membatalkan cache:

bash
docker build --build-arg CACHE_BUST=$(date +%s) -t myapp .

Gunakan ini secukupnya. Inti dari caching adalah kecepatan — hanya batalkan cache ketika ada alasan.

Manajemen Secrets: Berhenti Meletakkan Secrets di Dockerfile#

Ini adalah salah satu kesalahan paling umum dan berbahaya. Saya melihatnya terus-menerus:

dockerfile
# JANGAN PERNAH LAKUKAN INI
ENV DATABASE_URL=postgres://user:password@db:5432/myapp
ENV API_KEY=sk-live-abc123def456

Environment variables yang diatur dengan ENV di Dockerfile tertanam di image. Siapa saja yang menarik image bisa melihatnya dengan docker inspect atau docker history. Mereka juga terlihat di setiap layer setelah diatur. Bahkan jika Anda unset nanti, mereka ada di riwayat layer.

Tiga Level Secrets#

1. Build-time secrets (Docker BuildKit)

Jika Anda memerlukan secrets selama build (seperti token npm registry private), gunakan flag --secret BuildKit:

dockerfile
# syntax=docker/dockerfile:1
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
 
# Mount secret saat build — tidak pernah disimpan di image
RUN --mount=type=secret,id=npmrc,target=/app/.npmrc \
    npm ci
 
COPY . .
RUN npm run build

Build dengan:

bash
docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp .

File .npmrc tersedia selama perintah RUN tapi tidak pernah di-commit ke layer image manapun. Tidak muncul di docker history atau docker inspect.

2. Runtime secrets melalui environment variables

Untuk secrets yang dibutuhkan aplikasi Anda saat runtime, berikan saat memulai container:

bash
docker run -d \
  -e DATABASE_URL="postgres://user:pass@db:5432/myapp" \
  -e API_KEY="sk-live-abc123" \
  myapp

Atau dengan env file:

bash
docker run -d --env-file .env.production myapp

Ini terlihat melalui docker inspect pada container yang sedang berjalan, tapi tidak tertanam di image. Siapa saja yang menarik image tidak mendapat secrets-nya.

3. Docker secrets (Swarm / Kubernetes)

Untuk manajemen secrets yang tepat di lingkungan yang diorkestrasikan:

yaml
# docker-compose.yml (mode Swarm)
version: "3.8"
services:
  api:
    image: myapp:latest
    secrets:
      - db_password
      - api_key
 
secrets:
  db_password:
    external: true
  api_key:
    external: true

Docker memasang secrets sebagai file di /run/secrets/<nama_secret>. Aplikasi Anda membacanya dari filesystem:

javascript
import { readFileSync } from "fs";
 
function getSecret(name) {
  try {
    return readFileSync(`/run/secrets/${name}`, "utf8").trim();
  } catch {
    // Fallback ke environment variable untuk pengembangan lokal
    return process.env[name.toUpperCase()];
  }
}
 
const dbPassword = getSecret("db_password");

Ini adalah pendekatan paling aman karena secrets tidak pernah muncul di environment variables, daftar proses, atau output inspeksi container.

File .env dan Docker#

Jangan pernah menyertakan file .env di image Docker Anda. .dockerignore Anda harus mengecualikannya (itulah mengapa kita mencantumkan .env dan .env.* sebelumnya). Untuk pengembangan lokal dengan docker-compose, mount saat runtime:

yaml
services:
  api:
    env_file:
      - .env.local

Health Checks: Beri Tahu Docker Bahwa Aplikasi Anda Benar-benar Berjalan#

Health check memberi tahu Docker apakah aplikasi Anda berfungsi dengan benar. Tanpanya, Docker hanya tahu apakah proses berjalan — bukan apakah benar-benar bisa menangani request.

Instruksi HEALTHCHECK#

dockerfile
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
  CMD node -e "require('http').get('http://localhost:3000/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })"

Izinkan saya menjelaskan parameternya:

  • --interval=30s: Periksa setiap 30 detik
  • --timeout=10s: Jika pemeriksaan memakan waktu lebih dari 10 detik, anggap gagal
  • --start-period=40s: Beri aplikasi 40 detik untuk mulai sebelum menghitung kegagalan
  • --retries=3: Tandai tidak sehat setelah 3 kegagalan berturut-turut

Mengapa Tidak Menggunakan curl?#

Alpine tidak menyertakan curl secara default. Anda bisa menginstalnya (apk add --no-cache curl), tapi itu menambahkan binary lain ke image minimal Anda. Menggunakan Node.js langsung berarti nol dependensi tambahan.

Untuk health check yang lebih ringan, Anda bisa menggunakan skrip khusus:

javascript
// healthcheck.js
const http = require("http");
 
const options = {
  hostname: "localhost",
  port: 3000,
  path: "/health",
  timeout: 5000,
};
 
const req = http.request(options, (res) => {
  process.exit(res.statusCode === 200 ? 0 : 1);
});
 
req.on("error", () => process.exit(1));
req.on("timeout", () => {
  req.destroy();
  process.exit(1);
});
 
req.end();
dockerfile
COPY --chown=node:node healthcheck.js ./
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
  CMD ["node", "healthcheck.js"]

Endpoint Health#

Aplikasi Anda memerlukan endpoint /health untuk diperiksa. Jangan hanya mengembalikan 200 — benar-benar verifikasi bahwa aplikasi Anda sehat:

javascript
app.get("/health", async (req, res) => {
  const checks = {
    uptime: process.uptime(),
    timestamp: Date.now(),
    status: "ok",
  };
 
  try {
    // Periksa koneksi database
    await db.query("SELECT 1");
    checks.database = "connected";
  } catch (err) {
    checks.database = "disconnected";
    checks.status = "degraded";
  }
 
  try {
    // Periksa koneksi Redis
    await redis.ping();
    checks.redis = "connected";
  } catch (err) {
    checks.redis = "disconnected";
    checks.status = "degraded";
  }
 
  const statusCode = checks.status === "ok" ? 200 : 503;
  res.status(statusCode).json(checks);
});

Status "degraded" dengan 503 memberi tahu orchestrator untuk berhenti mengarahkan traffic ke instance ini saat pulih, tapi tidak selalu memicu restart.

Mengapa Health Checks Penting untuk Orchestrator#

Docker Swarm, Kubernetes, dan bahkan docker-compose biasa dengan restart: always menggunakan health checks untuk membuat keputusan:

  • Load balancer berhenti mengirim traffic ke container yang tidak sehat
  • Rolling updates menunggu container baru sehat sebelum menghentikan yang lama
  • Orchestrator bisa me-restart container yang menjadi tidak sehat
  • Pipeline deployment bisa memverifikasi deployment berhasil

Tanpa health checks, rolling deployment mungkin menghentikan container lama sebelum yang baru siap, menyebabkan downtime.

docker-compose untuk Pengembangan#

Lingkungan pengembangan Anda harus sedekat mungkin dengan produksi, tapi dengan kenyamanan hot reload, debugger, dan umpan balik instan. Berikut setup docker-compose yang saya gunakan untuk pengembangan:

yaml
# docker-compose.dev.yml
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile.dev
      args:
        NODE_VERSION: "20"
    ports:
      - "3000:3000"
      - "9229:9229"   # Debugger Node.js
    volumes:
      # Mount kode sumber untuk hot reload
      - .:/app
      # Volume anonim untuk mempertahankan node_modules dari image
      # Ini mencegah node_modules host menimpa node_modules container
      - /app/node_modules
    environment:
      - NODE_ENV=development
      - DATABASE_URL=postgres://postgres:devpassword@db:5432/myapp_dev
      - REDIS_URL=redis://redis:6379
    env_file:
      - .env.local
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    command: npm run dev
 
  db:
    image: postgres:16-alpine
    ports:
      - "5432:5432"
    environment:
      POSTGRES_DB: myapp_dev
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: devpassword
    volumes:
      # Volume bernama untuk data persisten antar restart container
      - pgdata:/var/lib/postgresql/data
      # Skrip inisialisasi
      - ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init.sql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5
 
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    volumes:
      - redisdata:/data
    command: redis-server --appendonly yes
 
  # Opsional: UI admin database
  adminer:
    image: adminer:latest
    ports:
      - "8080:8080"
    depends_on:
      - db
 
volumes:
  pgdata:
  redisdata:

Pola Pengembangan Utama#

Volume mount untuk hot reload: Volume mount .:/app memetakan kode sumber lokal Anda ke dalam container. Ketika Anda menyimpan file, perubahan langsung terlihat di dalam container. Dikombinasikan dengan dev server yang memantau perubahan (seperti nodemon atau tsx --watch), Anda mendapat umpan balik instan.

Trik node_modules: Volume anonim - /app/node_modules memastikan container menggunakan node_modules-nya sendiri (yang diinstal saat build image) alih-alih node_modules host Anda. Ini penting karena modul native yang dikompilasi di macOS tidak akan berjalan di dalam container Linux.

Dependensi layanan: depends_on dengan condition: service_healthy memastikan database benar-benar siap sebelum aplikasi Anda mencoba terhubung. Tanpa kondisi health check, depends_on hanya menunggu container dimulai — bukan layanan di dalamnya siap.

Volume bernama: pgdata dan redisdata bertahan antar restart container. Tanpa volume bernama, Anda akan kehilangan database setiap kali menjalankan docker-compose down.

Dockerfile Pengembangan#

Dockerfile pengembangan Anda lebih sederhana dari produksi:

dockerfile
# Dockerfile.dev
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine
 
WORKDIR /app
 
# Instal semua dependensi (termasuk devDependencies)
COPY package*.json ./
RUN npm ci
 
# Kode sumber di-mount melalui volume, tidak disalin
# Tapi tetap perlu untuk build awal
COPY . .
 
EXPOSE 3000 9229
 
CMD ["npm", "run", "dev"]

Tanpa multi-stage build, tanpa optimasi produksi. Tujuannya adalah iterasi cepat, bukan image kecil.

Docker Compose Produksi#

Docker-compose produksi adalah hal yang berbeda. Inilah yang saya gunakan:

yaml
# docker-compose.prod.yml
services:
  app:
    image: ghcr.io/yourorg/myapp:${TAG:-latest}
    restart: unless-stopped
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
    env_file:
      - .env.production
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M
        reservations:
          cpus: "0.25"
          memory: 128M
      replicas: 2
    healthcheck:
      test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/health', r => process.exit(r.statusCode === 200 ? 0 : 1))"]
      interval: 30s
      timeout: 10s
      start_period: 40s
      retries: 3
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"
    networks:
      - internal
      - web
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
 
  db:
    image: postgres:16-alpine
    restart: unless-stopped
    volumes:
      - pgdata:/var/lib/postgresql/data
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 1G
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - internal
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"
 
  redis:
    image: redis:7-alpine
    restart: unless-stopped
    command: >
      redis-server
      --appendonly yes
      --maxmemory 256mb
      --maxmemory-policy allkeys-lru
    volumes:
      - redisdata:/data
    deploy:
      resources:
        limits:
          cpus: "0.5"
          memory: 512M
    networks:
      - internal
    logging:
      driver: "json-file"
      options:
        max-size: "5m"
        max-file: "3"
 
volumes:
  pgdata:
    driver: local
  redisdata:
    driver: local
 
networks:
  internal:
    driver: bridge
  web:
    external: true

Apa yang Berbeda dari Pengembangan#

Restart policy: unless-stopped me-restart container secara otomatis jika crash, kecuali Anda secara eksplisit menghentikannya. Ini menangani skenario "crash jam 3 pagi". Alternatif always juga akan me-restart container yang sengaja Anda hentikan, yang biasanya bukan yang Anda inginkan.

Batas sumber daya: Tanpa batas, memory leak di aplikasi Node.js Anda akan mengonsumsi semua RAM yang tersedia di host, berpotensi menghentikan container lain atau host itu sendiri. Atur batas berdasarkan penggunaan aktual aplikasi Anda ditambah margin:

bash
# Monitor penggunaan aktual untuk mengatur batas yang sesuai
docker stats --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}"

Konfigurasi logging: Tanpa max-size dan max-file, log Docker tumbuh tanpa batas. Saya pernah melihat server produksi kehabisan ruang disk karena log Docker. json-file dengan rotasi adalah solusi paling sederhana. Untuk logging terpusat, ganti ke driver fluentd atau gelf:

yaml
logging:
  driver: "fluentd"
  options:
    fluentd-address: "localhost:24224"
    tag: "myapp.{{.Name}}"

Isolasi jaringan: Jaringan internal hanya bisa diakses oleh layanan di compose stack ini. Database dan Redis tidak terekspos ke host atau container lain. Hanya layanan app yang terhubung ke jaringan web, yang digunakan reverse proxy Anda (Nginx, Traefik) untuk mengarahkan traffic.

Tanpa port mapping untuk database: Perhatikan bahwa db dan redis tidak memiliki ports di konfigurasi produksi. Mereka hanya bisa diakses melalui jaringan internal Docker. Di pengembangan, kita mengeksposnya agar bisa menggunakan alat lokal (pgAdmin, Redis Insight). Di produksi, tidak ada alasan untuk mereka bisa diakses dari luar jaringan Docker.

Khusus Next.js: Output Standalone#

Next.js memiliki optimasi Docker bawaan yang banyak orang tidak tahu: mode output standalone. Ini melacak impor aplikasi Anda dan menyalin hanya file yang diperlukan untuk berjalan — tidak perlu node_modules (dependensi di-bundle).

Aktifkan di next.config.ts:

typescript
// next.config.ts
import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  output: "standalone",
};
 
export default nextConfig;

Ini mengubah output build secara dramatis. Alih-alih memerlukan seluruh direktori node_modules, Next.js menghasilkan server.js mandiri di .next/standalone/ yang hanya menyertakan dependensi yang benar-benar digunakan.

Dockerfile Produksi Next.js#

Ini adalah Dockerfile yang saya gunakan untuk proyek Next.js, berdasarkan contoh resmi Vercel tapi dengan penguatan keamanan:

dockerfile
# ============================================
# Tahap 1: Instal dependensi
# ============================================
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
 
COPY package.json package-lock.json ./
RUN npm ci
 
# ============================================
# Tahap 2: Build aplikasi
# ============================================
FROM node:20-alpine AS builder
WORKDIR /app
 
COPY --from=deps /app/node_modules ./node_modules
COPY . .
 
# Nonaktifkan telemetri Next.js saat build
ENV NEXT_TELEMETRY_DISABLED=1
 
RUN npm run build
 
# ============================================
# Tahap 3: Runner produksi
# ============================================
FROM node:20-alpine AS runner
WORKDIR /app
 
RUN apk add --no-cache dumb-init
 
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
 
# Pengguna non-root
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
 
# Salin aset publik
COPY --from=builder /app/public ./public
 
# Siapkan direktori output standalone
# Secara otomatis memanfaatkan output traces untuk mengurangi ukuran image
# https://nextjs.org/docs/advanced-features/output-file-tracing
RUN mkdir .next
RUN chown nextjs:nodejs .next
 
# Salin server standalone dan file statis
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
 
USER nextjs
 
EXPOSE 3000
 
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
 
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
  CMD node -e "require('http').get('http://localhost:3000/api/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })"
 
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "server.js"]

Perbandingan Ukuran untuk Next.js#

PendekatanUkuran Image
node:20 + node_modules lengkap + .next1.4 GB
node:20-alpine + node_modules lengkap + .next600 MB
node:20-alpine + output standalone120 MB

Output standalone itu transformatif. Image 1.4 GB menjadi 120 MB. Deploy yang membutuhkan 90 detik untuk pull sekarang menjadi 10 detik.

Penanganan File Statis#

Mode standalone Next.js tidak menyertakan folder public atau aset statis dari .next/static. Anda perlu menyalinnya secara eksplisit (yang kita lakukan di Dockerfile di atas). Di produksi, Anda biasanya ingin CDN di depannya:

typescript
// next.config.ts
const nextConfig: NextConfig = {
  output: "standalone",
  assetPrefix: process.env.CDN_URL || undefined,
};

Jika Anda tidak menggunakan CDN, Next.js menyajikan file statis secara langsung. Server standalone menangani ini dengan baik — Anda hanya perlu memastikan file berada di tempat yang tepat (yang dipastikan Dockerfile kita).

Sharp untuk Optimasi Gambar#

Next.js menggunakan sharp untuk optimasi gambar. Di image produksi berbasis Alpine, Anda perlu memastikan binary yang benar tersedia:

dockerfile
# Di tahap runner, sebelum beralih ke pengguna non-root
RUN apk add --no-cache --virtual .sharp-deps vips-dev

Atau lebih baik, instal sebagai dependensi produksi dan biarkan npm menangani binary khusus platform:

bash
npm install sharp

Image node:20-alpine berfungsi dengan prebuilt binary linux-x64-musl milik sharp. Tidak perlu konfigurasi khusus dalam kebanyakan kasus.

Pemindaian Image dan Keamanan#

Membangun image kecil dengan pengguna non-root adalah awal yang baik, tapi tidak cukup untuk beban kerja produksi yang serius. Berikut cara melangkah lebih jauh.

Trivy: Pindai Image Anda#

Trivy adalah scanner kerentanan komprehensif untuk image container. Jalankan di pipeline CI Anda:

bash
# Instal trivy
brew install aquasecurity/trivy/trivy  # macOS
# atau
apt-get install trivy  # Debian/Ubuntu
 
# Pindai image Anda
trivy image myapp:latest

Contoh output:

myapp:latest (alpine 3.19.1)
=============================
Total: 0 (UNKNOWN: 0, LOW: 0, MEDIUM: 0, HIGH: 0, CRITICAL: 0)

Node.js (node_modules/package-lock.json)
=========================================
Total: 2 (UNKNOWN: 0, LOW: 0, MEDIUM: 1, HIGH: 1, CRITICAL: 0)

┌──────────────┬────────────────┬──────────┬────────┬───────────────┐
│   Library    │ Vulnerability  │ Severity │ Status │ Fixed Version │
├──────────────┼────────────────┼──────────┼────────┼───────────────┤
│ semver       │ CVE-2022-25883 │ HIGH     │ fixed  │ 7.5.4         │
│ word-wrap    │ CVE-2023-26115 │ MEDIUM   │ fixed  │ 1.2.4         │
└──────────────┴────────────────┴──────────┴────────┴───────────────┘

Integrasikan di CI untuk menggagalkan build pada kerentanan kritis:

yaml
# .github/workflows/docker.yml
- name: Build image
  run: docker build -t myapp:${{ github.sha }} .
 
- name: Scan image
  uses: aquasecurity/trivy-action@master
  with:
    image-ref: myapp:${{ github.sha }}
    exit-code: 1
    severity: CRITICAL,HIGH
    ignore-unfixed: true

Filesystem Read-Only#

Anda bisa menjalankan container dengan filesystem root read-only. Ini mencegah penyerang memodifikasi binary, menginstal tools, atau menulis skrip berbahaya:

bash
docker run --read-only \
  --tmpfs /tmp \
  --tmpfs /app/.next/cache \
  myapp:latest

Mount --tmpfs menyediakan direktori sementara yang bisa ditulis di mana aplikasi Anda benar-benar perlu menulis (file sementara, cache). Selebihnya adalah read-only.

Di docker-compose:

yaml
services:
  app:
    image: myapp:latest
    read_only: true
    tmpfs:
      - /tmp
      - /app/.next/cache

Hapus Semua Capabilities#

Linux capabilities adalah izin yang lebih granular yang menggantikan model all-or-nothing root. Secara default, container Docker mendapat subset capabilities. Anda bisa menghapus semuanya:

bash
docker run --cap-drop=ALL myapp:latest

Jika aplikasi Anda perlu bind ke port di bawah 1024, Anda memerlukan NET_BIND_SERVICE. Tapi karena kita menggunakan port 3000 dengan pengguna non-root, kita tidak memerlukan capabilities apa pun:

yaml
services:
  app:
    image: myapp:latest
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true

no-new-privileges mencegah proses mendapat hak tambahan melalui binary setuid/setgid. Ini adalah langkah pertahanan berlapis yang tidak memerlukan biaya.

Pin Digest Base Image Anda#

Alih-alih menggunakan node:20-alpine (yang merupakan target bergerak), pin ke digest spesifik:

dockerfile
FROM node:20-alpine@sha256:abcdef123456...

Dapatkan digest dengan:

bash
docker inspect --format='{{index .RepoDigests 0}}' node:20-alpine

Ini memastikan build Anda 100% reproducible. Trade-off-nya adalah Anda tidak secara otomatis mendapat patch keamanan pada base image. Gunakan Dependabot atau Renovate untuk mengotomasi pembaruan digest:

yaml
# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: docker
    directory: "/"
    schedule:
      interval: weekly

Integrasi CI/CD: Menyatukan Semuanya#

Berikut workflow GitHub Actions lengkap yang membangun, memindai, dan mendorong image Docker:

yaml
# .github/workflows/docker.yml
name: Build and Push Docker Image
 
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
 
env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}
 
jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      security-events: write
 
    steps:
      - name: Checkout
        uses: actions/checkout@v4
 
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
 
      - name: Log in to Container Registry
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
 
      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha
            type=ref,event=branch
            type=semver,pattern={{version}}
 
      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
 
      - name: Scan image with Trivy
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}
          exit-code: 1
          severity: CRITICAL,HIGH
          ignore-unfixed: true
 
      - name: Upload Trivy results
        if: always()
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: trivy-results.sarif

BuildKit Cache di CI#

Baris cache-from: type=gha dan cache-to: type=gha,mode=max menggunakan cache GitHub Actions sebagai cache layer Docker. Ini berarti build CI Anda mendapat manfaat dari layer caching antar run. Build pertama membutuhkan 5 menit; build berikutnya dengan hanya perubahan kode membutuhkan 30 detik.

Jebakan Umum dan Cara Menghindarinya#

Konflik node_modules di dalam Image vs Host#

Jika Anda volume-mount direktori proyek Anda ke container (-v .:/app), node_modules host menimpa node_modules container. Modul native yang dikompilasi di macOS tidak akan berjalan di Linux. Selalu gunakan trik volume anonim:

yaml
volumes:
  - .:/app
  - /app/node_modules  # mempertahankan node_modules container

Penanganan SIGTERM di Proyek TypeScript#

Jika Anda menjalankan TypeScript dengan tsx atau ts-node di pengembangan, penanganan sinyal berjalan normal. Tapi di produksi, jika Anda menggunakan JavaScript yang dikompilasi dengan node, pastikan output kompilasi Anda mempertahankan signal handler. Beberapa build tools mengoptimasi kode yang "tidak terpakai".

Batas Memori dan Node.js#

Node.js tidak secara otomatis menghormati batas memori Docker. Jika container Anda memiliki batas memori 512MB, Node.js tetap akan mencoba menggunakan ukuran heap default-nya (sekitar 1.5 GB pada sistem 64-bit). Atur max old space size:

dockerfile
CMD ["node", "--max-old-space-size=384", "dist/server.js"]

Sisakan sekitar 25% margin antara batas heap Node.js dan batas memori container untuk memori non-heap (buffer, kode native, dll.).

Atau gunakan flag deteksi otomatis:

dockerfile
ENV NODE_OPTIONS="--max-old-space-size=384"

Masalah Timezone#

Alpine menggunakan UTC secara default. Jika aplikasi Anda bergantung pada timezone spesifik:

dockerfile
RUN apk add --no-cache tzdata
ENV TZ=America/New_York

Tapi lebih baik: tulis kode yang tidak bergantung timezone. Simpan semuanya dalam UTC. Konversi ke waktu lokal hanya di lapisan presentasi.

Build Arguments vs Environment Variables#

  • ARG hanya tersedia saat build. Tidak bertahan di image final (kecuali Anda menyalinnya ke ENV).
  • ENV bertahan di image dan tersedia saat runtime.
dockerfile
# Konfigurasi build-time
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine
 
# Konfigurasi runtime
ENV PORT=3000
 
# SALAH: Ini membuat secret terlihat di image
ARG API_KEY
ENV API_KEY=${API_KEY}
 
# BENAR: Berikan secret saat runtime
# docker run -e API_KEY=secret myapp

Monitoring di Produksi#

Setup Docker Anda belum lengkap tanpa observabilitas. Berikut stack monitoring minimal tapi efektif:

yaml
# docker-compose.monitoring.yml
services:
  prometheus:
    image: prom/prometheus:latest
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
      - prometheus_data:/prometheus
    ports:
      - "9090:9090"
    networks:
      - internal
 
  grafana:
    image: grafana/grafana:latest
    volumes:
      - grafana_data:/var/lib/grafana
    ports:
      - "3001:3000"
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD}
    networks:
      - internal
 
volumes:
  prometheus_data:
  grafana_data:

Ekspos metrik dari aplikasi Node.js Anda menggunakan prom-client:

javascript
import { collectDefaultMetrics, Registry, Histogram } from "prom-client";
 
const register = new Registry();
collectDefaultMetrics({ register });
 
const httpRequestDuration = new Histogram({
  name: "http_request_duration_seconds",
  help: "Durasi request HTTP dalam detik",
  labelNames: ["method", "route", "status_code"],
  buckets: [0.01, 0.05, 0.1, 0.5, 1, 5],
  registers: [register],
});
 
// Middleware
app.use((req, res, next) => {
  const end = httpRequestDuration.startTimer();
  res.on("finish", () => {
    end({ method: req.method, route: req.route?.path || req.path, status_code: res.statusCode });
  });
  next();
});
 
// Endpoint metrik
app.get("/metrics", async (req, res) => {
  res.set("Content-Type", register.contentType);
  res.end(await register.metrics());
});

Daftar Periksa#

Sebelum Anda mengirim aplikasi Node.js yang dikontainerisasi ke produksi, verifikasi:

  • Pengguna non-root — Container berjalan sebagai pengguna non-root
  • Multi-stage build — devDependencies dan build tools tidak ada di image final
  • Base Alpine — Menggunakan base image minimal
  • .dockerignore.git, .env, node_modules, tests dikecualikan
  • Layer cachingpackage.json disalin sebelum kode sumber
  • Health check — Instruksi HEALTHCHECK di Dockerfile
  • Penanganan sinyaldumb-init atau --init untuk penanganan SIGTERM yang benar
  • Tanpa secrets di image — Tidak ada ENV dengan nilai sensitif di Dockerfile
  • Batas sumber daya — Batas memori dan CPU diatur di compose/orchestrator
  • Rotasi log — Driver logging dikonfigurasi dengan ukuran maksimum
  • Pemindaian image — Trivy atau yang setara di pipeline CI
  • Versi yang di-pin — Base image dan versi dependensi di-pin
  • Batas memori--max-old-space-size diatur untuk heap Node.js

Sebagian besar ini adalah pengaturan satu kali. Lakukan sekali, buat template, dan setiap proyek baru dimulai dengan container production-ready sejak hari pertama.

Docker tidak rumit. Tapi jarak antara Dockerfile yang "berfungsi" dan yang production-ready lebih lebar dari yang kebanyakan orang pikirkan. Pola-pola dalam panduan ini menutup jarak itu. Gunakan, adaptasi, dan berhenti men-deploy container root dengan image 1GB dan tanpa health check. Diri Anda di masa depan — yang dipanggil jam 3 pagi — akan berterima kasih.

Artikel Terkait