"Before Docker: 'It works on my machine.' After Docker: 'It works on everyone's machine.'"
Tentang E-Book 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.
E-book 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 e-book #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 e-book #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
Bayangkan kamu baru join tim dan dapat tugas: jalankan aplikasi backend yang sudah jalan di server production. README-nya bilang:
- Install Node.js 18
- Install PostgreSQL 14
- Set beberapa environment variable
- Jalankan
npm installlalunpm start
Kamu ikuti semua langkah, tapi aplikasinya error. Ternyata komputer kamu punya Node.js versi 20, PostgreSQL 15, dan ada library sistem yang versinya berbeda. Developer lain bilang: "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?
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
Image: Blueprint Container
Docker Image adalah 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
Container adalah instansi image yang sedang berjalan. Kamu bisa menjalankan banyak container dari image yang sama secara bersamaan.
Analogi: Image adalah cetakan kue, container adalah kue yang dihasilkan. Dari satu cetakan bisa buat banyak kue identik.
Docker Registry
Image disimpan dan didistribusikan melalui registry. Registry publik terbesar adalah Docker Hub (hub.docker.com). Kamu juga bisa punya private registry.
# 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
# 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
Struktur Dockerfile
Dockerfile adalah file instruksi untuk membangun image. Setiap instruksi membuat layer baru.
# 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
# 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
Apa itu Docker Compose?
Aplikasi backend modern biasanya butuh lebih dari satu service: API server, database, cache, message queue, dll. Docker Compose memungkinkan kamu mendefinisikan dan menjalankan semua service ini dengan satu file konfigurasi (docker-compose.yml) dan 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
Struktur Project
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)
# 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)
# 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
| 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
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
# 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
# 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
Tiga Jenis Storage di Docker
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
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
Optimasi Image Size
# 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
# 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
# ✗ 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
# 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
// 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
Setup VPS (Ubuntu 22.04)
# 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/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
#!/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
# 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
- Gunakan
node:20-alpinebukannode:20— selisih ~800MB -
.dockerignoreada dan mencantumkannode_modules,.git,.env - Container tidak berjalan sebagai root
- Secrets dari
.envfile, tidak hardcoded di Compose -
depends_on: condition: service_healthyuntuk urutan startup yang benar - Retry logic di kode aplikasi untuk koneksi database
- Migration dijalankan sebagai langkah terpisah sebelum deploy
- Resource limits (
mem_limit,cpus) di semua service production - Log rotation dikonfigurasi (
max-size,max-file) - Health check endpoint tersedia di
/health - Graceful shutdown dengan SIGTERM handler
- Port database tidak di-expose ke host di production
-
docker compose.prod.ymlterpisah dari development config
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 e-book #07 (Deploy ke VPS) untuk pembahasan lebih dalam tentang production deployment