Lompat ke konten
dokumentasi.id
Commit 32b2830
v2
Star0

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-63). 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-34). 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-14, compose.prod.yaml:22-34, compose.prod.yaml:42-56).

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.
Fig. 1: Tiga container pada tiga networkweb hanya menyentuh edge dan app, api menjembatani app dan data, db hanya berada di data dengan volume pgdata.
ContainerImageNetworkVolumeRujukan
webghcr.io/${GHCR_OWNER}/hydro-web:${APP_VERSION}edge, app${CERT_DIR:-./deploy/certs} → /etc/nginx/certs (ro)compose.prod.yaml:2-14
apighcr.io/${GHCR_OWNER}/hydro-api:${APP_VERSION}app, datamedia → /data/mediacompose.prod.yaml:22-34
dbpostgres:18.1datapgdata → /var/lib/postgresqlcompose.prod.yaml:42-56

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-20, compose.prod.yaml:35-40). Karena root filesystem hanya bisa dibaca, satu-satunya area tulis di api adalah volume media yang dipasang di /data/media (compose.prod.yaml:29-30).

db tidak memakai pembatas itu, tetapi dijaga lewat healthcheck pg_isready dengan interval: 5s, timeout: 3s, dan retries: 20 (compose.prod.yaml:52-56). Kedua layanan lain menunggu status service_healthy sebelum start, sehingga api tidak pernah membuka koneksi ke PostgreSQL yang belum siap (compose.prod.yaml:32-34, compose.prod.yaml:12-14).

⚠️ Satu-satunya port yang dipublikasikan adalah ${TAILSCALE_IP}:443:8443 untuk web. Komentar di berkas menyebut alasannya: port Docker bisa melewati aturan UFW (compose.prod.yaml:6-8). Menambahkan pemetaan port untuk db akan membatalkan pemisahan network data yang 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-9). Batas 6m itu lebih longgar daripada batas aplikasi, FOTO_MAKS_BYTE 2 MiB dan IMPOR_MAKS_BYTE 5 MiB (backend/config/settings.py:174-175).

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.
Fig. 2: Siklus satu request APINginx 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-33). Header itu berasal dari deploy/keamanan.conf yang di-include di level server maupun di tiap location (deploy/keamanan.conf:1-7).

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-145). Aplikasi memakai sesi lewat cookie dengan SESSION_COOKIE_SECURE dan CSRF_COOKIE_SECURE aktif saat DEBUG mati (backend/config/settings.py:133-138). 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-19). Healthcheck container memanggilnya langsung ke http://127.0.0.1:8000/api/kesehatan/ dan lulus bila status 200 (backend/healthcheck.py:1-13), sedangkan view kesehatan hanya mengembalikan {"status": "ok"} (backend/akun/views.py:29-30). Path yang sama juga dibebaskan dari redirect HTTPS lewat SECURE_REDIRECT_EXEMPT (backend/config/settings.py:143-144).

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-47).

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.
Fig. 3: Dua jalur build imageFrontend 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-14). 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-25).

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-38, deploy/nginx.conf:40-44).

Volume dan data yang bertahan

VolumeTitik pasangDipakaiIsiRujukan
pgdata/var/lib/postgresqldbseluruh database PostgreSQLcompose.prod.yaml:49-50, compose.prod.yaml:65-67
media/data/mediaapiberkas unggahan, dipakai MEDIA_ROOTcompose.prod.yaml:29-30, backend/config/settings.py:110-112

MEDIA_URL bernilai /media/, tetapi deploy/nginx.conf tidak punya location untuk prefiks itu (backend/config/settings.py:110-112, deploy/nginx.conf:21-44). 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-28), dan settings.py membacanya lewat DATABASES dengan CONN_MAX_AGE: 60 (backend/config/settings.py:77-87).

Topologi saat mencoba di lokal

Topologi lokal sengaja dibuat berbeda dari produksi, dan ketiganya tidak saling menimpa.

BerkasContainerPortCatatanRujukan
compose.prod.yamlweb, api, db443:8443 di IP Tailscaletiga network, internal untuk app dan datacompose.prod.yaml:58-63
compose.yamlweb, api, db127.0.0.1:8080:8080satu network default, deploy/nginx.lokal.conf, tanpa TLScompose.yaml:3-14
compose.dev.yamldb saja127.0.0.1:5433:5432Django dan Next.js dijalankan di hostcompose.dev.yaml:1-16

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-16). 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-87, compose.dev.yaml:9-10).

⚠️ compose.yaml memakai password contoh hydro dan tanpa HTTPS, dan berkas itu sendiri menyatakan mode ini tidak untuk produksi. Jangan mengarahkan COMPOSE_FILE ke compose.yaml di VPS.

Langkah menyalakan ketiga topologi ini, beserta variabel .env yang wajib diisi, ada di Memulai dan Konfigurasi.