"Environment yang beda-beda itu bukan nasib yang harus diterima — itu masalah yang bisa dihilangkan. Docker adalah titik saya berhenti percaya pada 'di komputerku jalan kok.'"
Tentang Artikel Ini
Docker mengubah cara kita develop, ship, dan run aplikasi. Tapi banyak developer — terutama yang baru mulai — merasa Docker terlalu kompleks, terlalu banyak istilah baru, dan tidak jelas kapan atau kenapa harus dipakai.
Artikel ini memotong semua noise itu. Kita mulai dari nol: kenapa Docker ada, apa masalah yang dipecahkannya, dan bagaimana cara kerjanya. Lalu kita langsung praktik — containerize aplikasi Node.js dari artikel #02, setup local dev environment dengan Docker Compose (termasuk PostgreSQL dan Redis), sampai deploy ke VPS production.
Setelah selesai, kamu akan bisa:
- Memahami konsep container vs VM
- Menulis Dockerfile yang efisien
- Setup multi-service dev environment dengan Docker Compose
- Debug container yang bermasalah
- Menjalankan database migrations dengan benar di lingkungan Docker
- Deploy aplikasi containerized ke production VPS
Prasyarat: Sudah punya aplikasi Node.js (ideal: sudah baca artikel #02). Familiar dengan terminal. Tidak perlu pengalaman Docker sebelumnya.
Daftar Isi
- Kenapa Docker?
- Konsep Inti: Image, Container, Layer
- Dockerfile: Membangun Image Sendiri
- Docker Compose: Orkestrasi Multi-Service
- Studi Kasus: Containerize REST API + PostgreSQL
- Database Migrations di Docker
- Volume, Network, dan Data Persistence
- Production-Ready: Optimasi dan Security
- Deploy ke VPS dengan Docker
Bab 1: Kenapa Docker?
Masalah yang Dipecahkan Docker
Saya masih ingat rasanya baru join sebuah tim dan dapat tugas pertama: jalankan aplikasi backend yang katanya sudah jalan mulus di production. README-nya kelihatan sederhana:
- Install Node.js 18
- Install PostgreSQL 14
- Set beberapa environment variable
- Jalankan
npm installlalunpm start
Saya ikuti semua langkahnya persis seperti tertulis, tapi aplikasinya tetap error. Setelah ditelusuri, ternyata komputer saya pakai Node.js versi 20, PostgreSQL-nya beda minor version, dan ada satu library sistem yang versinya juga tidak sama. Rekan yang menulis README itu cuma bilang: "Lho, di komputerku jalan kok."
Ini adalah masalah klasik yang disebut "works on my machine" — perbedaan environment antara development, staging, dan production menyebabkan perilaku yang tidak konsisten.
Docker memecahkan masalah ini dengan containerization: mengemas aplikasi beserta semua dependensinya (runtime, library, config) ke dalam satu unit yang bisa jalan konsisten di mana saja.
Container vs Virtual Machine
Sebelum Docker, solusi environment isolation adalah Virtual Machine (VM). Bedanya:
Virtual Machine:
┌─────────────────────────────────────────────┐
│ App A │ App B │ App C │
├─────────────────────────────────────────────┤
│ Guest OS │ Guest OS │ Guest OS │ ← tiap VM punya OS sendiri
├─────────────────────────────────────────────┤
│ Hypervisor (VMware, VirtualBox) │
├─────────────────────────────────────────────┤
│ Host Operating System │
└─────────────────────────────────────────────┘
Container:
┌─────────────────────────────────────────────┐
│ App A │ App B │ App C │
├─────────────────────────────────────────────┤
│ Docker Engine (Container Runtime) │ ← share kernel OS host
├─────────────────────────────────────────────┤
│ Host Operating System │
└─────────────────────────────────────────────┘
Container lebih ringan karena:
- Tidak butuh OS sendiri per container (share kernel host)
- Start dalam hitungan detik (VM: menit)
- Ukuran file jauh lebih kecil
- Resource overhead minimal
Kapan Pakai Docker?
Bukan berarti Docker wajib dipakai di setiap proyek — tapi ada beberapa situasi di mana manfaatnya jelas lebih besar daripada overhead belajarnya, dan menurut saya hampir selalu worth it:
Docker paling berguna untuk:
- Development environment yang konsisten — onboarding developer baru cukup
docker compose up - Aplikasi dengan banyak dependencies — database, cache, queue, semua jalan lokal tanpa install satu per satu
- Deployment yang reproducible — image yang sama yang di-test adalah image yang di-deploy
- Scaling horizontal — mudah spawn multiple instance dari container yang sama
- Microservices — tiap service bisa punya runtime dan dependencies sendiri
Bab 2: Konsep Inti: Image, Container, Layer
Di Bab 1 saya sempat singgung image, container, dan layer tanpa menjelaskan detailnya — sekarang giliran tiga istilah itu dibongkar satu per satu, karena semua yang kamu lakukan dengan Docker akhirnya berputar di sekitar ketiganya.
Image: Blueprint Container
Anggap Docker Image sebagai blueprint: template read-only yang berisi semua yang dibutuhkan untuk menjalankan aplikasi — OS base, runtime, library, kode aplikasi, dan konfigurasi.
Image tersusun dari layers yang di-cache secara independen. Ini kunci efisiensi Docker — kalau kamu mengubah kode tapi tidak mengubah dependencies, Docker hanya perlu rebuild layer kode, bukan layer dependencies.
Image Node.js App:
┌─────────────────────────────────────────┐
│ Layer 4: COPY source code │ ← berubah kalau kode berubah
├─────────────────────────────────────────┤
│ Layer 3: RUN npm install │ ← berubah kalau package.json berubah
├─────────────────────────────────────────┤
│ Layer 2: COPY package.json │
├─────────────────────────────────────────┤
│ Layer 1: node:20-alpine (base image) │ ← jarang berubah, di-cache lama
└─────────────────────────────────────────┘
Container: Instansi Image yang Berjalan
Kalau image adalah blueprint, container adalah bangunan yang benar-benar berdiri dari blueprint itu — instansi image yang sedang berjalan. Dari satu image yang sama, kamu bisa menjalankan banyak container sekaligus, masing-masing berjalan independen.
Analogi: Image adalah cetakan kue, container adalah kue yang dihasilkan. Dari satu cetakan bisa buat banyak kue identik.
Docker Registry
Image yang sudah jadi itu harus disimpan di suatu tempat supaya bisa dipakai ulang atau dibagikan ke orang lain — tempat itu namanya registry. Yang paling sering kamu temui adalah Docker Hub (hub.docker.com), registry publik terbesar, tapi tim yang butuh privasi biasanya jalankan private registry sendiri.
# Format: registry/username/image-name:tag
docker pull node:20-alpine # dari Docker Hub (official image)
docker pull postgres:16 # PostgreSQL official
docker pull nginx:alpine # Nginx official
# Image yang kamu buat sendiri
docker build -t my-app:v1.0 .
docker tag my-app:v1.0 registry.example.com/my-app:v1.0
docker push registry.example.com/my-app:v1.0Perintah Docker Dasar
Ini perintah-perintah yang paling sering saya ketik dalam kerja sehari-hari dengan Docker — hafalkan yang ini dulu sebelum melangkah ke yang lebih kompleks:
# Pull image dari registry
docker pull node:20-alpine
# Lihat image yang sudah di-download
docker images
# Jalankan container
docker run -d -p 3000:3000 --name my-app node:20-alpine
# Lihat container yang berjalan
docker ps
# Lihat semua container (termasuk yang stopped)
docker ps -a
# Stop container
docker stop my-app
# Hapus container
docker rm my-app
# Masuk ke dalam container yang berjalan (interactive shell)
docker exec -it my-app sh
# Lihat log container
docker logs my-app
docker logs -f my-app # follow/stream logs
# Hapus image
docker rmi node:20-alpineBab 3: Dockerfile: Membangun Image Sendiri
Image resmi seperti node:20-alpine sudah cukup untuk sekadar coba-coba, tapi aplikasimu sendiri butuh image buatanmu sendiri. Di sinilah Dockerfile masuk.
Struktur Dockerfile
Dockerfile sebenarnya sederhana kalau dilihat dari fungsinya: file instruksi step-by-step untuk membangun image, dan setiap instruksi di dalamnya membuat satu layer baru — persis konsep layer yang kita bahas di Bab 2.
# Instruksi umum Dockerfile
FROM <image>:<tag> # base image
WORKDIR /app # set working directory
COPY <src> <dest> # copy file dari host ke image
RUN <command> # jalankan perintah saat build
ENV KEY=value # set environment variable
EXPOSE <port> # dokumentasikan port yang dipakai
CMD ["node", "server.js"] # perintah default saat container start
ENTRYPOINT ["node"] # seperti CMD tapi tidak bisa di-overrideDockerfile untuk Aplikasi Node.js
Berikut Dockerfile paling dasar yang biasa saya pakai sebagai titik awal untuk REST API Node.js — belum optimal, tapi sudah jalan dan jadi baseline yang gampang dipahami sebelum dioptimasi di bab-bab berikutnya:
# Dockerfile untuk REST API Node.js
FROM node:20-alpine
# Set working directory di dalam container
WORKDIR /app
# Copy dependency manifest DULU (sebelum source code)
# Ini memanfaatkan Docker layer cache:
# Layer npm install hanya di-rebuild jika package.json berubah
COPY package.json package-lock.json ./
# Install dependencies
RUN npm ci --only=production
# Copy source code SETELAH install dependencies
COPY src ./src
COPY .env.example .env
# Expose port yang dipakai aplikasi
EXPOSE 3000
# Jalankan aplikasi
CMD ["node", "src/server.js"]Urutan Layer: Kunci Efisiensi Build
Urutan instruksi di Dockerfile sangat berpengaruh pada kecepatan build. Taruh instruksi yang jarang berubah di atas, yang sering berubah di bawah.
# ✗ BURUK: source code di-copy sebelum npm install
# Setiap perubahan kode akan trigger npm install ulang!
FROM node:20-alpine
WORKDIR /app
COPY . . ← copy semua termasuk source code
RUN npm install ← selalu re-run karena layer sebelumnya berubah
# ✓ BAIK: package.json di-copy terpisah
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./ ← hanya berubah jika dependencies berubah
RUN npm ci ← di-cache selama package.json tidak berubah
COPY . . ← perubahan source code tidak trigger npm install ulang.dockerignore
Sama seperti .gitignore, file .dockerignore mencegah file yang tidak perlu masuk ke dalam image:
# .dockerignore
node_modules
.git
.env
*.log
dist
coverage
.DS_Store
README.md
docker-compose*.yml
Dockerfile*
Tanpa .dockerignore, COPY . . akan menyalin node_modules (bisa ratusan MB) dari host ke image — sangat lambat dan sia-sia karena kita sudah menjalankan npm install di dalam image.
Multi-Stage Build: Image Lebih Kecil
Untuk aplikasi TypeScript atau yang butuh kompilasi, gunakan multi-stage build untuk memisahkan build environment dari runtime environment:
# Stage 1: Builder
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci # install semua deps termasuk devDependencies
COPY tsconfig.json ./
COPY src ./src
# Compile TypeScript ke JavaScript
RUN npm run build
# Stage 2: Production runner (image final yang lebih kecil)
FROM node:20-alpine AS runner
WORKDIR /app
# Hanya copy apa yang dibutuhkan untuk production
COPY package*.json ./
RUN npm ci --only=production # hanya production deps
# Copy hasil build dari stage 1
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/server.js"]Dengan multi-stage build, image final tidak mengandung TypeScript compiler, devDependencies, atau source .ts files — bisa menghemat hingga 70% ukuran image.
Bab 4: Docker Compose: Orkestrasi Multi-Service
Satu Dockerfile cukup kalau aplikasimu berdiri sendiri. Tapi kenyataannya jarang begitu — hampir selalu ada database, cache, kadang message queue yang harus jalan bareng. Menjalankan semuanya satu-satu dengan docker run cepat jadi merepotkan.
Apa itu Docker Compose?
Docker Compose menyelesaikan masalah itu: kamu definisikan semua service — API server, database, cache, apa saja — dalam satu file docker-compose.yml, lalu nyalakan semuanya sekaligus dengan satu perintah, docker compose up.
Anatomi docker-compose.yml
version: '3.8'
services: # daftar container yang akan dijalankan
app:
build: . # build dari Dockerfile di direktori ini
ports:
- "3000:3000"
environment:
- NODE_ENV=development
db:
image: postgres:16 # gunakan image resmi, tidak perlu Dockerfile
volumes:
- postgres_data:/var/lib/postgresql/data
volumes: # named volumes untuk persisten data
postgres_data:
networks: # jaringan internal antar container
default:
name: my-app-networkPerintah Docker Compose
# Start semua service (background)
docker compose up -d
# Start dengan rebuild image
docker compose up -d --build
# Stop semua service
docker compose down
# Stop dan hapus volume (DATA HILANG!)
docker compose down -v
# Lihat status service
docker compose ps
# Lihat log semua service
docker compose logs
# Lihat log service tertentu (follow)
docker compose logs -f app
# Masuk ke container service tertentu
docker compose exec app sh
docker compose exec db psql -U postgres
# Restart service tertentu
docker compose restart app
# Scale service (spawn multiple instance)
docker compose up -d --scale app=3Bab 5: Studi Kasus: Containerize REST API + PostgreSQL
Konsep sudah cukup. Sekarang kita satukan semuanya jadi satu studi kasus nyata — containerize REST API lengkap dengan PostgreSQL, dari struktur project sampai debugging saat sesuatu tidak berjalan seperti yang diharapkan.
Struktur Project
Begini susunan file yang akan kita pakai sepanjang bab ini:
my-api/
├── src/
│ ├── app.js
│ ├── server.js
│ └── ...
├── db/
│ └── init/
│ ├── 01-schema.sql
│ └── 02-seed.sql
├── .env
├── .env.example
├── .dockerignore
├── Dockerfile
├── Dockerfile.dev
├── docker-compose.yml
└── docker-compose.prod.yml
Dockerfile (Production)
Ini versi Dockerfile yang sudah lebih matang dari contoh di Bab 3 — ada dumb-init untuk menangani sinyal OS dengan benar, dan user non-root untuk keamanan:
# Dockerfile
FROM node:20-alpine AS base
WORKDIR /app
# Install dumb-init: menangani sinyal OS dengan benar (untuk graceful shutdown)
RUN apk add --no-cache dumb-init
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
COPY src ./src
# Non-root user untuk keamanan
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
EXPOSE 3000
# Gunakan dumb-init sebagai PID 1
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "src/server.js"]Dockerfile.dev (Development dengan Hot-Reload)
Image development berbeda dari production: butuh devDependencies (nodemon) dan tidak perlu hardened security selengkap production.
# Dockerfile.dev
FROM node:20-alpine
WORKDIR /app
# Install dumb-init untuk signal handling yang benar
RUN apk add --no-cache dumb-init
# Install SEMUA dependencies termasuk devDependencies (nodemon, dll)
COPY package*.json ./
RUN npm install
# Source code TIDAK di-copy — akan di-mount via bind mount
# Ini yang memungkinkan hot-reload: perubahan file langsung terlihat
EXPOSE 3000
ENTRYPOINT ["dumb-init", "--"]
CMD ["npm", "run", "dev"]Setup nodemon di package.json:
{
"scripts": {
"dev": "nodemon --watch src --ext js,json src/server.js",
"start": "node src/server.js"
},
"devDependencies": {
"nodemon": "^3.0.0"
}
}docker-compose.yml (Development)
Satukan app, database, dan Redis dalam satu file compose berikut — perhatikan depends_on dengan condition: service_healthy, itu yang memastikan app baru start setelah database benar-benar siap menerima koneksi, bukan cuma sudah dinyalakan:
# docker-compose.yml — untuk development
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile.dev # pakai Dockerfile khusus development
container_name: myapi_app
restart: unless-stopped
ports:
- "3000:3000"
- "9229:9229" # port untuk Node.js debugger (--inspect)
environment:
NODE_ENV: development
PORT: 3000
DB_HOST: db
DB_PORT: 5432
DB_NAME: myapi_dev
DB_USER: postgres
DB_PASSWORD: postgres
JWT_SECRET: dev-secret-change-in-production
volumes:
- ./src:/app/src # bind mount: hot-reload
- ./package.json:/app/package.json # supaya bisa npm install dari host
depends_on:
db:
condition: service_healthy
networks:
- backend
db:
image: postgres:16-alpine
container_name: myapi_db
restart: unless-stopped
environment:
POSTGRES_DB: myapi_dev
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
volumes:
- postgres_data:/var/lib/postgresql/data
- ./db/init:/docker-entrypoint-initdb.d # SQL init scripts
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
networks:
- backend
redis:
image: redis:7-alpine
container_name: myapi_redis
restart: unless-stopped
ports:
- "6379:6379"
volumes:
- redis_data:/data
networks:
- backend
volumes:
postgres_data:
redis_data:
networks:
backend:
driver: bridgeKoneksi Database dari Aplikasi
Di dalam Docker Compose network, setiap service bisa diakses dari container lain menggunakan nama service-nya sebagai hostname:
// src/config/database.js
import pg from 'pg';
const pool = new pg.Pool({
host: process.env.DB_HOST, // "db" (nama service di docker-compose)
port: parseInt(process.env.DB_PORT),
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
pool.connect((err, client, release) => {
if (err) {
console.error('Database connection error:', err.message);
process.exit(1);
}
console.log('✓ Database connected');
release();
});
export default pool;Setup Database Init Scripts
File SQL di ./db/init/ akan otomatis dieksekusi oleh PostgreSQL container saat pertama kali berjalan:
-- db/init/01-schema.sql
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- db/init/02-seed.sql
INSERT INTO users (email, name) VALUES
('admin@example.com', 'Admin User'),
('demo@example.com', 'Demo User')
ON CONFLICT (email) DO NOTHING;Catatan: Script init hanya jalan sekali saat volume PostgreSQL kosong pertama kali. Untuk perubahan schema setelah itu, gunakan database migrations (dibahas di Bab 6).
Debugging Node.js di Dalam Container
Untuk attach debugger VS Code ke container yang sedang berjalan:
1. Jalankan Node.js dengan flag --inspect:
// package.json
"scripts": {
"dev": "nodemon --watch src --ext js,json --inspect=0.0.0.0:9229 src/server.js"
}2. Pastikan port 9229 di-expose di docker-compose.yml (sudah ada di contoh di atas).
3. Tambahkan launch config VS Code:
// .vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
"name": "Attach to Docker",
"type": "node",
"request": "attach",
"port": 9229,
"address": "localhost",
"localRoot": "${workspaceFolder}/src",
"remoteRoot": "/app/src",
"restart": true
}
]
}Error Umum dan Cara Mengatasinya
1. ECONNREFUSED — App tidak bisa connect ke database
depends_on: condition: service_healthy memastikan container DB sudah start, tapi belum tentu siap menerima koneksi. Solusi paling robust: retry logic di kode aplikasi.
// src/config/database.js
async function connectWithRetry(maxRetries = 10, delayMs = 2000) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const client = await pool.connect();
client.release();
console.log('✓ Database connected');
return;
} catch (err) {
console.log(`Database connection attempt ${attempt}/${maxRetries} failed: ${err.message}`);
if (attempt === maxRetries) throw err;
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
}
export { pool, connectWithRetry };// src/server.js
import { connectWithRetry } from './config/database.js';
await connectWithRetry();
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));2. Hot-reload tidak berjalan
Bind mount bekerja berdasarkan filesystem events. Di Mac/Windows, Docker Desktop kadang lambat mendeteksi perubahan file.
# docker-compose.yml — tambahkan ini di service app
volumes:
- ./src:/app/src
environment:
CHOKIDAR_USEPOLLING: "true" # paksa polling jika inotify tidak bekerja
CHOKIDAR_INTERVAL: "1000"3. Permission denied pada volume
Terjadi ketika user di dalam container berbeda dengan owner file di host:
# Lihat UID user di dalam container
docker compose exec app id
# uid=1000(appuser) gid=1000(appgroup)
# Fix: chown di Dockerfile atau set user ID yang sama
RUN chown -R appuser:appgroup /app4. node_modules di volume tertimpa bind mount
Kalau kamu mount ./src:/app/src, tidak ada masalah. Tapi kalau mount .:/app (seluruh direktori), node_modules di host akan override yang di container — yang berbeda arsitekturnya.
# ✗ MASALAH: mount semua direktori
volumes:
- .:/app
# ✓ SOLUSI: mount hanya src, biarkan node_modules di container
volumes:
- ./src:/app/src
- /app/node_modules # anonymous volume — "lindungi" node_modules containerBab 6: Database Migrations di Docker
Masalah dengan Init Scripts
docker-entrypoint-initdb.d hanya berjalan sekali — saat volume PostgreSQL pertama kali dibuat. Begitu volume sudah ada, script itu diabaikan sepenuhnya. Ini artinya kamu tidak bisa menggunakannya untuk mengubah schema yang sudah ada.
Solusinya: database migrations — sistem yang melacak perubahan schema secara bertahap dan hanya menjalankan yang belum pernah dieksekusi.
Pilihan Tools
Ada beberapa tool migration yang umum dipakai di ekosistem Node.js dan sekitarnya — pilih berdasarkan stack dan seberapa nyaman timmu dengan SQL murni:
| Tool | Bahasa Config | Cocok untuk |
|---|---|---|
node-pg-migrate | JavaScript/SQL | Node.js + PostgreSQL, sederhana |
db-migrate | JavaScript | Multi-database, lebih kompleks |
Flyway | SQL murni | Tim yang prefer pure SQL |
Liquibase | XML/YAML/SQL | Enterprise, audit trail ketat |
Kita akan pakai node-pg-migrate — paling straightforward untuk stack Node.js + PostgreSQL.
Setup node-pg-migrate
Instalasinya singkat — dua package dan konfigurasi koneksi database per environment:
npm install node-pg-migrate pg// package.json
{
"scripts": {
"migrate:up": "node-pg-migrate up",
"migrate:down": "node-pg-migrate down",
"migrate:create": "node-pg-migrate create"
}
}// database.json (config untuk node-pg-migrate)
{
"development": {
"connectionString": "postgresql://postgres:postgres@localhost:5432/myapi_dev"
},
"production": {
"connectionString": { "ENV": "DATABASE_URL" }
}
}Membuat File Migration
Setiap perubahan schema dibungkus jadi satu file migration bernomor timestamp, supaya urutan eksekusinya jelas dan tidak ambigu:
# Buat migration baru
npm run migrate:create -- add-users-table
# Membuat: migrations/1700000000000_add-users-table.js// migrations/1700000000000_add-users-table.js
export const up = (pgm) => {
pgm.createTable('users', {
id: {
type: 'bigserial',
primaryKey: true,
},
email: {
type: 'varchar(255)',
notNull: true,
unique: true,
},
name: {
type: 'varchar(255)',
notNull: true,
},
password_hash: {
type: 'varchar(255)',
},
role: {
type: 'varchar(50)',
notNull: true,
default: 'user',
},
created_at: {
type: 'timestamptz',
notNull: true,
default: pgm.func('NOW()'),
},
});
pgm.createIndex('users', 'email');
};
export const down = (pgm) => {
pgm.dropTable('users');
};// migrations/1700000001000_add-products-table.js
export const up = (pgm) => {
pgm.createTable('products', {
id: { type: 'bigserial', primaryKey: true },
name: { type: 'varchar(255)', notNull: true },
price: { type: 'numeric(12,2)', notNull: true },
created_by: {
type: 'bigint',
references: '"users"',
onDelete: 'SET NULL',
},
created_at: {
type: 'timestamptz',
notNull: true,
default: pgm.func('NOW()'),
},
});
};
export const down = (pgm) => {
pgm.dropTable('products');
};Pattern: Migration Container
Di lingkungan Docker, cara paling aman menjalankan migrasi adalah dengan container terpisah yang selesai sebelum app container start. Ini membuat alur deployment eksplisit dan jelas.
# docker-compose.yml (tambahkan service migrate)
services:
migrate:
build:
context: .
dockerfile: Dockerfile.dev
container_name: myapi_migrate
command: npm run migrate:up # jalankan migrasi, lalu exit
environment:
DATABASE_URL: postgresql://postgres:postgres@db:5432/myapi_dev
depends_on:
db:
condition: service_healthy
networks:
- backend
# Tidak ada restart policy — container ini memang harus exit setelah selesai
app:
build:
context: .
dockerfile: Dockerfile.dev
depends_on:
db:
condition: service_healthy
migrate:
condition: service_completed_successfully # tunggu migrasi selesai!
# ... sisanya sama seperti sebelumnya# Alur development:
# 1. Semua service start — migrate container jalan duluan
docker compose up -d
# 2. Cek apakah migrasi berhasil
docker compose logs migrate
# 3. Buat migration baru
docker compose exec app npm run migrate:create -- add-orders-table
# 4. Jalankan migration baru tanpa restart semua service
docker compose run --rm migrate npm run migrate:upMigrasi di Production
Di production, jangan jalankan migrasi otomatis saat container app start. Ini berbahaya karena:
- Jika app di-scale jadi 3 instance, migrasi bisa jalan 3 kali bersamaan
- Jika migrasi gagal di tengah jalan, app sudah terlanjur start dengan schema yang setengah jadi
Pattern yang benar: migrasi adalah langkah deployment yang eksplisit dan manual (atau dijalankan satu kali oleh CI/CD sebelum rolling update):
# scripts/deploy.sh
#!/bin/bash
set -e
echo "==> Running database migrations..."
docker compose -f docker-compose.prod.yml run --rm migrate npm run migrate:up
echo "✓ Migrations complete"
echo "==> Deploying new app version..."
docker compose -f docker-compose.prod.yml up -d --no-deps app
echo "✓ Deployment complete"Rollback Migration
Kalau migrasi ternyata bermasalah, node-pg-migrate bisa mundur satu langkah atau lebih:
# Rollback satu step terakhir
npm run migrate:down
# Rollback ke titik tertentu (jalankan berkali-kali)
npm run migrate:down
npm run migrate:downPenting: Tidak semua migrasi bisa di-rollback dengan aman di production — misalnya migrasi yang menghapus kolom. Selalu review
down()function sebelum deploy, dan pastikan data di kolom yang dihapus sudah di-backup atau sudah tidak dibutuhkan.
Bab 7: Volume, Network, dan Data Persistence
Container itu sifatnya ephemeral — begitu dihapus, semua yang ada di dalamnya ikut hilang. Untuk data yang harus bertahan (database, upload user), kamu perlu tahu ke mana data itu sebaiknya disimpan.
Tiga Jenis Storage di Docker
Docker menawarkan tiga cara menyimpan data di luar lifecycle container, dan masing-masing punya kegunaan yang berbeda:
1. Named Volume (direkomendasikan untuk data penting)
docker volume create postgres_data
→ disimpan di /var/lib/docker/volumes/
→ dikelola Docker, tidak berubah kalau container dihapus
2. Bind Mount (untuk development hot-reload)
./src:/app/src
→ direktori di host langsung di-mount ke container
→ perubahan file langsung terlihat di container
3. Anonymous Volume (hindari untuk data penting)
VOLUME /app/node_modules dalam Dockerfile
→ otomatis dibuat, susah di-manage
Volume Best Practices
Untuk data production, saya selalu jatuhkan pilihan ke named volume — dikelola Docker, tidak hilang saat container dihapus, dan bisa dikonfigurasi lebih lanjut kalau butuh backup otomatis:
volumes:
# Named volume untuk database (data production)
postgres_data:
driver: local
# Named volume dengan driver khusus untuk backup otomatis
postgres_backup:
driver: local
driver_opts:
type: nfs
o: addr=192.168.1.100,rw
device: ":/mnt/backups"# Perintah manajemen volume
docker volume ls
docker volume inspect postgres_data
docker volume rm postgres_data # hapus volume (DATA HILANG!)
docker volume prune # hapus semua volume yang tidak dipakai
# Backup volume database ke file tar
docker run --rm \
-v postgres_data:/data \
-v $(pwd):/backup \
alpine tar czf /backup/postgres-backup-$(date +%Y%m%d).tar.gz -C /data .
# Restore dari backup
docker run --rm \
-v postgres_data:/data \
-v $(pwd):/backup \
alpine tar xzf /backup/postgres-backup-20240115.tar.gz -C /dataDocker Network
Secara default, semua service dalam satu docker-compose.yml berada dalam network yang sama dan bisa saling berkomunikasi menggunakan nama service sebagai hostname.
networks:
# Network internal untuk komunikasi antar service
backend:
driver: bridge
internal: false # set true untuk isolasi total dari internet
# Contoh: dua network untuk isolasi lebih ketat
services:
app:
networks:
- frontend
- backend
db:
networks:
- backend # hanya bisa diakses dari service di network backend
nginx:
networks:
- frontend
- backendBab 8: Production-Ready: Optimasi dan Security
Image yang jalan di laptop belum tentu siap dijalankan di production. Bab ini mengumpulkan kebiasaan-kebiasaan yang membedakan image 'sekadar jalan' dari image yang aman dan efisien.
Optimasi Image Size
Image yang gemuk bukan cuma boros bandwidth saat di-pull — build dan deploy juga ikut lebih lambat. Beberapa kebiasaan kecil berikut biasanya sudah cukup memangkas ukurannya signifikan:
# Gunakan Alpine-based image (jauh lebih kecil)
FROM node:20-alpine # ~180MB vs node:20 (~1GB)
# Gabungkan RUN commands dengan &&
# Setiap RUN = layer baru. Semakin banyak layer, semakin besar image.
# ✗ BURUK
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get clean
# ✓ BAIK
RUN apt-get update && \
apt-get install -y curl && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Bersihkan cache npm setelah install
RUN npm ci --only=production && npm cache clean --forceSecurity: Jangan Jalankan sebagai Root
Kalau container berjalan sebagai root dan aplikasi di dalamnya punya celah keamanan, penyerang yang berhasil masuk container itu langsung punya privilege root di dalamnya. Tiga baris berikut cukup untuk menutup celah itu:
# Buat user non-root
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
# Set ownership
RUN chown -R appuser:appgroup /app
# Switch ke user non-root
USER appuserSecurity: Jangan Hardcode Secrets
Credential yang ditulis langsung di docker-compose.yml ikut ter-commit ke git kalau kamu tidak hati-hati — dan begitu masuk history git, dianggap bocor selamanya walau baris itu dihapus di commit berikutnya:
# ✗ BURUK: hardcode di docker-compose.yml
environment:
DB_PASSWORD: supersecret123
# ✓ BAIK: gunakan .env file
services:
app:
env_file:
- .env
environment:
NODE_ENV: production
# ✓ LEBIH BAIK (production): Docker Secrets atau external secret manager
secrets:
db_password:
external: trueFile .env untuk development (jangan commit ke git!):
# .env
DB_HOST=db
DB_PORT=5432
DB_NAME=myapi_prod
DB_USER=apiuser
DB_PASSWORD=your-secure-password-here
JWT_SECRET=your-256-bit-secret-hereResource Limits
Tanpa batas resource, satu container yang bermasalah bisa menghabiskan seluruh RAM atau CPU server dan membuat service lain ikut mati.
# docker-compose.prod.yml
services:
app:
image: registry.example.com/my-app:latest
deploy:
resources:
limits:
cpus: '1.0' # maksimal 1 CPU core
memory: 512M # maksimal 512MB RAM
reservations:
cpus: '0.25' # minimal dijamin 0.25 CPU
memory: 128M # minimal dijamin 128MB RAM
restart: always
db:
image: postgres:16-alpine
deploy:
resources:
limits:
cpus: '2.0'
memory: 1G
reservations:
memory: 256M
restart: always# Monitor resource usage real-time
docker stats
# Output:
# CONTAINER CPU % MEM USAGE / LIMIT MEM %
# myapi_app 0.5% 87MiB / 512MiB 17%
# myapi_db 0.2% 234MiB / 1GiB 22%Catatan:
deploy.resourcesberlaku untuk Docker Compose v3 dalam mode Swarm. Untuk standalone Compose (non-Swarm), gunakanmem_limitdancpuslangsung di level service.
# Untuk standalone Docker Compose (non-Swarm):
services:
app:
mem_limit: 512m
cpus: 1.0Log Management & Rotasi
Secara default, Docker menyimpan log setiap container di file JSON di disk tanpa batas ukuran. Di production, ini bisa membuat disk penuh dalam hitungan hari untuk aplikasi dengan traffic tinggi.
# docker-compose.prod.yml — konfigurasi logging
services:
app:
logging:
driver: "json-file"
options:
max-size: "10m" # maksimal 10MB per file log
max-file: "5" # simpan maksimal 5 file (rotasi), total ~50MB# Atau set default logging untuk semua container di /etc/docker/daemon.json
# (berlaku untuk seluruh Docker daemon di server)
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "5"
}
}
# Restart Docker daemon setelah perubahan
sudo systemctl restart dockerUntuk production yang butuh log aggregation (semua log dikirim ke satu tempat seperti Datadog, Loki, atau ELK):
services:
app:
logging:
driver: "syslog"
options:
syslog-address: "udp://logs.example.com:514"
tag: "myapi-app"Health Check
Docker sebenarnya bisa mengecek sendiri apakah container-mu masih sehat, bukan cuma masih "running". Tambahkan instruksi berikut supaya Docker tahu bedanya:
# Dockerfile
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1// src/app.js — tambahkan health check endpoint
app.get('/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
});
});Graceful Shutdown
Saat kamu docker stop sebuah container, Docker mengirim sinyal SIGTERM dan memberi waktu sebelum benar-benar mematikannya paksa. Kalau aplikasimu tidak menangani sinyal itu, request yang sedang diproses bisa terputus di tengah jalan:
// src/server.js — handle SIGTERM dengan benar
const server = app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
// Docker mengirim SIGTERM saat stop container
process.on('SIGTERM', () => {
console.log('SIGTERM received, shutting down gracefully...');
server.close(() => {
console.log('HTTP server closed');
pool.end(() => {
console.log('Database pool closed');
process.exit(0);
});
});
// Force close jika lebih dari 30 detik
setTimeout(() => {
console.error('Forced shutdown after timeout');
process.exit(1);
}, 30000);
});Bab 9: Deploy ke VPS dengan Docker
Semua yang kita bangun sejauh ini masih di laptop. Bab terakhir ini memindahkannya ke VPS sungguhan — install Docker di server, susun compose file per environment, dan taruh Nginx di depan sebagai reverse proxy dengan SSL.
Setup VPS (Ubuntu 22.04)
Langkah pertama di VPS baru: pasang Docker-nya sendiri.
# Di VPS — install Docker
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
# Tambah user ke group docker (tidak perlu sudo setiap kali)
sudo usermod -aG docker $USER
newgrp docker
# Verifikasi
docker --version
docker compose versionMulti-Environment dengan Compose Override
Docker Compose mendukung merge beberapa file — ini adalah cara yang benar untuk mengelola perbedaan antara environment (dev/staging/prod) tanpa duplikasi konfigurasi.
docker-compose.yml ← base config (shared semua env)
docker-compose.override.yml ← otomatis dipakai saat `docker compose up` (development)
docker-compose.staging.yml ← khusus staging
docker-compose.prod.yml ← khusus production
Base config (docker-compose.yml) — hanya definisi service yang shared:
# docker-compose.yml — base
version: '3.8'
services:
app:
build: .
environment:
PORT: 3000
networks:
- backend
db:
image: postgres:16-alpine
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- backend
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres}"]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres_data:
networks:
backend:Development override (docker-compose.override.yml) — otomatis di-merge:
# docker-compose.override.yml — development (auto-loaded)
version: '3.8'
services:
app:
build:
dockerfile: Dockerfile.dev
ports:
- "3000:3000"
- "9229:9229"
volumes:
- ./src:/app/src
environment:
NODE_ENV: development
DB_HOST: db
DB_USER: postgres
DB_PASSWORD: postgres
DB_NAME: myapi_dev
env_file:
- .env.dev
db:
ports:
- "5432:5432" # expose ke host untuk tools development
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: myapi_devProduction (docker-compose.prod.yml) — eksplisit di-specify:
# docker-compose.prod.yml
version: '3.8'
services:
app:
image: registry.example.com/my-app:${APP_VERSION:-latest}
restart: always
expose:
- "3000" # tidak expose ke host — hanya lewat nginx
env_file:
- .env.prod
mem_limit: 512m
cpus: 1.0
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
depends_on:
db:
condition: service_healthy
db:
restart: always
env_file:
- .env.prod
mem_limit: 1g
# TIDAK expose port ke host di production
nginx:
image: nginx:alpine
restart: always
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ./nginx/ssl:/etc/nginx/ssl:ro
depends_on:
- app
networks:
- backend
- frontend
networks:
frontend:# Development: otomatis pakai docker-compose.yml + docker-compose.override.yml
docker compose up -d
# Production: eksplisit specify file (override tidak di-load)
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
# Staging:
docker compose -f docker-compose.yml -f docker-compose.staging.yml up -dKonfigurasi Nginx
Nginx di sini melakukan dua hal sekaligus: terminasi SSL dan meneruskan traffic ke container app lewat nama service-nya sebagai upstream — persis konsep networking Docker Compose yang sudah dibahas di Bab 7:
# nginx/nginx.conf
upstream app {
server app:3000; # nama service Docker sebagai upstream
}
server {
listen 80;
server_name backendlabs.id www.backendlabs.id;
# Redirect HTTP ke HTTPS
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name backendlabs.id;
ssl_certificate /etc/nginx/ssl/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# Security headers
add_header X-Content-Type-Options nosniff;
add_header X-Frame-Options DENY;
add_header X-XSS-Protection "1; mode=block";
location / {
proxy_pass http://app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 60s;
proxy_connect_timeout 60s;
}
location /health {
proxy_pass http://app;
access_log off;
}
}Deploy Script
Semua langkah manual di atas — migrasi, pull image, restart app — saya bungkus jadi satu script supaya deploy konsisten setiap kali, tidak tergantung ingat atau lupa satu langkah:
#!/bin/bash
# scripts/deploy.sh
set -e
APP_VERSION=${1:-latest}
echo "==> Running database migrations..."
docker compose -f docker-compose.yml -f docker-compose.prod.yml \
run --rm migrate npm run migrate:up
echo "✓ Migrations complete"
echo "==> Pulling latest image (v${APP_VERSION})..."
APP_VERSION=$APP_VERSION \
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull app
echo "==> Deploying app..."
APP_VERSION=$APP_VERSION \
docker compose -f docker-compose.yml -f docker-compose.prod.yml \
up -d --no-deps app
echo "==> Cleaning up old images..."
docker image prune -f
# Health check
echo "==> Verifying deployment..."
sleep 5
curl -sf http://localhost/health || (echo "❌ Health check failed!" && exit 1)
echo "✅ Deployment complete! Version: ${APP_VERSION}"# Cara pakai:
./scripts/deploy.sh v1.2.0Tips Debugging di Production
Kalau ada yang terasa aneh di production, ini perintah-perintah pertama yang saya jalankan untuk mencari tahu apa yang sebenarnya terjadi di dalam container:
# Masuk ke container yang berjalan
docker compose exec app sh
# Lihat resource usage real-time
docker stats
# Inspect container detail (network, volume, env)
docker inspect myapi_app
# Lihat event Docker real-time
docker events
# Lihat berapa disk yang dipakai Docker
docker system df
# Bersihkan semua yang tidak dipakai (hati-hati di production!)
docker system prunePenutup
Production Checklist
Langkah selanjutnya:
- Setup CI/CD pipeline dengan GitHub Actions yang otomatis build dan push Docker image
- Eksplorasi Kubernetes jika sudah butuh orchestration yang lebih kompleks (tapi Docker Compose sudah cukup untuk kebanyakan aplikasi)
- Baca artikel #07 (Deploy ke VPS) untuk pembahasan lebih dalam tentang production deployment