2
Arsitektur Sistem
Hydro berjalan sebagai tiga container di satu host: web, api, dan db. Ketiganya didefinisikan di compose.prod.yaml dan dipisah ke tiga network bernama edge, app, dan data, dengan dua network terakhir bertanda internal: true (compose.prod.yaml:58-631). web satu-satunya layanan di edge, db satu-satunya layanan di data, dan api terpasang di app sekaligus data sehingga menjadi satu-satunya jembatan menuju database (compose.prod.yaml:22-342). Halaman ini hanya membahas susunan runtime. Isi modul dan model ada di Modul Domain dan Model Data, sedangkan langkah rilis dan operasi ada di Deploy Produksi dan Operasi.
Tiga layanan pada tiga network
Setiap layanan hanya dihubungkan ke network yang benar-benar dibutuhkannya. web memakai daftar networks: [edge, app], api memakai [app, data], dan db hanya [data] (compose.prod.yaml:2-143, compose.prod.yaml:22-342, compose.prod.yaml:42-564).
graph TD
klien["klien Tailscale"]
subgraph EDGE["network edge"]
web["web: nginx-unprivileged 1.29-alpine, listen 8443 ssl"]
end
subgraph APP["network app - internal true"]
api["api: gunicorn config.wsgi, bind 0.0.0.0:8000"]
end
subgraph DATA["network data - internal true"]
db["db: postgres 18.1, port 5432"]
pgdata[("volume pgdata /var/lib/postgresql")]
end
media[("volume media /data/media")]
klien -->|"443 ke TAILSCALE_IP"| web
web -->|"proxy_pass http://api:8000"| api
api -->|"POSTGRES_HOST=db"| db
api --> media
db --> pgdata
style klien fill:#f59e0b,color:#000
style web fill:#2d7d46,color:#fff
style api fill:#2d7d46,color:#fff
style db fill:#336791,color:#fff
style pgdata fill:#336791,color:#fff
style media fill:#336791,color:#fff
%% fig: Tiga container pada tiga network
%% desc: web hanya menyentuh edge dan app, api menjembatani app dan data, db hanya berada di data dengan volume pgdata.| Container | Image | Network | Volume | Rujukan |
|---|---|---|---|---|
web | ghcr.io/${GHCR_OWNER}/hydro-web:${APP_VERSION} | edge, app | ${CERT_DIR:-./deploy/certs} → /etc/nginx/certs (ro) | compose.prod.yaml:2-143 |
api | ghcr.io/${GHCR_OWNER}/hydro-api:${APP_VERSION} | app, data | media → /data/media | compose.prod.yaml:22-342 |
db | postgres:18.1 | data | pgdata → /var/lib/postgresql | compose.prod.yaml:42-564 |
web dan api sama-sama dijalankan dengan read_only: true, tmpfs hanya untuk /tmp, cap_drop: [ALL], dan no-new-privileges:true (compose.prod.yaml:15-205, compose.prod.yaml:35-406). Karena root filesystem hanya bisa dibaca, satu-satunya area tulis di api adalah volume media yang dipasang di /data/media (compose.prod.yaml:29-307).
db tidak memakai pembatas itu, tetapi dijaga lewat healthcheck pg_isready dengan interval: 5s, timeout: 3s, dan retries: 20 (compose.prod.yaml:52-568). Kedua layanan lain menunggu status service_healthy sebelum start, sehingga api tidak pernah membuka koneksi ke PostgreSQL yang belum siap (compose.prod.yaml:32-349, compose.prod.yaml:12-1410).
⚠️ Satu-satunya port yang dipublikasikan adalah
${TAILSCALE_IP}:443:8443untukweb. Komentar di berkas menyebut alasannya: port Docker bisa melewati aturan UFW (compose.prod.yaml:6-811). Menambahkan pemetaan port untukdbakan membatalkan pemisahan networkdatayang menjadi batas keamanan utama di sini.
Satu request: Nginx, Django, PostgreSQL
Nginx menerima TLS di port 8443 memakai sertifikat /etc/nginx/certs/tls.crt dan kunci tls.key, dengan server_tokens off dan client_max_body_size 6m (deploy/nginx.conf:1-912). Batas 6m itu lebih longgar daripada batas aplikasi, FOTO_MAKS_BYTE 2 MiB dan IMPOR_MAKS_BYTE 5 MiB (backend/config/settings.py:174-17513).
sequenceDiagram
participant B as Browser
participant W as web Nginx 8443
participant A as api gunicorn 8000
participant D as db PostgreSQL
B->>W: GET /api/produk/ lewat TLS
W->>A: proxy_pass http://api:8000
Note over W,A: X-Forwarded-Proto https, X-Real-IP, X-Forwarded-For
A->>A: AutentikasiSesi lalu PunyaIzin
A->>D: query dengan POSTGRES_HOST=db
D-->>A: baris hasil
A-->>W: JSON
W-->>B: JSON
%% fig: Siklus satu request API
%% desc: Nginx meneruskan /api/ ke gunicorn, Django memeriksa sesi dan izin, lalu membaca PostgreSQL di network data sebelum mengembalikan JSON.Blok location /api/ meneruskan ke http://api:8000 sambil memasang X-Real-IP, X-Forwarded-For, dan X-Forwarded-Proto https, lalu menyembunyikan header HSTS, X-Content-Type-Options, X-Frame-Options, dan Referrer-Policy dari response Django karena Nginx sudah memasangnya sendiri (deploy/nginx.conf:21-3314). Header itu berasal dari deploy/keamanan.conf yang di-include di level server maupun di tiap location (deploy/keamanan.conf:1-715).
Di sisi Django, SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") membuat request yang datang lewat HTTP dari Nginx tetap dikenali sebagai HTTPS, lalu SECURE_HSTS_SECONDS diset 30 hari (backend/config/settings.py:140-14516). Aplikasi memakai sesi lewat cookie dengan SESSION_COOKIE_SECURE dan CSRF_COOKIE_SECURE aktif saat DEBUG mati (backend/config/settings.py:133-13817). Rincian alur login dan 2FA ada di Autentikasi, 2FA, dan Izin.
Endpoint kesehatan sengaja ditutup dari luar: location = /api/kesehatan/ mengembalikan 404 (deploy/nginx.conf:16-1918). Healthcheck container memanggilnya langsung ke http://127.0.0.1:8000/api/kesehatan/ dan lulus bila status 200 (backend/healthcheck.py:1-1319), sedangkan view kesehatan hanya mengembalikan {"status": "ok"} (backend/akun/views.py:29-3020). Path yang sama juga dibebaskan dari redirect HTTPS lewat SECURE_REDIRECT_EXEMPT (backend/config/settings.py:143-14421).
Semua rute dikumpulkan di satu daftar urlpatterns: view laporan dan api/_allauth/ ditulis eksplisit, sedangkan 14 viewset didaftarkan lewat SimpleRouter dan dipasang di prefiks api/ (backend/config/urls.py:22-4722).
Berkas statis dan pembentukan image
graph LR
fe["frontend/ + package-lock.json"] --> ci["npm ci"]
ci --> buildfe["npm run build, output export"]
buildfe --> out["/src/out"]
out --> webimg["image hydro-web: Nginx + berkas statis"]
be["backend/"] --> pip["pip install requirements.txt"]
pip --> user["useradd hydro UID 10001"]
user --> apiimg["image hydro-api: gunicorn 3 workers"]
style fe fill:#f59e0b,color:#000
style be fill:#f59e0b,color:#000
style out fill:#8b5cf6,color:#fff
style webimg fill:#2d7d46,color:#fff
style apiimg fill:#2d7d46,color:#fff
%% fig: Dua jalur build image
%% desc: Frontend dibangun dengan Node lalu hasil statis disalin ke image Nginx, sementara backend dibangun dari python 3.13-slim-bookworm dan dijalankan sebagai UID 10001.Image web dibangun dua tahap: tahap pertama memakai node:26-slim, menjalankan npm ci dan npm run build; tahap akhir memakai nginxinc/nginx-unprivileged:1.29-alpine dan hanya menyalin deploy/nginx.conf, deploy/keamanan.conf, serta /src/out ke /usr/share/nginx/html, tanpa Node.js (deploy/web.Dockerfile:1-1423). Di produksi NODE_ENV=production memilih output: "export", trailingSlash: true, dan images: { unoptimized: true }, jadi seluruh halaman keluar sebagai berkas statis (frontend/next.config.ts:18-2524).
Image api berjalan sebagai user hydro dengan UID 10001, menyiapkan /data/media, menyetel DJANGO_MEDIA_ROOT=/data/media, dan memakai HEALTHCHECK tiap 15 detik serta gunicorn config.wsgi --workers 3 --timeout 60 (backend/Dockerfile:16-24).
Berkas statis disajikan dengan dua aturan cache: /_next/static/ mendapat Cache-Control "public, max-age=31536000, immutable", sedangkan location / memakai no-cache dan try_files $uri $uri/index.html /404.html (deploy/nginx.conf:35-3825, deploy/nginx.conf:40-4426).
Volume dan data yang bertahan
| Volume | Titik pasang | Dipakai | Isi | Rujukan |
|---|---|---|---|---|
pgdata | /var/lib/postgresql | db | seluruh database PostgreSQL | compose.prod.yaml:49-5027, compose.prod.yaml:65-6728 |
media | /data/media | api | berkas unggahan, dipakai MEDIA_ROOT | compose.prod.yaml:29-307, backend/config/settings.py:110-11229 |
MEDIA_URL bernilai /media/, tetapi deploy/nginx.conf tidak punya location untuk prefiks itu (backend/config/settings.py:110-11229, deploy/nginx.conf:21-4430). Permintaan ke /media/ akan jatuh ke location / dan berakhir di try_files, sehingga berkas media tidak disajikan langsung oleh web.
Koneksi database diatur dari environment: POSTGRES_HOST: db dan POSTGRES_PORT: "5432" diisi di tingkat layanan api (compose.prod.yaml:26-2831), dan settings.py membacanya lewat DATABASES dengan CONN_MAX_AGE: 60 (backend/config/settings.py:77-8732).
Topologi saat mencoba di lokal
Topologi lokal sengaja dibuat berbeda dari produksi, dan ketiganya tidak saling menimpa.
| Berkas | Container | Port | Catatan | Rujukan |
|---|---|---|---|---|
compose.prod.yaml | web, api, db | 443:8443 di IP Tailscale | tiga network, internal untuk app dan data | compose.prod.yaml:58-631 |
compose.yaml | web, api, db | 127.0.0.1:8080:8080 | satu network default, deploy/nginx.lokal.conf, tanpa TLS | compose.yaml:3-1433 |
compose.dev.yaml | db saja | 127.0.0.1:5433:5432 | Django dan Next.js dijalankan di host | compose.dev.yaml:1-1634 |
deploy/nginx.lokal.conf mendengarkan di port 8080 tanpa ssl dan meneruskan /api/ dengan X-Forwarded-Proto http, lalu menyajikan sisanya lewat try_files seperti konfigurasi produksi (deploy/nginx.lokal.conf:1-1635). Tanpa environment POSTGRES_HOST, settings.py jatuh ke 127.0.0.1 port 5433, yang memang cocok dengan pemetaan port compose.dev.yaml (backend/config/settings.py:77-8732, compose.dev.yaml:9-1036).
⚠️
compose.yamlmemakai password contohhydrodan tanpa HTTPS, dan berkas itu sendiri menyatakan mode ini tidak untuk produksi. Jangan mengarahkanCOMPOSE_FILEkecompose.yamldi VPS.
Langkah menyalakan ketiga topologi ini, beserta variabel .env yang wajib diisi, ada di Memulai dan Konfigurasi.