Aplikasi full-stack di dunia nyata jarang berdiri sendiri sebagai satu proses tunggal. Backend butuh database untuk menyimpan data, cache untuk mempercepat query yang sering diakses, dan reverse proxy di depan semuanya, yaitu server perantara yang mengatur trafik masuk sekaligus menyembunyikan detail internal arsitektur dari luar. Studi kasus sebelumnya sudah membahas cara men-containerize satu jenis aplikasi secara spesifik, mulai dari Node.js, Laravel, Python, Golang, sampai WordPress. Bab ini mengambil sudut pandang berbeda: bukan tentang framework tertentu, melainkan tentang cara menyusun kombinasi database PostgreSQL, cache Redis, dan reverse proxy Nginx menjadi satu arsitektur multi-container yang kokoh, lengkap dengan pengaturan urutan startup antar-service, konfigurasi yang berbeda antara development dan production, serta strategi scaling untuk service yang sifatnya stateless.
33.1 Kombinasi Database, Cache, dan Reverse Proxy
Arsitektur full-stack yang dibahas di bab ini terdiri dari empat service: postgres sebagai database utama, redis sebagai cache dan penyimpanan session, api sebagai backend yang memproses logika bisnis, dan nginx sebagai reverse proxy yang jadi satu-satunya pintu masuk trafik dari luar. Pola ini dipakai luas oleh Developer maupun Sysadmin/DevOps Engineer karena tiap service punya tanggung jawab yang jelas dan bisa di-scale atau diganti secara independen.
33.1.1 Arsitektur Full-Stack Multi-Container
Sebelum menulis konfigurasi, penting memetakan dulu bagaimana trafik mengalir di antara keempat service tersebut. Nginx menerima seluruh request dari luar lewat port yang dipetakan ke host, lalu meneruskannya ke service api lewat network internal Compose. Service api membaca dan menulis data ke postgres untuk data permanen, serta ke redis untuk data sementara seperti cache hasil query atau session pengguna yang sedang login. Baik postgres maupun redis tidak pernah diekspos langsung ke host, karena keduanya cuma perlu diakses dari dalam network Docker oleh service api.
Susun kerangka compose.yaml dengan keempat service ini, beserta satu custom network agar isolasi antar-service lebih eksplisit dibanding mengandalkan default network begitu saja.
services:
nginx:
image: nginx:1.27-alpine
ports:
- "80:80"
depends_on:
- api
networks:
- frontend-net
api:
build: ./api
expose:
- "3000"
environment:
DATABASE_URL: postgres://appuser:secret@postgres:5432/appdb
REDIS_URL: redis://redis:6379
depends_on:
- postgres
- redis
networks:
- frontend-net
- backend-net
postgres:
image: postgres:18-alpine
environment:
POSTGRES_DB: appdb
POSTGRES_USER: appuser
POSTGRES_PASSWORD: secret
volumes:
- pg-data:/var/lib/postgresql/data
networks:
- backend-net
redis:
image: redis:8-alpine
networks:
- backend-net
networks:
frontend-net:
backend-net:
volumes:
pg-data:Pembagian dua custom network di sini bukan sekadar formalitas. Service nginx cuma tergabung ke frontend-net bersama api, sementara postgres dan redis cuma tergabung ke backend-net bersama api. Hasilnya, nginx secara arsitektural tidak punya jalur jaringan langsung menuju postgres maupun redis, meskipun berjalan dalam Compose project yang sama. Pola isolasi seperti ini memperkecil attack surface kalau suatu saat container nginx berhasil dieksploitasi Attacker, karena akses ke database dan cache tetap terbatas cuma lewat service api.
Perhatikan juga bahwa service api memakai expose, bukan ports. Instruksi expose cuma mendeklarasikan port yang bisa diakses oleh service lain dalam network yang sama, tanpa memetakannya ke host, sesuai definisi resmi di Compose Specification. Ini konsisten dengan prinsip bahwa satu-satunya jalur masuk trafik dari luar wajib lewat nginx, bukan langsung ke backend.
33.1.2 Konfigurasi PostgreSQL dan Redis
Image resmi postgres otomatis membuat database, user, dan password sesuai variabel environment POSTGRES_DB, POSTGRES_USER, dan POSTGRES_PASSWORD saat volume datanya masih kosong. Redis pada konfigurasi dasar di atas berjalan tanpa autentikasi maupun konfigurasi khusus, cukup memadai untuk kebutuhan cache internal yang cuma diakses dari backend-net yang sudah terisolasi.
Untuk kebutuhan production, tambahkan password di Redis lewat opsi --requirepass supaya service ini tidak sepenuhnya terbuka meskipun sudah berada di network yang terisolasi, sebagai lapisan pertahanan tambahan.
services:
redis:
image: redis:8-alpine
command: ["redis-server", "--requirepass", "${REDIS_PASSWORD}"]
networks:
- backend-netKalau opsi --requirepass diaktifkan, REDIS_URL di sisi api juga wajib disesuaikan menyertakan password tersebut, misalnya redis://:secret@redis:6379, mengikuti format connection string Redis yang menaruh password di posisi userinfo URL sebelum tanda @.
33.1.3 Nginx sebagai Reverse Proxy ke Backend
Konfigurasi Nginx meneruskan seluruh request ke service api lewat nama service-nya, memanfaatkan DNS resolution internal Compose yang sama seperti pola reverse proxy pada studi kasus lain.
server {
listen 80;
server_name _;
location /api/ {
proxy_pass http://api:3000/;
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;
}
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
}Konfigurasi ini sekaligus menunjukkan pola umum aplikasi full-stack modern: path /api/ diteruskan ke backend, sementara path lain dilayani sebagai file statis hasil build frontend (misalnya single-page application berbasis React, Vue, atau framework frontend lain). Direktif try_files dengan fallback ke /index.html penting untuk SPA yang memakai client-side routing, supaya me-refresh halaman di path selain root tidak menghasilkan error 404 dari Nginx, karena routing sebenarnya baru ditangani oleh JavaScript setelah index.html termuat di browser.
Petakan file hasil build frontend ke /usr/share/nginx/html lewat bind mount untuk development, atau salin langsung ke dalam image lewat multi-stage build untuk production, pola yang dibahas lebih lanjut di bagian konfigurasi environment-specific.
33.2 Service Dependency dan Startup Order
Kalau service api mencoba konek ke postgres atau redis sebelum keduanya benar-benar siap menerima koneksi, aplikasi biasanya langsung crash atau gagal start di percobaan pertama. Instruksi depends_on polos seperti pada konfigurasi awal cuma mengatur urutan container dibuat dan distart, bukan menjamin service di dalamnya sudah siap menerima trafik, perbedaan yang sering jadi sumber kebingungan Developer yang baru pertama kali menyusun arsitektur multi-container.
33.2.1 Healthcheck pada PostgreSQL dan Redis
Tambahkan healthcheck pada service postgres memakai pg_isready, utility bawaan PostgreSQL yang dirancang khusus untuk memeriksa kesiapan server menerima koneksi.
services:
postgres:
image: postgres:18-alpine
environment:
POSTGRES_DB: appdb
POSTGRES_USER: appuser
POSTGRES_PASSWORD: secret
volumes:
- pg-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
interval: 5s
timeout: 5s
retries: 5
networks:
- backend-netLakukan hal serupa untuk redis memakai redis-cli ping, yang mengembalikan respons PONG begitu server Redis siap melayani perintah.
services:
redis:
image: redis:8-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
networks:
- backend-net33.2.2 depends_on dengan condition: service_healthy
Setelah kedua service dilengkapi healthcheck, ubah depends_on pada service api supaya menunggu kondisi service_healthy, bukan cuma menunggu container-nya selesai dibuat.
services:
api:
build: ./api
expose:
- "3000"
environment:
DATABASE_URL: postgres://appuser:secret@postgres:5432/appdb
REDIS_URL: redis://redis:6379
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks:
- frontend-net
- backend-netKonfigurasi ini membuat Docker Compose baru menjalankan container api setelah postgres dan redis sama-sama berstatus healthy, sesuai perilaku condition: service_healthy yang didefinisikan Compose Specification. Perilaku ini jauh lebih dapat diandalkan dibanding menambahkan sleep beberapa detik di entrypoint script sebagai workaround, praktik yang masih sering ditemui di lapangan tapi rapuh karena waktu startup database bisa berbeda-beda tergantung beban I/O host saat itu.
33.2.3 Retry Logic di Sisi Aplikasi
Healthcheck dan depends_on cuma menjamin kondisi saat container pertama kali start, bukan menjamin koneksi tetap stabil sepanjang lifetime aplikasi berjalan. Postgres atau Redis bisa saja restart karena maintenance, kehabisan resource, atau proses rolling update, sementara service api tetap berjalan dan perlu menyambung ulang begitu koneksi terputus.
Di lapangan, Sysadmin/DevOps Engineer sering menemukan kasus aplikasi yang berhasil start dengan mulus berkat depends_on dan healthcheck, tapi tetap crash beberapa jam kemudian saat database di-restart untuk keperluan maintenance, karena kode aplikasi tidak punya mekanisme reconnect otomatis. Pastikan library atau driver database dan Redis yang dipakai backend mendukung connection pooling dengan retry otomatis, dan pertahankan restart: unless-stopped pada service api sebagai jaring pengaman terakhir kalau proses aplikasi sampai keluar akibat koneksi yang gagal total.
services:
api:
build: ./api
restart: unless-stopped
expose:
- "3000"33.3 Environment-Specific Configuration
Konfigurasi yang cocok untuk development, misalnya hot reload dan port database yang diekspos untuk debugging langsung dari tool database di host, jelas tidak cocok dipakai apa adanya di production. Compose menyediakan mekanisme override file supaya satu basis konfigurasi bisa disesuaikan per environment tanpa duplikasi penuh.
33.3.1 Compose Override Files
Docker Compose secara otomatis membaca dan menggabungkan file compose.override.yaml bersama compose.yaml setiap kali perintah docker compose up dijalankan tanpa opsi -f tambahan, sesuai dokumentasi resmi Compose mengenai multiple compose files. Manfaatkan perilaku ini untuk menaruh konfigurasi khusus development di compose.override.yaml, sementara compose.yaml tetap berisi konfigurasi dasar yang relevan di semua environment.
services:
api:
build:
context: ./api
target: development
volumes:
- ./api/src:/app/src
environment:
NODE_ENV: development
postgres:
ports:
- "5432:5432"Bind mount ke folder src pada contoh ini memungkinkan perubahan kode langsung terbaca oleh proses hot reload di dalam container tanpa perlu rebuild image berulang kali, sementara pemetaan port 5432 ke host memudahkan Developer membuka koneksi langsung dari database client favorit untuk keperluan debugging data. Untuk production, siapkan file terpisah bernama compose.prod.yaml yang isinya justru menghilangkan kedua kemudahan tersebut demi keamanan dan efisiensi.
services:
api:
build:
context: ./api
target: production
environment:
NODE_ENV: production
postgres:
ports: []Jalankan production dengan menyebutkan kedua file secara eksplisit lewat opsi -f, karena compose.prod.yaml tidak otomatis terbaca seperti compose.override.yaml.
docker compose -f compose.yaml -f compose.prod.yaml up -dCompose menggabungkan kedua file secara berurutan sesuai urutan penyebutannya di opsi -f, dengan nilai dari file yang disebut belakangan menimpa nilai yang sama dari file sebelumnya. Menuliskan ports: [] secara eksplisit pada compose.prod.yaml di atas memastikan pemetaan port 5432 dari compose.override.yaml tidak ikut aktif kalau berkas tersebut sampai tercampur secara tidak sengaja, meskipun dalam alur normal berkas compose.override.yaml memang tidak disertakan sama sekali pada perintah production di atas.
33.3.2 Environment Variable per Environment dengan .env
Docker Compose otomatis membaca file .env di direktori yang sama dengan compose.yaml untuk keperluan variable substitution memakai sintaks ${VARIABLE}, sesuai dokumentasi resmi Compose mengenai environment variables. Manfaatkan ini untuk memisahkan nilai yang berbeda-beda per environment, seperti kredensial atau nama host, dari struktur konfigurasi yang tetap sama.
services:
postgres:
image: postgres:18-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}# .env
POSTGRES_DB=appdb
POSTGRES_USER=appuser
POSTGRES_PASSWORD=dev-secret-not-for-prodUntuk production, jangan pernah menaruh file .env berisi kredensial asli ke dalam version control. Simpan file .env khusus production terpisah di server target, atau lebih baik lagi, kelola lewat mekanisme secrets management khusus platform orkestrasi yang dipakai, sesuai prinsip keamanan yang berlaku umum untuk kredensial sensitif. Tambahkan .env ke dalam .gitignore sejak awal proyek, dan sediakan .env.example berisi daftar variabel yang dibutuhkan tanpa nilai sungguhan, sebagai dokumentasi bagi anggota tim lain.
33.3.3 Multi-Stage Build untuk Target Dev dan Production
Multi-stage build pada Dockerfile backend bisa dirancang dengan dua target berbeda, satu untuk development dan satu untuk production, lalu dipilih lewat opsi target pada konfigurasi build di Compose seperti pada bagian sebelumnya.
FROM node:20-alpine AS base
WORKDIR /app
COPY package*.json ./
FROM base AS development
RUN npm install
COPY . .
CMD ["npm", "run", "dev"]
FROM base AS build
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
CMD ["node", "dist/index.js"]Stage development memakai npm install biasa dan menjalankan script dev yang umumnya memantau perubahan file untuk hot reload, sementara stage production memakai npm ci --omit=dev untuk instalasi dependency yang lebih cepat dan reproducible serta cuma menyertakan dependency production, lalu menyalin hasil build dari stage build tanpa ikut membawa source code mentah maupun dev dependency ke dalam image akhir. Hasilnya, image production berukuran jauh lebih kecil dan permukaan serangannya lebih sempit dibanding image development, karena tidak menyertakan tool build maupun source code asli yang tidak diperlukan saat runtime.
33.4 Scaling Stateless Services
Salah satu keuntungan memisahkan backend jadi service tersendiri yang stateless, artinya tidak menyimpan data penting apapun di dalam filesystem container itu sendiri, adalah kemampuan menjalankan banyak instance sekaligus untuk menangani beban trafik yang lebih besar. Database dan cache tetap berjalan sebagai instance tunggal pada skenario dasar ini, karena keduanya bersifat stateful dan butuh strategi replikasi tersendiri yang berbeda dari sekadar menambah jumlah container.
33.4.1 Stateless vs Stateful dalam Arsitektur Ini
Service api pada arsitektur bab ini tergolong stateless selama tidak menyimpan session atau data apapun di disk lokal container, dan sudah memakai redis untuk keperluan session sharing antar-instance. Karakteristik ini krusial: kalau backend menyimpan session di memori proses itu sendiri (pola in-memory session) alih-alih di Redis, menambah jumlah instance justru merusak pengalaman pengguna, karena request berikutnya dari user yang sama bisa saja mendarat di instance berbeda yang tidak punya data session tersebut.
Service postgres dan redis di sisi lain bersifat stateful karena menyimpan data yang wajib konsisten dan tidak bisa begitu saja diduplikasi jadi banyak instance independen tanpa mekanisme replikasi database yang tepat. Fokus scaling pada bagian ini murni pada service api, sementara postgres dan redis tetap berjalan sebagai instance tunggal.
33.4.2 Scaling dengan docker compose --scale
Jalankan lebih dari satu instance service api lewat opsi --scale pada perintah docker compose up.
docker compose up -d --scale api=3Perintah ini membuat tiga container terpisah dari service api, masing-masing dengan nama container yang diberi akhiran nomor urut secara otomatis oleh Compose. Perhatikan bahwa konfigurasi api pada bab ini sengaja memakai expose, bukan ports dengan pemetaan port host yang statis. Kalau sebuah service memetakan port host secara statis, misalnya "3000:3000", menjalankan --scale dengan nilai lebih dari satu akan gagal karena tiap instance mencoba mem-bind port host yang sama, menghasilkan error Bind for 0.0.0.0:3000 failed: port is already allocated. Ini sebabnya port host cuma dipetakan pada service nginx yang memang berjalan sebagai instance tunggal di depan seluruh trafik.
33.4.3 Load Balancing Lewat Nginx
Setelah service api punya beberapa instance, Nginx perlu membagi trafik secara merata ke seluruh instance tersebut lewat mekanisme load balancing, bukan cuma meneruskan seluruh trafik ke satu instance saja. Docker embedded DNS server pada network Compose otomatis mengembalikan seluruh alamat IP instance yang aktif saat nama service api di-resolve, sesuai dokumentasi resmi Docker mengenai networking, sehingga secara prinsip trafik bisa didistribusikan ke banyak instance lewat resolusi DNS ini.
Ada jebakan penting di sini yang sering luput dari perhatian Developer yang baru menerapkan scaling: Nginx secara default cuma me-resolve hostname pada proxy_pass sekali saat proses worker Nginx pertama kali start, lalu menyimpan hasilnya di cache selama proses tersebut berjalan. Akibatnya, kalau ada instance api baru yang ditambahkan lewat --scale setelah Nginx sudah berjalan, Nginx tidak otomatis mengetahui alamat IP instance baru tersebut sampai Nginx sendiri di-restart atau di-reload. Atasi ini dengan menambahkan direktif resolver yang menunjuk ke embedded DNS Docker pada alamat 127.0.0.11, dikombinasikan dengan menaruh nama service dalam bentuk variabel, bukan string statis, pada proxy_pass.
server {
listen 80;
server_name _;
resolver 127.0.0.11 valid=10s;
location /api/ {
set $upstream_api api:3000;
proxy_pass http://$upstream_api/;
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;
}
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
}Menaruh nama service ke dalam variabel $upstream_api memaksa Nginx melakukan resolusi DNS ulang tiap kali cache hasil resolusi sebelumnya kedaluwarsa sesuai nilai valid pada direktif resolver, alih-alih cuma sekali di awal. Alamat 127.0.0.11 adalah alamat embedded DNS server baku yang disediakan Docker di dalam setiap container pada user-defined network, dipakai di sini alih-alih resolver publik seperti milik penyedia DNS eksternal, karena yang perlu di-resolve adalah nama service internal Compose, bukan domain publik.
33.4.4 Verifikasi dan Troubleshooting Scaling
Periksa daftar container yang aktif untuk memastikan jumlah instance api sudah sesuai target scaling.
docker compose ps apiUji distribusi trafik dengan mengirim beberapa request berturut-turut ke endpoint yang menampilkan identitas container, misalnya hostname container itu sendiri, lalu amati apakah responsnya berganti-ganti antar-instance.
for i in $(seq 1 6); do curl -s http://localhost/api/health; echo; doneKalau seluruh request selalu mendarat di instance yang sama meskipun sudah ada beberapa replika, periksa dulu apakah konfigurasi resolver pada Nginx sudah benar dan direktif proxy_pass sudah memakai variabel, bukan string host statis. Penyebab lain yang sering ditemui di lapangan adalah koneksi keep-alive antara client dan Nginx yang membuat satu client memang secara wajar selalu terhubung ke upstream connection yang sama selama koneksinya belum ditutup, perilaku normal yang bukan berarti load balancing gagal, cuma perlu diuji dengan beberapa koneksi terpisah alih-alih satu koneksi panjang yang sama untuk melihat distribusinya secara akurat. Turunkan kembali jumlah instance ke satu kalau scaling cuma untuk keperluan pengujian, supaya resource host tidak terus terpakai tanpa perlu.
docker compose up -d --scale api=1
