Docker Workflow yang Saya Pakai di Setiap Project

Foto oleh Unsplash

Foto oleh Unsplash
Setelah beberapa tahun menjalankan Docker di produksi—mulai dari VPS, Cloud Run, hingga server bare-metal di Commsult Indonesia—saya sudah menemukan satu workflow yang dapat langsung diterapkan ke project baru di hari pertama. Bukan sesuatu yang revolusioner; justru membosankan dalam artian yang baik. Post ini mendokumentasikan setup tersebut secara detail: apa saja komponennya, kenapa setiap bagian ada di sana, dan kesalahan-kesalahan yang saya buat sebelum akhirnya settle dengan setup ini.
Keberatan yang sering muncul adalah Docker menambah kompleksitas untuk project kecil. Itu benar jika langsung pakai Kubernetes atau Swarm untuk side project. Tapi compose.yml dengan dua service—app dan database—hanya butuh sekitar 30 menit setup dan menghemat berjam-jam debugging 'works on my machine' sepanjang umur project. Keuntungan terbesarnya adalah parity: environment lokal, pipeline CI, dan server produksi semuanya menjalankan image yang sama.
Saya pakai Docker bukan karena container itu keren, tapi karena parity environment memang sulit dicapai dengan cara lain. Version manager Node (nvm, fnm) membantu untuk runtime, tapi tidak menyelesaikan perbedaan library OS, pengaturan locale, atau versi Postgres yang tepat. Dockerfile mengunci semua itu. compose.yml menambahkan layanan pendukung—database, cache, mungkin mail catcher—sehingga seluruh stack bisa jalan dengan satu perintah.
Setiap project memiliki Dockerfile di root dengan tiga stage: base (image Node bersama dan instalasi package), development (mount source code, jalankan dev server dengan hot reload), dan production (hanya menyalin output kompilasi, set non-root user, jalankan runtime minimal). compose.yml merujuk ke stage development secara default.
┌─────────────────────────────────────────────────────────────┐
│ Docker Dev Workflow │
│ │
│ ┌──────────────┐ docker compose up ┌────────────────┐ │
│ │ Source Code │ ─────────────────────▶│ App Container │ │
│ │ (bind mount)│ │ :3000 │ │
│ └──────────────┘ └────────┬───────┘ │
│ │ │
│ ┌──────────────┐ hot reload / watch ┌────────▼───────┐ │
│ │ Dockerfile │◀── volume change ──────│ DB Container │ │
│ │ .env │ │ postgres:16 │ │
│ │ compose.yml │ └────────────────┘ │
│ └──────────────┘ │
│ │
│ Local: identical env to production, zero "works on my │
│ machine" surprises │
└─────────────────────────────────────────────────────────────┘Gunakan field 'target' di compose.yml untuk memilih stage development. Di CI, build stage production secara eksplisit dengan: docker build --target production -t myapp:latest . Ini mencegah dev dependencies masuk ke image produksi.
compose.yml standar saya menyertakan healthcheck pada service database dan menggunakan 'depends_on' dengan 'condition: service_healthy'. Ini mencegah container app mulai sebelum Postgres benar-benar siap menerima koneksi. Saya juga menggunakan anonymous volume untuk node_modules di dalam container, yang mencegah node_modules host menimpa modul container.
Untuk development lokal, saya menyimpan file .env.example di repository dengan nilai placeholder dan file .env di .gitignore. Docker Compose membaca .env secara otomatis. Untuk produksi, saya menggunakan integrasi secret manager Cloud Run—tidak pernah environment variable yang di-bake ke dalam image.
# compose.yml (root of every project)
services:
app:
build:
context: .
dockerfile: Dockerfile
target: development
volumes:
- .:/app
- /app/node_modules # anonymous volume keeps container modules
ports:
- "3000:3000"
environment:
- NODE_ENV=development
- DATABASE_URL=postgresql://dev:dev@db:5432/appdb
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: dev
POSTGRES_PASSWORD: dev
POSTGRES_DB: appdb
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U dev -d appdb"]
interval: 5s
timeout: 5s
retries: 5
volumes:
pgdata:
---
# Multi-stage Dockerfile
FROM node:20-alpine AS base
WORKDIR /app
COPY package*.json ./
FROM base AS development
RUN npm ci
COPY . .
CMD ["npm", "run", "dev"]
FROM base AS builder
RUN npm ci --omit=dev
COPY . .
RUN npm run build
FROM node:20-alpine AS production
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
USER node
CMD ["node", "dist/main.js"]Dockerfile Node.js yang naif menginstal semua dependensi dan menyalin semua file sumber. Image yang dihasilkan bisa 800 MB atau lebih. Dengan multi-stage build, stage production hanya menyalin direktori dist/ yang dikompilasi dan menginstal hanya production dependencies. Hasilnya biasanya 150-250 MB—pengurangan 60-70%.
Jangan pernah gunakan 'latest' sebagai tag image di produksi. Selalu tag image dengan git commit SHA atau versi semantik. 'latest' membuat rollback ambigu—Anda kehilangan kemampuan untuk dengan cepat re-deploy image yang diketahui baik sebelumnya.
Di GitHub Actions, saya build dan test di dalam Docker untuk menjamin environment CI sesuai dengan produksi. Workflow menjalankan 'docker compose up -d' untuk memulai stack penuh, mengeksekusi test suite, lalu build dan push image produksi.
Kesalahan terbesar adalah tidak menggunakan health check, yang menyebabkan kegagalan startup intermiten di CI. Kedua adalah bind-mounting seluruh project tanpa anonymous volume untuk node_modules. Ketiga adalah menggunakan root di dalam container di produksi. Menambahkan 'USER node' ke stage production memperbaiki ketiga kategori masalah.