11
Deploy, Backup, dan Operasi VPS
Halaman ini membahas perjalanan Hydro sampai berjalan di VPS: image yang dirilis lewat tag Git, container web read-only di balik Tailscale, siklus sertifikat, backup harian, dan pemulihan database. Gerbang pengujian yang harus hijau sebelum rilis dijelaskan di Pengujian dan Pipeline CI, sedangkan pembagian komponen aplikasi ada di Arsitektur Sistem.
Topologi tiga jaringan produksi
compose.prod.yaml hanya mengikat satu port ke host, yaitu ${TAILSCALE_IP:?}:443 yang diteruskan ke 8443 milik container web. Komentar di berkas itu menyebut alasannya: port Docker bisa melewati aturan UFW, jadi binding sengaja diikat ke IP Tailscale. compose.prod.yaml:3-201
Container web juga dipaksa tidak bisa menulis ke filesystem: read_only: true dengan satu tmpfs di /tmp, cap_drop: [ALL], no-new-privileges:true, dan sertifikat dipasang read-only dari ${CERT_DIR} ke /etc/nginx/certs. Service ini menunggu api berstatus service_healthy sebelum start. compose.prod.yaml:3-201
api memakai env_file: .env ditambah POSTGRES_HOST: db, memasang volume media di /data/media, dan menerima pengamanan container yang sama dengan web. compose.prod.yaml:22-402
db memakai image postgres:18.1 tanpa port yang dibuka, menyimpan datanya di volume pgdata, dan healthcheck pg_isready berjalan tiap 5 detik dengan timeout 3 detik serta 20 retry. Network app dan data diberi internal: true, jadi hanya edge yang tersentuh host. compose.prod.yaml:42-673
graph TD
klien[Tailnet client] -->|https :443| web
subgraph edgeNet[edge]
web[web nginx-unprivileged :8443<br/>read_only cap_drop ALL]
end
subgraph appNet[app internal]
api[api gunicorn :8000<br/>read_only cap_drop ALL]
end
subgraph dataNet[data internal]
db[db postgres:18.1]
end
web -->|proxy_pass /api/| api
api --> db
web --> certs[volume certs :ro]
api --> mediaV[(volume media)]
db --> pgV[(volume pgdata)]
style web fill:#2d7d46,color:#fff
style api fill:#2d7d46,color:#fff
style db fill:#336791,color:#fff
style mediaV fill:#336791,color:#fff
style pgV fill:#336791,color:#fff
style certs fill:#f59e0b,color:#000
%% fig: Topologi tiga jaringan produksi
%% desc: web di edge, api di app dan data, db hanya di data; certs read-only untuk Nginx, media dan pgdata sebagai volume.| Service | Image | Network | Pengamanan dan volume |
|---|---|---|---|
web | ghcr.io/${GHCR_OWNER}/hydro-web:${APP_VERSION} | edge, app | read_only, cap_drop: [ALL], no-new-privileges, certs:ro |
api | ghcr.io/${GHCR_OWNER}/hydro-api:${APP_VERSION} | app, data | read_only, cap_drop: [ALL], volume media di /data/media |
db | postgres:18.1 | data saja | volume pgdata di /var/lib/postgresql, healthcheck pg_isready |
Gambar container ikut membatasi diri. Image api berjalan sebagai USER 10001 dan mendeklarasikan HEALTHCHECK tiap 15 detik, timeout 5 detik, 5 retry, yang memanggil healthcheck.py; perintah utamanya gunicorn config.wsgi dengan 3 worker dan timeout 60 detik. backend/Dockerfile:21-24
healthcheck.py meminta http://127.0.0.1:8000/api/kesehatan/ dengan header Host diambil dari DJANGO_ALLOWED_HOSTS, lalu keluar 0 hanya kalau status 200. backend/healthcheck.py:1-134 Di sisi Django, path itu dikecualikan dari SECURE_SSL_REDIRECT lewat SECURE_REDIRECT_EXEMPT karena healthcheck memanggil lewat HTTP langsung. backend/config/settings.py:140-1465 Inilah dasar depends_on: condition: service_healthy pada web.
Rilis image lewat tag Git
Workflow CI menyala pada push ke master, pada tag v*, dan pada pull request. Job image hanya berjalan bila startsWith(github.ref, 'refs/tags/v') dan menunggu job backend serta frontend selesai lebih dulu. .github/workflows/ci.yml:1-106
Di dalam job itu nomor versi diambil dari nama tag lewat versi=${GITHUB_REF_NAME#v}, kedua image dibangun, dipindai Trivy dengan --exit-code 1 --severity CRITICAL --ignore-unfixed, lalu dikirim ke GHCR memakai GITHUB_TOKEN dengan izin packages: write. .github/workflows/ci.yml:72-997
| Langkah | Perintah kunci | Hasil |
|---|---|---|
| Turunkan versi | versi=${GITHUB_REF_NAME#v} | tag v1.2.0 menjadi 1.2.0 |
| Build | docker build -t "$REGISTRY/hydro-api:$versi" backend | image hydro-api |
| Build | docker build -t "$REGISTRY/hydro-web:$versi" -f deploy/web.Dockerfile . | image hydro-web |
| Pindai | aquasec/trivy:0.67.2 image --exit-code 1 | rilis batal bila ada CRITICAL yang bisa diperbaiki |
| Kirim | docker push "$REGISTRY/hydro-api:$VERSI" | image tersedia di GHCR |
Kedua nama image itu harus cocok dengan yang ditagih compose.prod.yaml, yang memakai ${APP_VERSION:?} tanpa nilai default. compose.prod.yaml:3-201 Nilai awalnya ada di .env.example. .env.example:1-68
Menerapkan rilis di VPS
deploy/rilis.sh menerima nomor versi sebagai argumen pertama dan langsung mengaktifkan set -euo pipefail, sehingga satu langkah gagal menghentikan seluruh rangkaian. deploy/rilis.sh:1-139
sequenceDiagram
participant O as Operator
participant R as deploy/rilis.sh
participant B as deploy/backup.sh
participant C as docker compose
participant D as db
O->>R: bash deploy/rilis.sh 1.2.0
R->>B: bash deploy/backup.sh
B->>D: pg_dump -Fc via exec -T
B-->>R: rclone copy selesai
R->>R: sed APP_VERSION di .env
R->>C: pull web api
R->>C: run --rm api manage.py migrate
R->>C: up -d
C-->>O: Versi 1.2.0 berjalan
%% fig: Urutan satu rilis lewat rilis.sh
%% desc: Skrip membackup lebih dulu, menulis APP_VERSION, menarik image, menjalankan migrasi sekali pakai, lalu menyalakan semua container.Setelah backup, skrip menulis ulang baris APP_VERSION di .env dengan sed, menarik image web dan api, menjalankan python manage.py migrate di container sekali pakai (--rm), lalu docker compose up -d. deploy/rilis.sh:1-139
⚠️
rilis.shmengikat tiga langkah berurutan: backup, migrasi, dan penggantian container. Kalau migrasi sudah mengubah isi database, mundur ke image lama saja tidak cukup; README menyuruh memulihkan database dengandeploy/pulihkan.sh. README.md:77-11010
Langkah mundur yang aman: ubah APP_VERSION di .env ke versi sebelumnya, jalankan docker compose up -d, dan bila schema sudah berubah pulihkan dari backup. Untuk pemasangan pertama, urutannya pull, up -d db, migrate, buat_pemilik, baru up -d, dan backup harian dipasang di cron 0 19 * * *. README.md:77-11010
Nginx, sertifikat 90 hari, dan cache
Image web dibangun dalam dua tahap: tahap node:26-slim menjalankan npm run build, lalu image akhir hanya nginxinc/nginx-unprivileged:1.29-alpine yang memuat nginx.conf, keamanan.conf, dan hasil /src/out, dengan EXPOSE 8443. deploy/web.Dockerfile:9-1411
nginx.conf mendengar 8443 ssl dengan http2 on, membaca /etc/nginx/certs/tls.crt dan tls.key, membatasi unggahan di client_max_body_size 6m, dan meneruskan /api/ ke http://api:8000 dengan proxy_read_timeout 60s. Endpoint /api/kesehatan/ sengaja dibalas 404 di jalur publik karena hanya dipakai healthcheck container. deploy/nginx.conf:1-3312
Semua location menyertakan /etc/nginx/keamanan.conf. Aset /_next/static/ diberi Cache-Control public, max-age=31536000, immutable, sedangkan halaman HTML memakai no-cache dan try_files $uri $uri/index.html /404.html. deploy/nginx.conf:35-4513
keamanan.conf memasang HSTS max-age=2592000, X-Content-Type-Options nosniff, X-Frame-Options DENY, Referrer-Policy same-origin, dan CSP yang masih memuat 'unsafe-inline' karena skrip inline Next.js statis tidak memakai nonce. deploy/keamanan.conf:1-714
Sertifikat disediakan Tailscale dan berumur 90 hari, jadi perintah tailscale cert perlu dijadwalkan ulang setiap bulan diikuti docker compose restart web. Kunci harus bisa dibaca UID 101 karena Nginx berjalan sebagai user itu; README memakai chown 101 dan chmod 640. README.md:77-11010
stateDiagram-v2
[*] --> BelumAda
BelumAda --> Terbit: tailscale cert tls.crt tls.key
Terbit --> Terpasang: chown 101 dan chmod 640
Terpasang --> HampirKedaluwarsa: mendekati 90 hari
HampirKedaluwarsa --> Terbit: terbitkan ulang lalu restart web
Terpasang --> GagalBaca: kunci tidak terbaca UID 101
GagalBaca --> Terpasang: perbaiki izin berkas
style Terbit fill:#2d7d46,color:#fff
style Terpasang fill:#2d7d46,color:#fff
style HampirKedaluwarsa fill:#f59e0b,color:#000
style GagalBaca fill:#dc2626,color:#fff
%% fig: Siklus hidup sertifikat Tailscale untuk web
%% desc: Sertifikat terbit, dipasang dengan izin UID 101, dan diperbarui setiap mendekati 90 hari lewat terbitkan ulang plus restart web.Backup harian dengan age dan rclone
deploy/backup.sh memuat .env dengan set -a; source .env; set +a, membuat direktori sementara lewat mktemp -d, dan mendaftarkan trap 'rm -rf "$sementara"' EXIT supaya sisa berkas terhapus. deploy/backup.sh:1-2115
| Berkas keluaran | Rantai perintah | Isi |
|---|---|---|
hydro-$waktu.dump.age | docker compose exec -T db pg_dump -U $POSTGRES_USER -d $POSTGRES_DB -Fc lalu age -r $BACKUP_AGE_RECIPIENT | dump custom format yang sudah terenkripsi |
hydro-$waktu.media.tar.age | docker compose exec -T api tar -C /data -cf - media lalu age -r | arsip folder media dari volume media |
Stempel waktu memakai date -u +%Y%m%dT%H%M%SZ. Sebelum dikirim, skrip memeriksa hasilnya dengan test -s agar dump kosong tidak lolos, lalu rclone copy menyalin direktori sementara ke $BACKUP_RCLONE_TUJUAN. deploy/backup.sh:1-2115
graph LR
db[(db postgres 18.1)] -->|pg_dump -Fc| enc1[age -r BACKUP_AGE_RECIPIENT]
mediaV[(media /data)] -->|tar -C /data media| enc2[age -r BACKUP_AGE_RECIPIENT]
enc1 --> tmp[mktemp -d hydro-WAKTU.dump.age]
enc2 --> tmp
tmp --> cek{test -s lolos}
cek --> rclone[rclone copy ke BACKUP_RCLONE_TUJUAN]
rclone --> remote[(remote:hydro-backup)]
remote --> dec[age -d -i kunci-age]
stop[docker compose stop api web] --> dec
dec -->|pg_restore --clean --if-exists| db
style db fill:#336791,color:#fff
style mediaV fill:#336791,color:#fff
style remote fill:#336791,color:#fff
style rclone fill:#2d7d46,color:#fff
style enc1 fill:#f59e0b,color:#000
style enc2 fill:#f59e0b,color:#000
style dec fill:#f59e0b,color:#000
style stop fill:#dc2626,color:#fff
%% fig: Jalur backup dan pemulihan
%% desc: backup.sh mengenkripsi dump dan media lalu mengirimnya lewat rclone; pulihkan.sh mendekripsi dan menimpanya kembali ke database.Jadwal harian dipasang di cron pada pukul 19.00 dengan log ke /var/log/hydro-backup.log, persis seperti contoh di kepala skrip. deploy/backup.sh:1-2115
Pemulihan database
deploy/pulihkan.sh menerima dua argumen: berkas .dump.age dan berkas kunci age. Skrip meminta konfirmasi Ketik YA sebelum melanjutkan, menghentikan api dan web, mendekripsi dengan age -d -i, lalu menyalurkan hasilnya ke pg_restore --clean --if-exists --no-owner, dan akhirnya menyalakan kembali seluruh container. deploy/pulihkan.sh:1-1916
⚠️
pulihkan.shmenimpa isi database yang sedang aktif, karenapg_restoredijalankan dengan--clean --if-exists. Kunci privat age harus disimpan di luar VPS dan pemulihan perlu diuji di server lain secara berkala. README.md:77-11010
Variabel .env wajib
| Variabel | Nilai contoh | Dipakai oleh |
|---|---|---|
COMPOSE_FILE | compose.prod.yaml | pemilihan berkas compose |
GHCR_OWNER | nama-akun-github | nama image di web dan api |
APP_VERSION | 1.0.0 | tag image, ditulis ulang oleh rilis.sh |
TAILSCALE_IP | 100.x.y.z | binding port 443 |
CERT_DIR | ./deploy/certs | mount /etc/nginx/certs:ro |
POSTGRES_DB, POSTGRES_USER, POSTGRES_PASSWORD | hydro | db dan pg_dump |
DJANGO_SECRET_KEY | hasil secrets.token_urlsafe(50) | sesi dan CSRF |
DJANGO_ALLOWED_HOSTS, DJANGO_CSRF_TRUSTED_ORIGINS | hydro.nama-tailnet.ts.net | host Tailscale dan header Host healthcheck |
BACKUP_AGE_RECIPIENT | age1xxxxxxxx | enkripsi di backup.sh |
BACKUP_RCLONE_TUJUAN | remote:hydro-backup | tujuan rclone copy |
Berkas contohnya juga mengingatkan agar hasil salinan diberi izin chmod 600 .env, dan menyertakan NAMA_PERUSAHAAN, ALAMAT_PERUSAHAAN, serta HARI_HAMPIR_KEDALUWARSA. .env.example:8-2317
Menguji image produksi di lokal
compose.local.yaml menimpa web.ports menjadi 127.0.0.1:8443:8443 dan api.env_file menjadi .env.local, sekaligus menambahkan build untuk kedua service sehingga image bisa dirakit dari sumber setempat. compose.local.yaml:1-1218
Perintahnya docker compose -p hydro-lokal --env-file .env.local -f compose.prod.yaml -f compose.local.yaml up -d --build, dengan TAILSCALE_IP=127.0.0.1 dan DJANGO_ALLOWED_HOSTS=localhost di .env.local. README.md:112-11819