Lewati ke konten utama
Semua artikel
MenengahProduction & Ops

Docker untuk Backend Developer

Containerize aplikasi Node.js, setup development environment dengan Docker Compose, dan deploy ke production.

24 menit baca

"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

  1. Kenapa Docker?
  2. Konsep Inti: Image, Container, Layer
  3. Dockerfile: Membangun Image Sendiri
  4. Docker Compose: Orkestrasi Multi-Service
  5. Studi Kasus: Containerize REST API + PostgreSQL
  6. Database Migrations di Docker
  7. Volume, Network, dan Data Persistence
  8. Production-Ready: Optimasi dan Security
  9. 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 install lalu npm 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:

  1. Development environment yang konsisten — onboarding developer baru cukup docker compose up
  2. Aplikasi dengan banyak dependencies — database, cache, queue, semua jalan lokal tanpa install satu per satu
  3. Deployment yang reproducible — image yang sama yang di-test adalah image yang di-deploy
  4. Scaling horizontal — mudah spawn multiple instance dari container yang sama
  5. 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.0

Perintah 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-alpine

Bab 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-override

Dockerfile 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-network

Perintah 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=3

Bab 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: bridge

Koneksi 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 /app

4. 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 container

Bab 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

ToolBahasa ConfigCocok untuk
node-pg-migrateJavaScript/SQLNode.js + PostgreSQL, sederhana
db-migrateJavaScriptMulti-database, lebih kompleks
FlywaySQL murniTim yang prefer pure SQL
LiquibaseXML/YAML/SQLEnterprise, 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:up

Migrasi 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:down

Penting: 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 /data

Docker 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
      - backend

Bab 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 --force

Security: 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 appuser

Security: 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: true

File .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-here

Resource 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.resources berlaku untuk Docker Compose v3 dalam mode Swarm. Untuk standalone Compose (non-Swarm), gunakan mem_limit dan cpus langsung di level service.

# Untuk standalone Docker Compose (non-Swarm):
services:
  app:
    mem_limit: 512m
    cpus: 1.0

Log 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 docker

Untuk 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 version

Multi-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_dev

Production (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 -d

Konfigurasi 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.0

Tips 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 prune

Penutup

Production Checklist

  • Gunakan node:20-alpine bukan node:20 — selisih ~800MB
  • .dockerignore ada dan mencantumkan node_modules, .git, .env
  • Container tidak berjalan sebagai root
  • Secrets dari .env file, tidak hardcoded di Compose
  • depends_on: condition: service_healthy untuk 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.yml terpisah 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

Lanjutkan ke

CI/CD untuk Backend Developer: GitHub ActionsSegera
Deploy Backend ke VPS: Ubuntu + Nginx + SSLSegera
Kubernetes untuk Backend DeveloperSegera