Banyak deployment Docker gagal bukan saat image dibuat, tetapi ketika container harus hidup di server, menerima traffic HTTPS, menyimpan data, dan di-update tanpa downtime yang tidak terencana. Docker menyelesaikan packaging aplikasi. Ia tidak otomatis menyelesaikan network, secret, backup, observability, atau rollback.

Artikel ini memakai satu pola yang realistis: aplikasi web berjalan di Docker Compose, database tidak dibuka ke internet, Caddy menjadi reverse proxy HTTPS, dan deployment dilakukan dari GitHub Actions ke VPS. Polanya bisa disesuaikan untuk GitLab CI, GitLab Runner, atau platform lain.

Arsitektur target

Alur request yang dituju:

Client HTTPS
    |
    v
Caddy :80/:443
    |
    v
app container :3000
    |
    +--> postgres container :5432 (private Docker network)
    +--> redis container :6379 (private Docker network, optional)

Hanya Caddy yang membuka port publik 80 dan 443. Port aplikasi, PostgreSQL, dan Redis cukup berada di network Docker. Ini mengurangi attack surface dan mencegah client luar mengakses database secara langsung.

Untuk production kecil, Docker Compose cukup. Kubernetes baru memberi manfaat ketika kebutuhan scheduling, autoscaling, multi-node, dan service discovery memang sudah ada. Memakai Kubernetes terlalu awal menambah control plane, RBAC, monitoring, dan biaya operasi.

Prasyarat server

Gunakan VPS Linux dengan resource sesuai aplikasi. Untuk aplikasi Node.js kecil, 2 vCPU dan 4 GB RAM bisa menjadi titik awal, tetapi database, build, dan traffic menentukan kebutuhan sebenarnya.

sudo apt update && sudo apt upgrade -y
sudo apt install -y ca-certificates curl git ufw
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw --force enable

SSH sebaiknya memakai key, bukan password. Jangan menutup port 22 sebelum koneksi key baru teruji. Firewall bukan pengganti authentication; ia hanya membatasi jalur masuk.

Install Docker Engine

Pada Ubuntu, gunakan repository resmi Docker agar versi dan package berasal dari sumber yang bisa diaudit:

sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg |   sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg

echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo systemctl enable --now docker
sudo docker run --rm hello-world

Menambahkan user ke group docker memang membuat command tidak perlu sudo, tetapi group tersebut secara praktis memberikan hak setara root. Pada server multi-user, pertimbangkan tetap memakai sudo docker atau batasi siapa yang mendapat akses.

Struktur project

Pisahkan source code, konfigurasi deployment, dan data runtime:

my-app/
├── Dockerfile
├── compose.yml
├── .dockerignore
├── .env.example
├── package.json
├── package-lock.json
├── src/
└── infra/
    └── Caddyfile

.env.example boleh masuk Git karena hanya berisi nama variable. .env production harus berada di server atau secret manager dan masuk .gitignore.

.env
.env.*
!.env.example
node_modules
.next

Membuat Dockerfile yang layak production

Gunakan multi-stage build agar dependency development dan source yang tidak diperlukan tidak ikut masuk runtime image:

FROM node:22-bookworm-slim AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci

FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:22-bookworm-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/.next ./.next
COPY --from=build /app/public ./public
USER node
EXPOSE 3000
CMD ["npm", "start"]

Detail pentingnya bukan hanya ukuran image. Build harus reproducible melalui lockfile, proses runtime tidak berjalan sebagai root, dan runtime image tidak membawa tool build yang tidak diperlukan. Jika framework memakai standalone output, salin hanya bundle standalone agar ukuran lebih kecil.

.dockerignore mencegah credential, cache, dan file lokal masuk build context:

.git
.env*
node_modules
.next
coverage
*.log
Dockerfile*
compose*.yml

Docker Compose untuk aplikasi

Contoh berikut memakai app, PostgreSQL, dan Redis. Database dan Redis tidak memiliki ports, sehingga hanya dapat diakses melalui network internal Compose.

services:
  app:
    build:
      context: .
      target: runtime
    restart: unless-stopped
    env_file: .env
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    networks: [internal]
    expose:
      - "3000"

  postgres:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: app
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    networks: [internal]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app"]
      interval: 5s
      timeout: 3s
      retries: 20

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    command: redis-server --appendonly yes
    volumes:
      - redis_data:/data
    networks: [internal]
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 20

networks:
  internal:

volumes:
  postgres_data:
  redis_data:

depends_on membantu urutan start, tetapi bukan readiness aplikasi. Aplikasi tetap harus melakukan retry koneksi database dengan batas waktu dan backoff. Healthcheck juga tidak menggantikan migration dan smoke test.

Environment dan secret

Buat .env.example seperti ini:

NODE_ENV=production
PORT=3000
DATABASE_URL=postgresql://app:change-me@postgres:5432/app
REDIS_URL=redis://redis:6379
APP_URL=https://app.example.com

Di server, buat .env dengan permission terbatas:

sudo install -m 600 /dev/null /opt/my-app/.env
sudoedit /opt/my-app/.env

Jangan memasukkan .env ke image dengan COPY . . tanpa .dockerignore. Untuk cloud credential, OIDC atau secret manager lebih baik daripada static key panjang umur. Jika secret pernah masuk Git, rotate secret tersebut; menghapus commit tidak menghapus salinan dari clone atau log.

Menjalankan lokal

Sebelum menyentuh server, uji jalur utama di lokal:

docker compose config
 docker compose build --no-cache
 docker compose up -d
 docker compose ps
 curl -f http://localhost:3000/health
 docker compose logs --tail=100 app

docker compose config mendeteksi kesalahan interpolasi dan struktur YAML. Endpoint /health harus ringan, tidak bergantung pada proses mahal, dan membedakan proses hidup dari dependency yang siap dipakai. Tambahkan endpoint readiness terpisah jika diperlukan.

Untuk menghentikan stack tanpa menghapus volume:

docker compose down

Jangan memakai down -v pada production kecuali memang berniat menghapus volume dan sudah memiliki restore yang teruji.

Reverse proxy dengan Caddy

Caddy menangani TLS dan meneruskan request ke app melalui network Docker atau port lokal. Contoh saat Caddy juga dijalankan dalam Compose:

app.example.com {
    encode gzip zstd
    reverse_proxy app:3000

    header {
        X-Content-Type-Options nosniff
        X-Frame-Options DENY
        Referrer-Policy strict-origin-when-cross-origin
    }
}

Jika Caddy berjalan di host, publish app hanya ke loopback:

ports:
  - "127.0.0.1:3000:3000"

Jangan memakai 0.0.0.0:3000 jika app hanya perlu diakses reverse proxy. DNS A atau AAAA domain harus menunjuk ke VPS, dan port 80/443 harus dapat diakses agar sertifikat TLS diterbitkan.

Domain dan HTTPS

Urutannya:

  1. Buat DNS record ke IP VPS.
  2. Pastikan firewall mengizinkan TCP 80 dan 443.
  3. Pastikan Caddy dapat reach app.
  4. Jalankan Caddy dan lihat log certificate.
  5. Tes curl -I https://app.example.com.
dig +short app.example.com
curl -I https://app.example.com
docker compose logs --tail=100 caddy

Jangan membuat redirect HTTPS di banyak layer tanpa memahami proxy chain. Aplikasi harus mempercayai header forwarded hanya dari proxy yang kamu kontrol, dan URL canonical harus memakai domain production yang eksplisit.

Migration database

Migration harus menjadi langkah deployment yang dapat diulang dan aman. Pola umum:

docker compose run --rm app npm run db:migrate

Gunakan perubahan backward-compatible: tambahkan kolom nullable dulu, deploy kode yang memahami format lama dan baru, backfill, lalu hapus format lama pada release berikutnya. Migration yang langsung rename atau drop kolom dapat mematahkan container lama saat rolling deployment.

Jangan menjalankan migration otomatis dari setiap replica yang start tanpa lock atau mekanisme concurrency. Satu job release lebih mudah diaudit daripada beberapa container berlomba melakukan perubahan schema.

Deployment dari GitHub Actions

Pipeline sebaiknya membangun image dengan tag commit SHA, push ke registry, lalu server menarik image yang sama. Build ulang di server membuat hasil deploy bergantung pada source tree dan environment server.

name: deploy
on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm test

  deploy:
    needs: test
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - name: Deploy over SSH
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.DEPLOY_KEY }}
          script: |
            cd /opt/my-app
            git fetch origin main
            git reset --hard origin/main
            docker compose build app
            docker compose run --rm app npm run db:migrate
            docker compose up -d app
            curl --fail --retry 10 http://127.0.0.1:3000/health

Untuk sistem yang lebih matang, push image ke GHCR atau registry private, lalu deployment hanya melakukan pull berdasarkan digest. Pin action ke commit SHA untuk pipeline sensitif dan batasi permission token.

Go live checklist

Sebelum membuka domain ke user:

  • [ ] SSH key tested dan password auth sesuai kebijakan.
  • [ ] UFW hanya membuka 22, 80, dan 443 sesuai kebutuhan.
  • [ ] Docker Engine dan Compose plugin terpasang dari repository resmi.
  • [ ] .env tidak masuk Git atau image.
  • [ ] Container runtime tidak berjalan sebagai root.
  • [ ] Database dan Redis tidak dipublish ke internet.
  • [ ] Volume database memiliki backup dan restore test.
  • [ ] Healthcheck dan smoke test tersedia.
  • [ ] Caddy menerbitkan sertifikat valid.
  • [ ] Log dan restart policy sudah diverifikasi.
  • [ ] Migration backward-compatible.
  • [ ] Rollback image dan konfigurasi sudah diketahui.
  • [ ] Resource limit dan disk alert sudah ada.

Operasi setelah go live

Lihat status dan log tanpa mengikuti semua output secara buta:

docker compose ps
docker compose logs --since=15m app
docker stats --no-stream
curl -fsS https://app.example.com/health

Tetapkan log retention. Container log yang tidak dibatasi dapat memenuhi disk dan mematikan Docker, database, serta aplikasi sekaligus. Pantau disk Docker dengan docker system df, tetapi jangan menjalankan docker system prune -a secara otomatis tanpa memahami image yang masih dibutuhkan untuk rollback.

Backup database harus diuji restore. Backup yang hanya pernah dibuat tetapi belum pernah dipulihkan belum membuktikan recovery. Simpan backup di lokasi berbeda dan gunakan encryption sesuai kebutuhan data.

Rollback

Rollback harus mengembalikan artifact, konfigurasi, dan bila perlu schema ke kombinasi yang kompatibel. Menjalankan git checkout lalu docker compose up tidak selalu cukup jika image sudah berubah atau migration tidak reversible.

Simpan release identifier:

docker image inspect my-app:release-abc123 --format '{{.Id}}'
docker compose images

Pada deployment berbasis registry, rollback berarti mengubah image tag/digest ke release sebelumnya, menjalankan smoke test, lalu mengamati error rate. Rollback database adalah masalah terpisah; hindari migration destruktif supaya aplikasi lama masih dapat hidup selama rollback.

Kesalahan yang sering terjadi

  • Membuka port 5432, 6379, atau 3000 ke publik tanpa alasan.
  • Menyimpan secret di docker-compose.yml atau repository.
  • Menggunakan latest sehingga rollback tidak deterministik.
  • Menghapus volume saat membersihkan container.
  • Menjalankan semua proses sebagai root.
  • Tidak punya healthcheck, smoke test, atau endpoint readiness.
  • Menganggap depends_on berarti database sudah siap.
  • Build di server production tanpa artifact yang bisa dilacak.
  • Menjalankan migration dari semua replica.
  • Tidak membatasi log dan penggunaan disk.

Rekomendasi desain

Untuk satu aplikasi dan satu VPS, Docker Compose plus Caddy sudah cukup jika backup, monitoring, firewall, dan prosedur rollback dikerjakan serius. Untuk beberapa server, registry dan deployment controller mulai memberi manfaat. Untuk banyak service yang membutuhkan scheduling dan autoscaling, evaluasi Kubernetes setelah batas Compose benar-benar terasa.

Docker bukan tujuan akhir. Ia adalah boundary packaging dan process isolation. Go live yang sehat memerlukan boundary network, credential, data persistence, observability, dan recovery yang sama jelasnya.