Lompat ke konten
dokumentasi.id
Commit 32b2830
v1
Star0

Kamu membaca versi lama. Halaman ini hanya bisa dibaca.

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-20

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-20

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-40

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-67

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.
Fig. 1: Topologi tiga jaringan produksiweb di edge, api di app dan data, db hanya di data; certs read-only untuk Nginx, media dan pgdata sebagai volume.
ServiceImageNetworkPengamanan dan volume
webghcr.io/${GHCR_OWNER}/hydro-web:${APP_VERSION}edge, appread_only, cap_drop: [ALL], no-new-privileges, certs:ro
apighcr.io/${GHCR_OWNER}/hydro-api:${APP_VERSION}app, dataread_only, cap_drop: [ALL], volume media di /data/media
dbpostgres:18.1data sajavolume 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-13 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-146 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-10

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-99

LangkahPerintah kunciHasil
Turunkan versiversi=${GITHUB_REF_NAME#v}tag v1.2.0 menjadi 1.2.0
Builddocker build -t "$REGISTRY/hydro-api:$versi" backendimage hydro-api
Builddocker build -t "$REGISTRY/hydro-web:$versi" -f deploy/web.Dockerfile .image hydro-web
Pindaiaquasec/trivy:0.67.2 image --exit-code 1rilis batal bila ada CRITICAL yang bisa diperbaiki
Kirimdocker 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-20 Nilai awalnya ada di .env.example. .env.example:1-6

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-13

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.
Fig. 2: Urutan satu rilis lewat rilis.shSkrip 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-13

⚠️ rilis.sh mengikat 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 dengan deploy/pulihkan.sh. README.md:77-110

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-110

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-14

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-33

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-45

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-7

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-110

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.
Fig. 3: Siklus hidup sertifikat Tailscale untuk webSertifikat 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-21

Berkas keluaranRantai perintahIsi
hydro-$waktu.dump.agedocker compose exec -T db pg_dump -U $POSTGRES_USER -d $POSTGRES_DB -Fc lalu age -r $BACKUP_AGE_RECIPIENTdump custom format yang sudah terenkripsi
hydro-$waktu.media.tar.agedocker compose exec -T api tar -C /data -cf - media lalu age -rarsip 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-21

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.
Fig. 4: Jalur backup dan pemulihanbackup.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-21

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-19

⚠️ pulihkan.sh menimpa isi database yang sedang aktif, karena pg_restore dijalankan dengan --clean --if-exists. Kunci privat age harus disimpan di luar VPS dan pemulihan perlu diuji di server lain secara berkala. README.md:77-110

Variabel .env wajib

VariabelNilai contohDipakai oleh
COMPOSE_FILEcompose.prod.yamlpemilihan berkas compose
GHCR_OWNERnama-akun-githubnama image di web dan api
APP_VERSION1.0.0tag image, ditulis ulang oleh rilis.sh
TAILSCALE_IP100.x.y.zbinding port 443
CERT_DIR./deploy/certsmount /etc/nginx/certs:ro
POSTGRES_DB, POSTGRES_USER, POSTGRES_PASSWORDhydrodb dan pg_dump
DJANGO_SECRET_KEYhasil secrets.token_urlsafe(50)sesi dan CSRF
DJANGO_ALLOWED_HOSTS, DJANGO_CSRF_TRUSTED_ORIGINShydro.nama-tailnet.ts.nethost Tailscale dan header Host healthcheck
BACKUP_AGE_RECIPIENTage1xxxxxxxxenkripsi di backup.sh
BACKUP_RCLONE_TUJUANremote:hydro-backuptujuan 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-23

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-12

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-118