Server block dari Bagian 2 sudah melayani situs, tetapi dua pekerjaan besar masih tersisa. Pertama, keamanan: trafik masih berjalan polos di HTTP, dan beberapa lapisan pelindung layak ditambahkan sebelum situs menghadap publik. Kedua, operasional: selama mengganti versi situs masih berarti menyalin berkas satu per satu ke webroot, kita selalu satu salah ketik menjauh dari situs rusak di tengah pengunjung. Bagian ini menuntaskan keduanya — sertifikat TLS lewat certbot, server block HTTPS, lalu deploy.sh dan rollback.sh yang membuat pergantian versi menjadi satu perintah yang bisa dipercaya, lengkap dengan health check dan jalan pulang otomatis.

Header Keamanan dan Sidik Jari Versi #

Empat lapisan hardening sudah tertanam di server block Bagian 2; tabel berikut merangkumannya sebelum kita lanjut ke TLS:

LapisanDirectiveYang dicegahnya
Sembunyikan versiserver_tokens offpenyerang memetakan celah spesifik versi nginx
Anti MIME-sniffingX-Content-Type-Options: nosniffberkas dieksekusi sebagai tipe lain
Anti framingX-Frame-Options: SAMEORIGINhalaman dibingkai situs lain (clickjacking)
Privasi asalReferrer-Policy: strict-origin-when-cross-originURL lengkap kita bocor ke situs pihak ketiga

Dua pasang keputusan caching ikut memperkuat: gzip mengecilkan ongkir dokumen teks, dan expires 7d di location /assets/ mengizinkan browser menyimpan aset selama tujuh hari. Amannya selama nama aset membawa sidik jari versi — gaya-a3f2.css — karena setiap rilis baru otomatis menjadi berkas baru yang belum pernah ada di cache siapa pun.

Sertifikat TLS dengan Certbot #

HTTPS membutuhkan sertifikat dari Certificate Authority, dan Let’s Encrypt memberikannya gratis lewat tool certbot. Di VPS, pasang toolnya lalu minta sertifikat dengan metode webroot — certbot menaruh berkas tantangan ke webroot kita dan membuktikan kendali atas domain:

bash
sudo apt install certbot
sudo certbot certonly --webroot -w /var/www/site/current \
    -d latihan.example.com --email [email protected] --agree-tos

Opsi -w /var/www/site/current menunjuk webroot yang sama dengan root nginx, sehingga berkas tantangan terlayani sebagai berkas statis biasa. Berikut contoh tampilan hasilnya di VPS — jam dan tanggal masa berlaku akan mengikuti eksekusi Anda:

text
Saving debug log to /var/log/letsencrypt/letsencrypt.log
Requesting a certificate for latihan.example.com

Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/latihan.example.com/fullchain.pem
Key is saved at:         /etc/letsencrypt/live/latihan.example.com/privkey.pem
This certificate expires on 2026-04-15.
These files will be updated when the certificate renews.

Pembaruan otomatis ditangani timer systemd bawaan paket (certbot.timer) — cukup pastikan systemctl list-timers | grep certbot menunjukkannya aktif. Detail alur sertifikat, ACME, dan pembaruan dibahas tuntas di Chapter TLS dan SSL Certificates .

Server Block HTTPS #

Dengan sertifikat di tempatnya, giliran konfigurasi naik kelas. Versi HTTPS memakai dua server block: port 80 yang hanya mengalihkan, dan port 443 yang melayani. Berkas lengkapnya ada sebagai config/ssl-latihan.example.com.conf pada paket proyek:

nginx
server {
    listen 80;
    listen [::]:80;
    server_name latihan.example.com;

    # Seluruh trafik HTTP digiring ke HTTPS.
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name latihan.example.com;

    # Sertifikat dan kunci dari certbot.
    ssl_certificate     /etc/letsencrypt/live/latihan.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/latihan.example.com/privkey.pem;

    # Hanya protokol yang masih dianggap aman.
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers off;

    root /var/www/site/current;
    index index.html;
    server_tokens off;

    gzip on;
    gzip_types text/css application/javascript application/json image/svg+xml;
    gzip_min_length 1024;

    add_header Strict-Transport-Security "max-age=31536000" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    location /assets/ {
        expires 7d;
    }

    location /api/ {
        proxy_pass http://127.0.0.1:8080/;
        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 / {
        try_files $uri $uri/ =404;
    }
}

Bagian-bagian barunya:

  1. return 301 https://$host$request_uri;: server block port 80 hanya punya satu tugas — mengalihkan permanen ke HTTPS dengan nama host dan path yang sama. return berhenti di situ, jadi tidak ada konfigurasi lain yang perlu dijaga di blok ini.
  2. listen 443 ssl; dan http2 on;: port HTTPS dengan TLS aktif, lalu HTTP/2 untuk klien yang mendukungnya.
  3. ssl_certificate dan ssl_certificate_key: jalur persis ke output certbot — fullchain.pem berisi sertifikat beserta rantai perantara, privkey.pem kunci privatnya.
  4. ssl_protocols TLSv1.2 TLSv1.3;: menutup pintu protokol usang; TLS 1.2 dipertahankan demi klien lama.
  5. add_header Strict-Transport-Security "max-age=31536000" always;: HSTS — perintah agar browser menolak HTTP polos selama setahun. Mulailah dengan nilai kecil di situs baru bila belum yakin; keputusan ini sulit dibatalkan dari sisi browser.

Pemasangannya mengikuti pola yang sama dengan Bagian 2: salin ke sites-available, tautkan dari sites-enabled, lalu pasangan sudo nginx -t && sudo systemctl reload nginx.

Sekarang persoalan operasionalnya. Menimpa berkas situs langsung di webroot itu cepat tetapi berbahaya: saat rsync berjalan setengah jalan, pengunjung mendapat campuran berkas versi lama dan baru — dan bila ternyata rusak, versi lama sudah tidak ada lagi untuk kembali. Pola release-folder menghapus kedua masalah itu:

text
/var/www/site/
├── releases/
│   ├── 20260115-0900/   ← versi lama: utuh, tidak pernah diubah lagi
│   │   ├── index.html
│   │   └── assets/
│   └── 20260115-0930/   ← versi baru: salinan artifact yang sudah diverifikasi
│       ├── index.html
│       └── assets/
├── shared/
└── current -> releases/20260115-0930

Setiap deployment menyalin artifact build ke folder releases/STAMP yang baru — versi lama tidak pernah disentuh. Nama STAMP berformat YYYYmmdd-HHMMSS membuat urutan nama sama dengan urutan waktu, fakta yang nanti dipakai rollback.sh. Versi yang dilayani ditentukan oleh satu symlink: current. Menggesernya adalah operasi ln -sfn yang selesai dalam satu tarikan napas, dan mengembalikannya sama mudahnya — inilah “rollback instan” yang dijanjikan arsitektur ini. nginx tidak pernah reload pun tidak: baginya root menunjuk ke current, dan symlink dibaca ulang pada setiap permintaan.

deploy.sh dan rollback.sh #

Dua skrip berikut menjahit pola itu menjadi satu perintah. Blok besar di bawah memuat keduanya lengkap (identik dengan deploy/deploy.sh dan deploy/rollback.sh pada paket proyek), menyiapkan konfigurasi lokal deploy.conf untuk server latihan di port 8765, lalu memainkan seluruh siklusnya sungguhan: deploy v1, deploy v2, verifikasi current bergeser, rollback manual, dan deploy yang gagal health check hingga rollback otomatisnya:

bash
mkdir -p site/releases
cat > deploy.conf <<'CONF'
SITE_DIR="site"
HEALTH_URL="http://127.0.0.1:8765/current/"
HEALTH_RETRIES=3
HEALTH_DELAY=1
CONF

cat > deploy.sh <<'BASH'
#!/usr/bin/env bash
# deploy.sh — deployment dengan release folder, symlink current, dan
# health check.
#
# Alur satu eksekusi:
#   1. baca dan validasi konfigurasi (deploy.conf)
#   2. validasi artifact build (index.html wajib ada)
#   3. salin build ke releases/STAMP lalu verifikasi salinannya
#   4. geser symlink current ke release baru
#   5. health check dengan retry; gagal -> rollback otomatis ke
#      release sebelumnya dan release baru dihapus
#
# Variabel lingkungan (per eksekusi):
#   CONF       berkas konfigurasi yang dibaca (default: deploy.conf)
#   BUILD_DIR  folder artifact yang dideploy (default: build)
#   STAMP      identitas release (default: waktu sekarang, YYYYmmdd-HHMMSS)
#
# Kode keluar:
#   0 sukses                          1 konfigurasi tidak valid
#   2 artifact build tidak siap       3 release gagal disalin/diverifikasi
#   4 health check gagal (current sudah dikembalikan ke release lama)

set -u

CONF="${CONF:-deploy.conf}"
BUILD_DIR="${BUILD_DIR:-build}"
STAMP="${STAMP:-$(date +%Y%m%d-%H%M%S)}"

# log(): tulis satu baris beridentitas release ke stdout.
log() {
    printf '[%s] %s\n' "$STAMP" "$1"
}

# die(): catat kegagalan lewat log, lalu keluar dengan kode tertentu.
die() {
    log "GAGAL: $1 (kode $2)"
    exit "$2"
}

# --- 1. Baca dan validasi konfigurasi ---------------------------------
# Pesan tahap ini keluar lewat stderr: gagal di sini berarti skrip
# belum boleh menyentuh folder site sama sekali.
if [ ! -f "$CONF" ]; then
    printf 'deploy.sh: berkas konfigurasi %s tidak ditemukan\n' "$CONF" >&2
    exit 1
fi

# shellcheck source=/dev/null
. "$CONF"

if [ -z "${SITE_DIR:-}" ]; then
    printf 'deploy.sh: SITE_DIR belum diisi di %s\n' "$CONF" >&2
    exit 1
fi
if [ -z "${HEALTH_URL:-}" ]; then
    printf 'deploy.sh: HEALTH_URL belum diisi di %s\n' "$CONF" >&2
    exit 1
fi
case ${HEALTH_RETRIES:-} in
    ''|*[!0-9]*|0)
        printf 'deploy.sh: HEALTH_RETRIES harus angka 1 atau lebih\n' >&2
        exit 1
        ;;
esac
case ${HEALTH_DELAY:-} in
    ''|*[!0-9]*)
        printf 'deploy.sh: HEALTH_DELAY harus angka 0 atau lebih\n' >&2
        exit 1
        ;;
esac

# --- 2. Validasi artifact build ----------------------------------------
[ -d "$BUILD_DIR" ] || die "folder build tidak ditemukan: $BUILD_DIR" 2
[ -f "$BUILD_DIR/index.html" ] || die "index.html tidak ada di $BUILD_DIR" 2

# --- 3. Siapkan struktur site dan release baru -------------------------
mkdir -p "$SITE_DIR/releases" || exit 1
RELEASE="$SITE_DIR/releases/$STAMP"
if [ -e "$RELEASE" ]; then
    die "release $STAMP sudah ada; pakai STAMP yang berbeda" 1
fi

# Target symlink saat ini disimpan dulu: inilah tujuan rollback bila
# health check release baru gagal.
PREV=""
if [ -L "$SITE_DIR/current" ]; then
    PREV=$(readlink "$SITE_DIR/current")
fi

# --- 4. Salin artifact lalu verifikasi salinannya -----------------------
if ! cp -a "$BUILD_DIR" "$RELEASE"; then
    die "gagal menyalin $BUILD_DIR ke $RELEASE" 3
fi
if ! diff -r "$BUILD_DIR" "$RELEASE" > /dev/null; then
    rm -rf "$RELEASE"
    die "salinan release berbeda dari build" 3
fi
log "release tersalin: $RELEASE"

# --- 5. Geser symlink current ke release baru ---------------------------
# Target dibuat relatif terhadap folder site supaya struktur bisa
# dipindahkan tanpa memutus tautan.
ln -sfn "releases/$STAMP" "$SITE_DIR/current"
log "current -> releases/$STAMP"

# --- 6. Health check dengan retry ---------------------------------------
sehat=0
for percobaan in $(seq 1 "$HEALTH_RETRIES"); do
    if curl -fsS -o /dev/null "$HEALTH_URL" 2> /dev/null; then
        sehat=1
        break
    fi
    sleep "$HEALTH_DELAY"
done

if [ "$sehat" -ne 0 ]; then
    log "health check OK: $HEALTH_URL"
    log "selesai tanpa error"
    exit 0
fi

# --- 7. Health check gagal: rollback otomatis ---------------------------
# Release yang tidak sehat dihapus supaya rollback di masa depan
# tidak pernah mendarat di release rusak.
if [ -n "$PREV" ]; then
    ln -sfn "$PREV" "$SITE_DIR/current"
    log "health check gagal; current dikembalikan ke $PREV"
else
    rm -f "$SITE_DIR/current"
    log "health check gagal; tidak ada release sebelumnya; current dihapus"
fi
rm -rf "$RELEASE"
die "health check gagal setelah $HEALTH_RETRIES percobaan: $HEALTH_URL" 4
BASH

cat > rollback.sh <<'BASH'
#!/usr/bin/env bash
# rollback.sh — kembalikan symlink current ke release sebelumnya.
#
# Release diurutkan menurut nama: STAMP berformat YYYYmmdd-HHMMSS
# terurut sama dengan urutan waktunya. current dipindah ke release
# tepat di bawahnya, lalu health check memastikan release lama itu
# benar-benar sehat; bila tidak, current dikembalikan seperti semula.
#
# Variabel lingkungan:
#   CONF   berkas konfigurasi yang dibaca (default: deploy.conf)
#
# Kode keluar:
#   0 sukses                          1 konfigurasi tidak valid
#   2 tidak ada release lebih tua     3 health check gagal

set -u

CONF="${CONF:-deploy.conf}"

# log(): tulis satu baris beridentitas rollback ke stdout.
log() {
    printf '[rollback] %s\n' "$1"
}

# die(): catat kegagalan lewat log, lalu keluar dengan kode tertentu.
die() {
    log "GAGAL: $1 (kode $2)"
    exit "$2"
}

# --- 1. Baca dan validasi konfigurasi ---------------------------------
if [ ! -f "$CONF" ]; then
    printf 'rollback.sh: berkas konfigurasi %s tidak ditemukan\n' "$CONF" >&2
    exit 1
fi

# shellcheck source=/dev/null
. "$CONF"

if [ -z "${SITE_DIR:-}" ]; then
    printf 'rollback.sh: SITE_DIR belum diisi di %s\n' "$CONF" >&2
    exit 1
fi
if [ -z "${HEALTH_URL:-}" ]; then
    printf 'rollback.sh: HEALTH_URL belum diisi di %s\n' "$CONF" >&2
    exit 1
fi
case ${HEALTH_RETRIES:-} in
    ''|*[!0-9]*|0)
        printf 'rollback.sh: HEALTH_RETRIES harus angka 1 atau lebih\n' >&2
        exit 1
        ;;
esac
case ${HEALTH_DELAY:-} in
    ''|*[!0-9]*)
        printf 'rollback.sh: HEALTH_DELAY harus angka 0 atau lebih\n' >&2
        exit 1
        ;;
esac

# --- 2. Temukan release saat ini dan release di bawahnya ---------------
RELEASES="$SITE_DIR/releases"
[ -d "$RELEASES" ] || die "folder releases tidak ditemukan: $RELEASES" 1

KINI=""
if [ -L "$SITE_DIR/current" ]; then
    KINI=$(basename "$(readlink "$SITE_DIR/current")")
fi
[ -n "$KINI" ] || die "current belum menunjuk release mana pun" 1

# awk menyimpan nama release terakhir yang lebih kecil dari current;
# karena daftar sudah terurut, itulah release tepat di bawahnya.
LAMA=$(ls -1 "$RELEASES" |
    awk -v kini="$KINI" '$0 < kini { lama = $0 } END { print lama }')
[ -n "$LAMA" ] || die "tidak ada release lebih tua dari $KINI" 2

# --- 3. Geser current, lalu health check --------------------------------
ln -sfn "releases/$LAMA" "$SITE_DIR/current"
log "current -> releases/$LAMA"

sehat=0
for percobaan in $(seq 1 "$HEALTH_RETRIES"); do
    if curl -fsS -o /dev/null "$HEALTH_URL" 2> /dev/null; then
        sehat=1
        break
    fi
    sleep "$HEALTH_DELAY"
done

if [ "$sehat" -eq 0 ]; then
    ln -sfn "releases/$KINI" "$SITE_DIR/current"
    log "health check gagal; current dikembalikan ke $KINI"
    die "health check gagal setelah rollback ke $LAMA" 3
fi

log "health check OK: $HEALTH_URL"
log "selesai tanpa error"
exit 0
BASH

mkdir -p build-v1/assets build-v2/assets
printf '<h1>Situs v1</h1>\n' > build-v1/index.html
printf 'body { color: #333; }\n' > build-v1/assets/gaya.css
printf '<h1>Situs v2</h1>\n' > build-v2/index.html
printf 'body { color: #111; }\n' > build-v2/assets/gaya.css

python3 -m http.server 8765 --bind 127.0.0.1 --directory site >/dev/null 2>&1 &
server=$!
for i in 1 2 3 4 5; do curl -fsS -o /dev/null http://127.0.0.1:8765/ 2>/dev/null && break; sleep 0.2; done

echo "== deploy v1 =="
BUILD_DIR=build-v1 STAMP=20260115-0900 bash deploy.sh
echo "current -> $(readlink site/current)"
curl -s http://127.0.0.1:8765/current/

echo "== deploy v2 =="
BUILD_DIR=build-v2 STAMP=20260115-0930 bash deploy.sh
echo "current -> $(readlink site/current)"
curl -s http://127.0.0.1:8765/current/

echo "== rollback manual =="
bash rollback.sh
echo "current -> $(readlink site/current)"
curl -s http://127.0.0.1:8765/current/

echo "== deploy v3 saat server mati: health check gagal =="
kill "$server"
wait "$server" 2>/dev/null
BUILD_DIR=build-v2 STAMP=20260115-1030 bash deploy.sh
echo "exit=$?"
echo "current -> $(readlink site/current)"
echo "== release yang tersisa =="
ls -1 site/releases

Output yang Dihasilkan:

text
== deploy v1 ==
[20260115-0900] release tersalin: site/releases/20260115-0900
[20260115-0900] current -> releases/20260115-0900
[20260115-0900] health check OK: http://127.0.0.1:8765/current/
[20260115-0900] selesai tanpa error
current -> releases/20260115-0900
<h1>Situs v1</h1>
== deploy v2 ==
[20260115-0930] release tersalin: site/releases/20260115-0930
[20260115-0930] current -> releases/20260115-0930
[20260115-0930] health check OK: http://127.0.0.1:8765/current/
[20260115-0930] selesai tanpa error
current -> releases/20260115-0930
<h1>Situs v2</h1>
== rollback manual ==
[rollback] current -> releases/20260115-0900
[rollback] health check OK: http://127.0.0.1:8765/current/
[rollback] selesai tanpa error
current -> releases/20260115-0900
<h1>Situs v1</h1>
== deploy v3 saat server mati: health check gagal ==
[20260115-1030] release tersalin: site/releases/20260115-1030
[20260115-1030] current -> releases/20260115-1030
[20260115-1030] health check gagal; current dikembalikan ke releases/20260115-0900
[20260115-1030] GAGAL: health check gagal setelah 3 percobaan: http://127.0.0.1:8765/current/ (kode 4)
exit=4
current -> releases/20260115-0900
== release yang tersisa ==
20260115-0900
20260115-0930

Bacaan outputnya dulu, barulah bedah skripnya. Empat babak == == itu masing-masing meninggalkan jejak: setelah deploy v1 dan v2, readlink site/current (paman ls -l site/current menampilkan hal yang sama, lengkap dengan waktu pembuatan symlink) melaporkan releases/20260115-0900 lalu releases/20260115-0930, dan curl menyajikan <h1>Situs v1</h1> lalu <h1>Situs v2</h1> — bukti bahwa yang dilayani server ikut bergeser. Rollback manual memutar semuanya balik. Babak terakhir sengaja membunuh server latihan lebih dulu: deploy v3 menyalin berkasnya, menggeser current, lalu health check gagal tiga kali — dan skrip mengembalikan current ke releases/20260115-0900 sendirian, keluar kode 4, dan menghapus release yang tidak sehat sehingga ls -1 site/releases hanya menampilkan dua release yang terbukti hidup.

Keputusan-keputusan penting di dalam deploy.sh:

  1. Tahap 1 — pemisahan parameter: nilai yang berubah tiap eksekusi (BUILD_DIR, STAMP) datang lewat variabel lingkungan dengan nilai bawaan, sedangkan sifat site (SITE_DIR, HEALTH_URL, HEALTH_RETRIES, HEALTH_DELAY) tinggal di deploy.conf. Pola ${VAR:-bawaan} inilah yang membuat BUILD_DIR=build-v2 STAMP=20260115-0930 bash deploy.sh bisa men-deploy versi mana pun tanpa menyunting apa pun.
  2. Tahap 1 juga — urutan gagal yang aman: seluruh validasi konfigurasi selesai sebelum satu berkas pun tersentuh, dan pesannya keluar lewat stderr.
  3. Tahap 3 — PREV=$(readlink ...): target symlink lama disimpan sebelum apa pun digeser. Tanpa potongan ini, saat health check gagal kita tidak tahu harus kembali ke mana.
  4. Tahap 4 — cp -a lalu diff -r: artifact disalin dengan atributnya, lalu salinannya dibandingkan utuh dengan sumbernya. Release yang gagal diverifikasi langsung dihapus — tidak ada setengah-release di releases/.
  5. Tahap 5 — ln -sfn "releases/$STAMP": penggeseran satu atom. Opsi -n penting saat current sudah ada: tanpanya ln -s membuat tautan di dalam folder tujuan, bukan menggantikannya.
  6. Tahap 6 — curl -fsS dengan retry: opsi -f membuat kode HTTP 4xx/5xx dianggap gagal, bukan hanya error jaringan; jeda HEALTH_DELAY memberi server napas untuk bangun. Health check menanyai current melalui HTTP sungguhan — persis pertanyaan yang akan diajukan nginx di VPS.
  7. Tahap 7 — hapus release yang tidak sehat: setelah current dikembalikan ke PREV, release baru dihapus. Keputusan ini menjaga janji penting: folder di releases/ hanya berisi versi yang pernah terbukti menjawab 200, sehingga rollback tidak pernah mendarat di versi rusak.

rollback.sh sendiri memegang satu trik: ls -1 "$RELEASES" | awk -v kini="$KINI" '$0 < kini { lama = $0 } END { print lama }' memilih release tepat di bawah current — setiap nama yang lebih kecil dari KINI menimpa lama, dan karena daftar ls terurut, nilai akhirnya adalah nama terbesar yang masih lebih kecil, alias satu tingkat di bawahnya. Health check tetap dijalankan sesudah menggeser; bila release lama ternyata juga bermasalah, current dikembalikan ke KINI dan skrip keluar kode 3 — kondisi terburuk yang dilaporkannya jujur, bukan didiamkan.

Test Suite #

Seluruh klaim di atas kini dibungkus menjadi test suite bash — skrip yang menyiapkan folder kerja terisolasinya sendiri, menjalankan server latihan, lalu memeriksa assertion demi assertion. Blok berikut merakit tata letak proyek lengkap (config/, deploy/, tests/) dengan berkas-berkas yang sama persis dengan paket proyek, lalu menjalankan suitenya:

bash
mkdir -p proyek/config proyek/deploy proyek/tests proyek/docs
cat > proyek/config/latihan.example.com.conf <<'NGINX'
# latihan.example.com.conf — server block nginx untuk situs latihan.
# Situs statis (root + try_files) + reverse proxy /api/ ke backend
# lokal + kompresi gzip + header keamanan + server_tokens off.
#
# Penempatan di VPS:
#   sudo cp latihan.example.com.conf /etc/nginx/sites-available/
#   sudo ln -s /etc/nginx/sites-available/latihan.example.com.conf \
#              /etc/nginx/sites-enabled/
#   sudo nginx -t && sudo systemctl reload nginx

server {
    listen 80;
    listen [::]:80;
    server_name latihan.example.com;

    # Akar dokumen menunjuk ke symlink "current" yang digeser oleh
    # deploy.sh — pergantian release tidak menyentuh konfigurasi ini.
    root /var/www/site/current;
    index index.html;

    # Jangan umumkan versi nginx di header balasan dan halaman error.
    server_tokens off;

    # Kompresi untuk tipe dokumen yang paling sering diminta.
    gzip on;
    gzip_types text/css application/javascript application/json image/svg+xml;
    gzip_min_length 1024;

    # Header keamanan dasar untuk semua balasan.
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    # Aset membawa sidik jari versi pada namanya: aman di-cache lama.
    location /assets/ {
        expires 7d;
    }

    # /api/ diteruskan ke backend di loopback. Garis miring di ujung
    # proxy_pass membuat /api/halo tiba di backend sebagai /halo.
    location /api/ {
        proxy_pass http://127.0.0.1:8080/;
        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;
    }

    # Permintaan lain: coba berkas, lalu folder, lalu 404.
    location / {
        try_files $uri $uri/ =404;
    }
}
NGINX
cat > proyek/config/ssl-latihan.example.com.conf <<'NGINX'
# ssl-latihan.example.com.conf — varian HTTPS untuk server block.
# Sertifikat dari certbot (Let's Encrypt); port 80 hanya mengalihkan.
# Gantikan latihan.example.com.conf yang aktif dengan berkas ini
# setelah sertifikat terpasang.

server {
    listen 80;
    listen [::]:80;
    server_name latihan.example.com;

    # Seluruh trafik HTTP digiring ke HTTPS.
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name latihan.example.com;

    # Sertifikat dan kunci dari certbot.
    ssl_certificate     /etc/letsencrypt/live/latihan.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/latihan.example.com/privkey.pem;

    # Hanya protokol yang masih dianggap aman.
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers off;

    root /var/www/site/current;
    index index.html;
    server_tokens off;

    gzip on;
    gzip_types text/css application/javascript application/json image/svg+xml;
    gzip_min_length 1024;

    # Header keamanan: HSTS khusus situs HTTPS, sisanya sama dengan
    # versi HTTP.
    add_header Strict-Transport-Security "max-age=31536000" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    location /assets/ {
        expires 7d;
    }

    location /api/ {
        proxy_pass http://127.0.0.1:8080/;
        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 / {
        try_files $uri $uri/ =404;
    }
}
NGINX
cat > proyek/config/deploy.conf <<'CONF'
# deploy.conf — konfigurasi skrip deployment.
# Sunting nilai di bawah ini; deploy.sh dan rollback.sh tidak perlu
# disentuh. Jalur relatif dihitung dari direktori kerja saat skrip
# dijalankan; jalur absolut (mis. /var/www/site) juga boleh dipakai.

# Folder site: berisi releases/ dan symlink current.
SITE_DIR="/var/www/site"

# URL health check: harus menjawab 200 setelah release aktif.
# Di VPS nyata (nginx di port 80):      http://localhost/
# Saat latihan lokal (server latihan):  http://127.0.0.1:8765/current/
HEALTH_URL="http://localhost/"

# Berapa kali health check dicoba sebelum dinyatakan gagal.
HEALTH_RETRIES=3

# Jeda antar percobaan health check, dalam detik.
HEALTH_DELAY=1

# Catatan: folder artifact dibawa lewat variabel lingkungan BUILD_DIR
# (default: build) karena berbeda pada tiap eksekusi, bukan sifat site.
CONF
cat > proyek/deploy/deploy.sh <<'BASH'
#!/usr/bin/env bash
# deploy.sh — deployment dengan release folder, symlink current, dan
# health check.
#
# Alur satu eksekusi:
#   1. baca dan validasi konfigurasi (deploy.conf)
#   2. validasi artifact build (index.html wajib ada)
#   3. salin build ke releases/STAMP lalu verifikasi salinannya
#   4. geser symlink current ke release baru
#   5. health check dengan retry; gagal -> rollback otomatis ke
#      release sebelumnya dan release baru dihapus
#
# Variabel lingkungan (per eksekusi):
#   CONF       berkas konfigurasi yang dibaca (default: deploy.conf)
#   BUILD_DIR  folder artifact yang dideploy (default: build)
#   STAMP      identitas release (default: waktu sekarang, YYYYmmdd-HHMMSS)
#
# Kode keluar:
#   0 sukses                          1 konfigurasi tidak valid
#   2 artifact build tidak siap       3 release gagal disalin/diverifikasi
#   4 health check gagal (current sudah dikembalikan ke release lama)

set -u

CONF="${CONF:-deploy.conf}"
BUILD_DIR="${BUILD_DIR:-build}"
STAMP="${STAMP:-$(date +%Y%m%d-%H%M%S)}"

# log(): tulis satu baris beridentitas release ke stdout.
log() {
    printf '[%s] %s\n' "$STAMP" "$1"
}

# die(): catat kegagalan lewat log, lalu keluar dengan kode tertentu.
die() {
    log "GAGAL: $1 (kode $2)"
    exit "$2"
}

# --- 1. Baca dan validasi konfigurasi ---------------------------------
# Pesan tahap ini keluar lewat stderr: gagal di sini berarti skrip
# belum boleh menyentuh folder site sama sekali.
if [ ! -f "$CONF" ]; then
    printf 'deploy.sh: berkas konfigurasi %s tidak ditemukan\n' "$CONF" >&2
    exit 1
fi

# shellcheck source=/dev/null
. "$CONF"

if [ -z "${SITE_DIR:-}" ]; then
    printf 'deploy.sh: SITE_DIR belum diisi di %s\n' "$CONF" >&2
    exit 1
fi
if [ -z "${HEALTH_URL:-}" ]; then
    printf 'deploy.sh: HEALTH_URL belum diisi di %s\n' "$CONF" >&2
    exit 1
fi
case ${HEALTH_RETRIES:-} in
    ''|*[!0-9]*|0)
        printf 'deploy.sh: HEALTH_RETRIES harus angka 1 atau lebih\n' >&2
        exit 1
        ;;
esac
case ${HEALTH_DELAY:-} in
    ''|*[!0-9]*)
        printf 'deploy.sh: HEALTH_DELAY harus angka 0 atau lebih\n' >&2
        exit 1
        ;;
esac

# --- 2. Validasi artifact build ----------------------------------------
[ -d "$BUILD_DIR" ] || die "folder build tidak ditemukan: $BUILD_DIR" 2
[ -f "$BUILD_DIR/index.html" ] || die "index.html tidak ada di $BUILD_DIR" 2

# --- 3. Siapkan struktur site dan release baru -------------------------
mkdir -p "$SITE_DIR/releases" || exit 1
RELEASE="$SITE_DIR/releases/$STAMP"
if [ -e "$RELEASE" ]; then
    die "release $STAMP sudah ada; pakai STAMP yang berbeda" 1
fi

# Target symlink saat ini disimpan dulu: inilah tujuan rollback bila
# health check release baru gagal.
PREV=""
if [ -L "$SITE_DIR/current" ]; then
    PREV=$(readlink "$SITE_DIR/current")
fi

# --- 4. Salin artifact lalu verifikasi salinannya -----------------------
if ! cp -a "$BUILD_DIR" "$RELEASE"; then
    die "gagal menyalin $BUILD_DIR ke $RELEASE" 3
fi
if ! diff -r "$BUILD_DIR" "$RELEASE" > /dev/null; then
    rm -rf "$RELEASE"
    die "salinan release berbeda dari build" 3
fi
log "release tersalin: $RELEASE"

# --- 5. Geser symlink current ke release baru ---------------------------
# Target dibuat relatif terhadap folder site supaya struktur bisa
# dipindahkan tanpa memutus tautan.
ln -sfn "releases/$STAMP" "$SITE_DIR/current"
log "current -> releases/$STAMP"

# --- 6. Health check dengan retry ---------------------------------------
sehat=0
for percobaan in $(seq 1 "$HEALTH_RETRIES"); do
    if curl -fsS -o /dev/null "$HEALTH_URL" 2> /dev/null; then
        sehat=1
        break
    fi
    sleep "$HEALTH_DELAY"
done

if [ "$sehat" -ne 0 ]; then
    log "health check OK: $HEALTH_URL"
    log "selesai tanpa error"
    exit 0
fi

# --- 7. Health check gagal: rollback otomatis ---------------------------
# Release yang tidak sehat dihapus supaya rollback di masa depan
# tidak pernah mendarat di release rusak.
if [ -n "$PREV" ]; then
    ln -sfn "$PREV" "$SITE_DIR/current"
    log "health check gagal; current dikembalikan ke $PREV"
else
    rm -f "$SITE_DIR/current"
    log "health check gagal; tidak ada release sebelumnya; current dihapus"
fi
rm -rf "$RELEASE"
die "health check gagal setelah $HEALTH_RETRIES percobaan: $HEALTH_URL" 4
BASH
cat > proyek/deploy/rollback.sh <<'BASH'
#!/usr/bin/env bash
# rollback.sh — kembalikan symlink current ke release sebelumnya.
#
# Release diurutkan menurut nama: STAMP berformat YYYYmmdd-HHMMSS
# terurut sama dengan urutan waktunya. current dipindah ke release
# tepat di bawahnya, lalu health check memastikan release lama itu
# benar-benar sehat; bila tidak, current dikembalikan seperti semula.
#
# Variabel lingkungan:
#   CONF   berkas konfigurasi yang dibaca (default: deploy.conf)
#
# Kode keluar:
#   0 sukses                          1 konfigurasi tidak valid
#   2 tidak ada release lebih tua     3 health check gagal

set -u

CONF="${CONF:-deploy.conf}"

# log(): tulis satu baris beridentitas rollback ke stdout.
log() {
    printf '[rollback] %s\n' "$1"
}

# die(): catat kegagalan lewat log, lalu keluar dengan kode tertentu.
die() {
    log "GAGAL: $1 (kode $2)"
    exit "$2"
}

# --- 1. Baca dan validasi konfigurasi ---------------------------------
if [ ! -f "$CONF" ]; then
    printf 'rollback.sh: berkas konfigurasi %s tidak ditemukan\n' "$CONF" >&2
    exit 1
fi

# shellcheck source=/dev/null
. "$CONF"

if [ -z "${SITE_DIR:-}" ]; then
    printf 'rollback.sh: SITE_DIR belum diisi di %s\n' "$CONF" >&2
    exit 1
fi
if [ -z "${HEALTH_URL:-}" ]; then
    printf 'rollback.sh: HEALTH_URL belum diisi di %s\n' "$CONF" >&2
    exit 1
fi
case ${HEALTH_RETRIES:-} in
    ''|*[!0-9]*|0)
        printf 'rollback.sh: HEALTH_RETRIES harus angka 1 atau lebih\n' >&2
        exit 1
        ;;
esac
case ${HEALTH_DELAY:-} in
    ''|*[!0-9]*)
        printf 'rollback.sh: HEALTH_DELAY harus angka 0 atau lebih\n' >&2
        exit 1
        ;;
esac

# --- 2. Temukan release saat ini dan release di bawahnya ---------------
RELEASES="$SITE_DIR/releases"
[ -d "$RELEASES" ] || die "folder releases tidak ditemukan: $RELEASES" 1

KINI=""
if [ -L "$SITE_DIR/current" ]; then
    KINI=$(basename "$(readlink "$SITE_DIR/current")")
fi
[ -n "$KINI" ] || die "current belum menunjuk release mana pun" 1

# awk menyimpan nama release terakhir yang lebih kecil dari current;
# karena daftar sudah terurut, itulah release tepat di bawahnya.
LAMA=$(ls -1 "$RELEASES" |
    awk -v kini="$KINI" '$0 < kini { lama = $0 } END { print lama }')
[ -n "$LAMA" ] || die "tidak ada release lebih tua dari $KINI" 2

# --- 3. Geser current, lalu health check --------------------------------
ln -sfn "releases/$LAMA" "$SITE_DIR/current"
log "current -> releases/$LAMA"

sehat=0
for percobaan in $(seq 1 "$HEALTH_RETRIES"); do
    if curl -fsS -o /dev/null "$HEALTH_URL" 2> /dev/null; then
        sehat=1
        break
    fi
    sleep "$HEALTH_DELAY"
done

if [ "$sehat" -eq 0 ]; then
    ln -sfn "releases/$KINI" "$SITE_DIR/current"
    log "health check gagal; current dikembalikan ke $KINI"
    die "health check gagal setelah rollback ke $LAMA" 3
fi

log "health check OK: $HEALTH_URL"
log "selesai tanpa error"
exit 0
BASH
cat > proyek/tests/run-tests.sh <<'TESTS'
#!/usr/bin/env bash
# run-tests.sh — test suite untuk proyek server web.
# Suite menyiapkan folder kerja terisolasi (mktemp) beserta data
# contohnya, menjalankan python3 -m http.server sebagai server
# latihan pengganti nginx, lalu menguji: directive wajib pada berkas
# konfigurasi nginx, alur deploy (release + symlink + health check),
# rollback manual, dan rollback otomatis saat health check gagal.
# Jalankan dari mana saja: bash tests/run-tests.sh

set -u

SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
PROJ_DIR=$(dirname "$SCRIPT_DIR")

PASS=0
FAIL=0
WORK=""
SERVER=""

# assert_eq <harapan> <nyata> <pesan>: bandingkan dua nilai.
assert_eq() {
    if [ "$1" = "$2" ]; then
        PASS=$((PASS + 1))
        printf 'OK    %s\n' "$3"
    else
        FAIL=$((FAIL + 1))
        printf 'GAGAL %s: harapan %s, nyata %s\n' "$3" "$1" "$2"
    fi
}

# assert_ada <pola> <berkas> <pesan>: pola harus ditemukan di berkas.
assert_ada() {
    if grep -qF -- "$1" "$2"; then
        PASS=$((PASS + 1))
        printf 'OK    %s\n' "$3"
    else
        FAIL=$((FAIL + 1))
        printf 'GAGAL %s: pola tidak ditemukan di %s\n' "$3" "$2"
    fi
}

bersihkan() {
    if [ -n "$SERVER" ]; then
        kill "$SERVER" 2> /dev/null
    fi
    if [ -n "$WORK" ]; then
        rm -rf "$WORK"
    fi
}
trap bersihkan EXIT

# --- uji 1: konfigurasi nginx memuat directive wajib ---------------------
CONF_UTAMA="$PROJ_DIR/config/latihan.example.com.conf"
CONF_TLS="$PROJ_DIR/config/ssl-latihan.example.com.conf"
for pola in 'server {' 'listen 80' 'server_name latihan.example.com' \
    'root /var/www/site/current' 'index index.html' \
    'try_files $uri $uri/ =404' 'gzip on' 'location /api/' \
    'proxy_pass http://127.0.0.1:8080/' 'server_tokens off' \
    'X-Content-Type-Options' 'X-Frame-Options' 'Referrer-Policy'; do
    assert_ada "$pola" "$CONF_UTAMA" "server block memuat: $pola"
done
for pola in 'listen 443 ssl' 'ssl_certificate ' \
    'return 301 https://$host$request_uri' 'Strict-Transport-Security'; do
    assert_ada "$pola" "$CONF_TLS" "server block TLS memuat: $pola"
done
for kunci in SITE_DIR HEALTH_URL HEALTH_RETRIES; do
    assert_ada "$kunci" "$PROJ_DIR/config/deploy.conf" \
        "deploy.conf memuat $kunci"
done

# --- siapkan folder kerja terisolasi --------------------------------------
WORK=$(mktemp -d)
cp "$PROJ_DIR/deploy/deploy.sh" "$PROJ_DIR/deploy/rollback.sh" "$WORK/"
cd "$WORK" || exit 1

# --- uji 2: server latihan siap -------------------------------------------
# Struktur site dibuat dulu: di VPS, nginx sudah berjalan sebelum
# deploy pertama — server latihan kita pun harus demikian.
mkdir -p site/releases
python3 -m http.server 8765 --bind 127.0.0.1 --directory site \
    > /dev/null 2>&1 &
SERVER=$!
siap=0
for i in 1 2 3 4 5 6 7 8 9 10; do
    if curl -fsS -o /dev/null http://127.0.0.1:8765/ 2> /dev/null; then
        siap=1
        break
    fi
    sleep 0.2
done
assert_eq 1 "$siap" "server latihan siap di port 8765"

# --- uji 3: deploy pertama sukses ------------------------------------------
cat > deploy.conf <<'CONF'
SITE_DIR="site"
HEALTH_URL="http://127.0.0.1:8765/current/"
HEALTH_RETRIES=3
HEALTH_DELAY=0
CONF
mkdir -p build-v1/assets build-v2/assets
printf '<h1>Situs v1</h1>\n' > build-v1/index.html
printf 'body { color: #333; }\n' > build-v1/assets/gaya.css
printf '<h1>Situs v2</h1>\n' > build-v2/index.html
printf 'body { color: #111; }\n' > build-v2/assets/gaya.css

BUILD_DIR=build-v1 STAMP=20260115-0900 bash deploy.sh > /dev/null 2>&1
assert_eq 0 "$?" "deploy v1 keluar 0"
[ -d site/releases/20260115-0900 ]
assert_eq 0 "$?" "release v1 dibuat"
assert_eq "releases/20260115-0900" "$(readlink site/current)" \
    "current menunjuk release v1"
assert_eq '<h1>Situs v1</h1>' "$(curl -s http://127.0.0.1:8765/current/)" \
    "konten tersaji: v1"

# --- uji 4: deploy kedua menggeser current --------------------------------
BUILD_DIR=build-v2 STAMP=20260115-0930 bash deploy.sh > /dev/null 2>&1
assert_eq 0 "$?" "deploy v2 keluar 0"
assert_eq "releases/20260115-0930" "$(readlink site/current)" \
    "current bergeser ke v2"
assert_eq '<h1>Situs v2</h1>' "$(curl -s http://127.0.0.1:8765/current/)" \
    "konten tersaji: v2"
[ -d site/releases/20260115-0900 ]
assert_eq 0 "$?" "release v1 tidak terhapus"

# --- uji 5: rollback manual memulihkan v1 ---------------------------------
bash rollback.sh > /dev/null 2>&1
assert_eq 0 "$?" "rollback keluar 0"
assert_eq "releases/20260115-0900" "$(readlink site/current)" \
    "current kembali ke v1"
assert_eq '<h1>Situs v1</h1>' "$(curl -s http://127.0.0.1:8765/current/)" \
    "konten kembali v1"

# --- uji 6: health check gagal -> rollback otomatis -----------------------
# Deploy ketiga agar ada dua release sehat; server lalu dimatikan
# supaya health check deploy keempat pasti gagal.
BUILD_DIR=build-v2 STAMP=20260115-1000 bash deploy.sh > /dev/null 2>&1
assert_eq 0 "$?" "deploy ketiga keluar 0"
kill "$SERVER" 2> /dev/null
wait "$SERVER" 2> /dev/null
SERVER=""
BUILD_DIR=build-v2 STAMP=20260115-1030 bash deploy.sh > /dev/null 2>&1
assert_eq 4 "$?" "health check gagal keluar 4"
assert_eq "releases/20260115-1000" "$(readlink site/current)" \
    "current kembali ke release sehat"
[ ! -d site/releases/20260115-1030 ]
assert_eq 0 "$?" "release gagal dihapus"

# --- uji 7: exit code konfigurasi dan artifact ------------------------------
CONF=halang.conf bash deploy.sh > /dev/null 2>&1
assert_eq 1 "$?" "konfigurasi hilang keluar 1"
BUILD_DIR=tidak-ada STAMP=20260115-1100 bash deploy.sh > /dev/null 2>&1
assert_eq 2 "$?" "artifact hilang keluar 2"

# --- uji 8: rollback tanpa release lebih tua -------------------------------
mkdir -p site-satu/releases/20260115-0900
printf '<h1>Situs v1</h1>\n' > site-satu/releases/20260115-0900/index.html
ln -sfn releases/20260115-0900 site-satu/current
cat > satu.conf <<'CONF'
SITE_DIR="site-satu"
HEALTH_URL="http://127.0.0.1:8765/current/"
HEALTH_RETRIES=1
HEALTH_DELAY=0
CONF
CONF=satu.conf bash rollback.sh > /dev/null 2>&1
assert_eq 2 "$?" "rollback tanpa release lebih tua keluar 2"

# --- ringkasan ----------------------------------------------------------------
if [ "$FAIL" -eq 0 ]; then
    printf 'PASSED: %d/%d test lulus\n' "$PASS" "$PASS"
else
    printf 'FAILED: %d/%d test lulus\n' "$PASS" "$((PASS + FAIL))"
fi
[ "$FAIL" -eq 0 ]
TESTS
bash proyek/tests/run-tests.sh

Output yang Dihasilkan:

text
OK    server block memuat: server {
OK    server block memuat: listen 80
OK    server block memuat: server_name latihan.example.com
OK    server block memuat: root /var/www/site/current
OK    server block memuat: index index.html
OK    server block memuat: try_files $uri $uri/ =404
OK    server block memuat: gzip on
OK    server block memuat: location /api/
OK    server block memuat: proxy_pass http://127.0.0.1:8080/
OK    server block memuat: server_tokens off
OK    server block memuat: X-Content-Type-Options
OK    server block memuat: X-Frame-Options
OK    server block memuat: Referrer-Policy
OK    server block TLS memuat: listen 443 ssl
OK    server block TLS memuat: ssl_certificate
OK    server block TLS memuat: return 301 https://$host$request_uri
OK    server block TLS memuat: Strict-Transport-Security
OK    deploy.conf memuat SITE_DIR
OK    deploy.conf memuat HEALTH_URL
OK    deploy.conf memuat HEALTH_RETRIES
OK    server latihan siap di port 8765
OK    deploy v1 keluar 0
OK    release v1 dibuat
OK    current menunjuk release v1
OK    konten tersaji: v1
OK    deploy v2 keluar 0
OK    current bergeser ke v2
OK    konten tersaji: v2
OK    release v1 tidak terhapus
OK    rollback keluar 0
OK    current kembali ke v1
OK    konten kembali v1
OK    deploy ketiga keluar 0
OK    health check gagal keluar 4
OK    current kembali ke release sehat
OK    release gagal dihapus
OK    konfigurasi hilang keluar 1
OK    artifact hilang keluar 2
OK    rollback tanpa release lebih tua keluar 2
PASSED: 39/39 test lulus

Pola-pola penting di dalam suitenya:

  1. trap bersihkan EXIT: server latihan dimatikan dan folder kerja dihapus apa pun akhir eksekusinya — lulus maupun gagal di tengah jalan. Dengan WORK dan SERVER diinisialisasi kosong sejak awal, trap aman dijalankan kapan pun.
  2. mktemp -d plus salin dua skrip: tiap eksekusi suite mendapat dunia sendiri; deploy.sh dan rollback.sh disalin ke sana sehingga suite tidak pernah mengotori paket aslinya. Berhasil dijalankan dua kali berturut-turut dengan hasil sama adalah syaratnya.
  3. Server dimulai sebelum deploy pertama: meniru VPS, tempat nginx sudah hidup sebelum deploy.sh pertama tiba. HEALTH_DELAY=0 membuat suite cepat tanpa mengubah perilaku yang diuji.
  4. Assertion isi, bukan cuma status: assert_eq '<h1>Situs v2</h1>' "$(curl -s ...)" membuktikan bahwa setelah deploy kedua, isi yang dilayani memang berubah — bukan sekadar exit code 0 dan symlink yang berpindah.
  5. Uji kegagalan yang disengaja: server dibunuh sebelum deploy keempat, sehingga health check pasti gagal dan rollback otomatis bisa diuji ulang setiap saat tanpa menunggu kecelakaan sungguhan. Begitu pula site-satu dengan satu-satunya release, yang membuat rollback.sh keluar kode 2 secara deterministik.

Dari Latihan ke VPS #

Di VPS, siklus yang sama berjalan dengan tiga penyesuaian: deploy.conf memakai SITE_DIR="/var/www/site" dan HEALTH_URL="http://localhost/" (nginx sudah berjalan di port 80), artifact diunggah dengan rsync -a build/ deployer@SERVER:/tmp/build-baru/, lalu BUILD_DIR=/tmp/build-baru bash deploy.sh dijalankan dari dalam server. Panduan ringkas lengkapnya — termasuk tabel kode keluar kedua skrip — tersedia di docs/README.txt pada paket proyek.

Server web kita selesai: fondasi yang terkunci sejak Bagian 1, konfigurasi yang dibedah dan diperiksa strukturnya di Bagian 2, dan di bagian ini keamanan yang dinaikkan tingkat sertifikat serta deployment yang menjadi satu perintah dengan jaminan jalan pulang. Pola yang sama — folder ber-versi, symlink sebagai penunjuk aktif, health check sebagai penjaga gerbang — akan muncul kembali dalam wujud lain pada proyek berikutnya, Server File Lokal , tempat yang dipindahkan bukan lagi versi situs melainkan berkas-berkas yang dibagikan.

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

deploy/deploy.sh

Unduh berkas
bash
#!/usr/bin/env bash
# deploy.sh — deployment dengan release folder, symlink current, dan
# health check.
#
# Alur satu eksekusi:
#   1. baca dan validasi konfigurasi (deploy.conf)
#   2. validasi artifact build (index.html wajib ada)
#   3. salin build ke releases/STAMP lalu verifikasi salinannya
#   4. geser symlink current ke release baru
#   5. health check dengan retry; gagal -> rollback otomatis ke
#      release sebelumnya dan release baru dihapus
#
# Variabel lingkungan (per eksekusi):
#   CONF       berkas konfigurasi yang dibaca (default: deploy.conf)
#   BUILD_DIR  folder artifact yang dideploy (default: build)
#   STAMP      identitas release (default: waktu sekarang, YYYYmmdd-HHMMSS)
#
# Kode keluar:
#   0 sukses                          1 konfigurasi tidak valid
#   2 artifact build tidak siap       3 release gagal disalin/diverifikasi
#   4 health check gagal (current sudah dikembalikan ke release lama)

set -u

CONF="${CONF:-deploy.conf}"
BUILD_DIR="${BUILD_DIR:-build}"
STAMP="${STAMP:-$(date +%Y%m%d-%H%M%S)}"

# log(): tulis satu baris beridentitas release ke stdout.
log() {
    printf '[%s] %s\n' "$STAMP" "$1"
}

# die(): catat kegagalan lewat log, lalu keluar dengan kode tertentu.
die() {
    log "GAGAL: $1 (kode $2)"
    exit "$2"
}

# --- 1. Baca dan validasi konfigurasi ---------------------------------
# Pesan tahap ini keluar lewat stderr: gagal di sini berarti skrip
# belum boleh menyentuh folder site sama sekali.
if [ ! -f "$CONF" ]; then
    printf 'deploy.sh: berkas konfigurasi %s tidak ditemukan\n' "$CONF" >&2
    exit 1
fi

# shellcheck source=/dev/null
. "$CONF"

if [ -z "${SITE_DIR:-}" ]; then
    printf 'deploy.sh: SITE_DIR belum diisi di %s\n' "$CONF" >&2
    exit 1
fi
if [ -z "${HEALTH_URL:-}" ]; then
    printf 'deploy.sh: HEALTH_URL belum diisi di %s\n' "$CONF" >&2
    exit 1
fi
case ${HEALTH_RETRIES:-} in
    ''|*[!0-9]*|0)
        printf 'deploy.sh: HEALTH_RETRIES harus angka 1 atau lebih\n' >&2
        exit 1
        ;;
esac
case ${HEALTH_DELAY:-} in
    ''|*[!0-9]*)
        printf 'deploy.sh: HEALTH_DELAY harus angka 0 atau lebih\n' >&2
        exit 1
        ;;
esac

# --- 2. Validasi artifact build ----------------------------------------
[ -d "$BUILD_DIR" ] || die "folder build tidak ditemukan: $BUILD_DIR" 2
[ -f "$BUILD_DIR/index.html" ] || die "index.html tidak ada di $BUILD_DIR" 2

# --- 3. Siapkan struktur site dan release baru -------------------------
mkdir -p "$SITE_DIR/releases" || exit 1
RELEASE="$SITE_DIR/releases/$STAMP"
if [ -e "$RELEASE" ]; then
    die "release $STAMP sudah ada; pakai STAMP yang berbeda" 1
fi

# Target symlink saat ini disimpan dulu: inilah tujuan rollback bila
# health check release baru gagal.
PREV=""
if [ -L "$SITE_DIR/current" ]; then
    PREV=$(readlink "$SITE_DIR/current")
fi

# --- 4. Salin artifact lalu verifikasi salinannya -----------------------
if ! cp -a "$BUILD_DIR" "$RELEASE"; then
    die "gagal menyalin $BUILD_DIR ke $RELEASE" 3
fi
if ! diff -r "$BUILD_DIR" "$RELEASE" > /dev/null; then
    rm -rf "$RELEASE"
    die "salinan release berbeda dari build" 3
fi
log "release tersalin: $RELEASE"

# --- 5. Geser symlink current ke release baru ---------------------------
# Target dibuat relatif terhadap folder site supaya struktur bisa
# dipindahkan tanpa memutus tautan.
ln -sfn "releases/$STAMP" "$SITE_DIR/current"
log "current -> releases/$STAMP"

# --- 6. Health check dengan retry ---------------------------------------
sehat=0
for percobaan in $(seq 1 "$HEALTH_RETRIES"); do
    if curl -fsS -o /dev/null "$HEALTH_URL" 2> /dev/null; then
        sehat=1
        break
    fi
    sleep "$HEALTH_DELAY"
done

if [ "$sehat" -ne 0 ]; then
    log "health check OK: $HEALTH_URL"
    log "selesai tanpa error"
    exit 0
fi

# --- 7. Health check gagal: rollback otomatis ---------------------------
# Release yang tidak sehat dihapus supaya rollback di masa depan
# tidak pernah mendarat di release rusak.
if [ -n "$PREV" ]; then
    ln -sfn "$PREV" "$SITE_DIR/current"
    log "health check gagal; current dikembalikan ke $PREV"
else
    rm -f "$SITE_DIR/current"
    log "health check gagal; tidak ada release sebelumnya; current dihapus"
fi
rm -rf "$RELEASE"
die "health check gagal setelah $HEALTH_RETRIES percobaan: $HEALTH_URL" 4

deploy/periksa-conf.sh

Unduh berkas
bash
#!/usr/bin/env bash
# periksa-conf.sh — pastikan directive wajib ada di berkas server block.
set -u
berkas=$1
gagal=0
for pola in \
    'server {' \
    'listen 80' \
    'server_name latihan.example.com' \
    'root /var/www/site/current' \
    'index index.html' \
    'try_files $uri $uri/ =404' \
    'gzip on' \
    'location /api/' \
    'proxy_pass http://127.0.0.1:8080/' \
    'server_tokens off' \
    'X-Content-Type-Options' \
    'X-Frame-Options' \
    'Referrer-Policy'
do
    if grep -qF -- "$pola" "$berkas"; then
        printf 'OK    %s\n' "$pola"
    else
        printf 'HILANG %s\n' "$pola"
        gagal=$((gagal + 1))
    fi
done
exit "$gagal"

deploy/rollback.sh

Unduh berkas
bash
#!/usr/bin/env bash
# rollback.sh — kembalikan symlink current ke release sebelumnya.
#
# Release diurutkan menurut nama: STAMP berformat YYYYmmdd-HHMMSS
# terurut sama dengan urutan waktunya. current dipindah ke release
# tepat di bawahnya, lalu health check memastikan release lama itu
# benar-benar sehat; bila tidak, current dikembalikan seperti semula.
#
# Variabel lingkungan:
#   CONF   berkas konfigurasi yang dibaca (default: deploy.conf)
#
# Kode keluar:
#   0 sukses                          1 konfigurasi tidak valid
#   2 tidak ada release lebih tua     3 health check gagal

set -u

CONF="${CONF:-deploy.conf}"

# log(): tulis satu baris beridentitas rollback ke stdout.
log() {
    printf '[rollback] %s\n' "$1"
}

# die(): catat kegagalan lewat log, lalu keluar dengan kode tertentu.
die() {
    log "GAGAL: $1 (kode $2)"
    exit "$2"
}

# --- 1. Baca dan validasi konfigurasi ---------------------------------
if [ ! -f "$CONF" ]; then
    printf 'rollback.sh: berkas konfigurasi %s tidak ditemukan\n' "$CONF" >&2
    exit 1
fi

# shellcheck source=/dev/null
. "$CONF"

if [ -z "${SITE_DIR:-}" ]; then
    printf 'rollback.sh: SITE_DIR belum diisi di %s\n' "$CONF" >&2
    exit 1
fi
if [ -z "${HEALTH_URL:-}" ]; then
    printf 'rollback.sh: HEALTH_URL belum diisi di %s\n' "$CONF" >&2
    exit 1
fi
case ${HEALTH_RETRIES:-} in
    ''|*[!0-9]*|0)
        printf 'rollback.sh: HEALTH_RETRIES harus angka 1 atau lebih\n' >&2
        exit 1
        ;;
esac
case ${HEALTH_DELAY:-} in
    ''|*[!0-9]*)
        printf 'rollback.sh: HEALTH_DELAY harus angka 0 atau lebih\n' >&2
        exit 1
        ;;
esac

# --- 2. Temukan release saat ini dan release di bawahnya ---------------
RELEASES="$SITE_DIR/releases"
[ -d "$RELEASES" ] || die "folder releases tidak ditemukan: $RELEASES" 1

KINI=""
if [ -L "$SITE_DIR/current" ]; then
    KINI=$(basename "$(readlink "$SITE_DIR/current")")
fi
[ -n "$KINI" ] || die "current belum menunjuk release mana pun" 1

# awk menyimpan nama release terakhir yang lebih kecil dari current;
# karena daftar sudah terurut, itulah release tepat di bawahnya.
LAMA=$(ls -1 "$RELEASES" |
    awk -v kini="$KINI" '$0 < kini { lama = $0 } END { print lama }')
[ -n "$LAMA" ] || die "tidak ada release lebih tua dari $KINI" 2

# --- 3. Geser current, lalu health check --------------------------------
ln -sfn "releases/$LAMA" "$SITE_DIR/current"
log "current -> releases/$LAMA"

sehat=0
for percobaan in $(seq 1 "$HEALTH_RETRIES"); do
    if curl -fsS -o /dev/null "$HEALTH_URL" 2> /dev/null; then
        sehat=1
        break
    fi
    sleep "$HEALTH_DELAY"
done

if [ "$sehat" -eq 0 ]; then
    ln -sfn "releases/$KINI" "$SITE_DIR/current"
    log "health check gagal; current dikembalikan ke $KINI"
    die "health check gagal setelah rollback ke $LAMA" 3
fi

log "health check OK: $HEALTH_URL"
log "selesai tanpa error"
exit 0

tests/run-tests.sh

Unduh berkas
bash
#!/usr/bin/env bash
# run-tests.sh — test suite untuk proyek server web.
# Suite menyiapkan folder kerja terisolasi (mktemp) beserta data
# contohnya, menjalankan python3 -m http.server sebagai server
# latihan pengganti nginx, lalu menguji: directive wajib pada berkas
# konfigurasi nginx, alur deploy (release + symlink + health check),
# rollback manual, dan rollback otomatis saat health check gagal.
# Jalankan dari mana saja: bash tests/run-tests.sh

set -u

SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
PROJ_DIR=$(dirname "$SCRIPT_DIR")

PASS=0
FAIL=0
WORK=""
SERVER=""

# assert_eq <harapan> <nyata> <pesan>: bandingkan dua nilai.
assert_eq() {
    if [ "$1" = "$2" ]; then
        PASS=$((PASS + 1))
        printf 'OK    %s\n' "$3"
    else
        FAIL=$((FAIL + 1))
        printf 'GAGAL %s: harapan %s, nyata %s\n' "$3" "$1" "$2"
    fi
}

# assert_ada <pola> <berkas> <pesan>: pola harus ditemukan di berkas.
assert_ada() {
    if grep -qF -- "$1" "$2"; then
        PASS=$((PASS + 1))
        printf 'OK    %s\n' "$3"
    else
        FAIL=$((FAIL + 1))
        printf 'GAGAL %s: pola tidak ditemukan di %s\n' "$3" "$2"
    fi
}

bersihkan() {
    if [ -n "$SERVER" ]; then
        kill "$SERVER" 2> /dev/null
    fi
    if [ -n "$WORK" ]; then
        rm -rf "$WORK"
    fi
}
trap bersihkan EXIT

# --- uji 1: konfigurasi nginx memuat directive wajib ---------------------
CONF_UTAMA="$PROJ_DIR/config/latihan.example.com.conf"
CONF_TLS="$PROJ_DIR/config/ssl-latihan.example.com.conf"
for pola in 'server {' 'listen 80' 'server_name latihan.example.com' \
    'root /var/www/site/current' 'index index.html' \
    'try_files $uri $uri/ =404' 'gzip on' 'location /api/' \
    'proxy_pass http://127.0.0.1:8080/' 'server_tokens off' \
    'X-Content-Type-Options' 'X-Frame-Options' 'Referrer-Policy'; do
    assert_ada "$pola" "$CONF_UTAMA" "server block memuat: $pola"
done
for pola in 'listen 443 ssl' 'ssl_certificate ' \
    'return 301 https://$host$request_uri' 'Strict-Transport-Security'; do
    assert_ada "$pola" "$CONF_TLS" "server block TLS memuat: $pola"
done
for kunci in SITE_DIR HEALTH_URL HEALTH_RETRIES; do
    assert_ada "$kunci" "$PROJ_DIR/config/deploy.conf" \
        "deploy.conf memuat $kunci"
done

# --- siapkan folder kerja terisolasi --------------------------------------
WORK=$(mktemp -d)
cp "$PROJ_DIR/deploy/deploy.sh" "$PROJ_DIR/deploy/rollback.sh" "$WORK/"
cd "$WORK" || exit 1

# --- uji 2: server latihan siap -------------------------------------------
# Struktur site dibuat dulu: di VPS, nginx sudah berjalan sebelum
# deploy pertama — server latihan kita pun harus demikian.
mkdir -p site/releases
python3 -m http.server 8765 --bind 127.0.0.1 --directory site \
    > /dev/null 2>&1 &
SERVER=$!
siap=0
for i in 1 2 3 4 5 6 7 8 9 10; do
    if curl -fsS -o /dev/null http://127.0.0.1:8765/ 2> /dev/null; then
        siap=1
        break
    fi
    sleep 0.2
done
assert_eq 1 "$siap" "server latihan siap di port 8765"

# --- uji 3: deploy pertama sukses ------------------------------------------
cat > deploy.conf <<'CONF'
SITE_DIR="site"
HEALTH_URL="http://127.0.0.1:8765/current/"
HEALTH_RETRIES=3
HEALTH_DELAY=0
CONF
mkdir -p build-v1/assets build-v2/assets
printf '<h1>Situs v1</h1>\n' > build-v1/index.html
printf 'body { color: #333; }\n' > build-v1/assets/gaya.css
printf '<h1>Situs v2</h1>\n' > build-v2/index.html
printf 'body { color: #111; }\n' > build-v2/assets/gaya.css

BUILD_DIR=build-v1 STAMP=20260115-0900 bash deploy.sh > /dev/null 2>&1
assert_eq 0 "$?" "deploy v1 keluar 0"
[ -d site/releases/20260115-0900 ]
assert_eq 0 "$?" "release v1 dibuat"
assert_eq "releases/20260115-0900" "$(readlink site/current)" \
    "current menunjuk release v1"
assert_eq '<h1>Situs v1</h1>' "$(curl -s http://127.0.0.1:8765/current/)" \
    "konten tersaji: v1"

# --- uji 4: deploy kedua menggeser current --------------------------------
BUILD_DIR=build-v2 STAMP=20260115-0930 bash deploy.sh > /dev/null 2>&1
assert_eq 0 "$?" "deploy v2 keluar 0"
assert_eq "releases/20260115-0930" "$(readlink site/current)" \
    "current bergeser ke v2"
assert_eq '<h1>Situs v2</h1>' "$(curl -s http://127.0.0.1:8765/current/)" \
    "konten tersaji: v2"
[ -d site/releases/20260115-0900 ]
assert_eq 0 "$?" "release v1 tidak terhapus"

# --- uji 5: rollback manual memulihkan v1 ---------------------------------
bash rollback.sh > /dev/null 2>&1
assert_eq 0 "$?" "rollback keluar 0"
assert_eq "releases/20260115-0900" "$(readlink site/current)" \
    "current kembali ke v1"
assert_eq '<h1>Situs v1</h1>' "$(curl -s http://127.0.0.1:8765/current/)" \
    "konten kembali v1"

# --- uji 6: health check gagal -> rollback otomatis -----------------------
# Deploy ketiga agar ada dua release sehat; server lalu dimatikan
# supaya health check deploy keempat pasti gagal.
BUILD_DIR=build-v2 STAMP=20260115-1000 bash deploy.sh > /dev/null 2>&1
assert_eq 0 "$?" "deploy ketiga keluar 0"
kill "$SERVER" 2> /dev/null
wait "$SERVER" 2> /dev/null
SERVER=""
BUILD_DIR=build-v2 STAMP=20260115-1030 bash deploy.sh > /dev/null 2>&1
assert_eq 4 "$?" "health check gagal keluar 4"
assert_eq "releases/20260115-1000" "$(readlink site/current)" \
    "current kembali ke release sehat"
[ ! -d site/releases/20260115-1030 ]
assert_eq 0 "$?" "release gagal dihapus"

# --- uji 7: exit code konfigurasi dan artifact ------------------------------
CONF=halang.conf bash deploy.sh > /dev/null 2>&1
assert_eq 1 "$?" "konfigurasi hilang keluar 1"
BUILD_DIR=tidak-ada STAMP=20260115-1100 bash deploy.sh > /dev/null 2>&1
assert_eq 2 "$?" "artifact hilang keluar 2"

# --- uji 8: rollback tanpa release lebih tua -------------------------------
mkdir -p site-satu/releases/20260115-0900
printf '<h1>Situs v1</h1>\n' > site-satu/releases/20260115-0900/index.html
ln -sfn releases/20260115-0900 site-satu/current
cat > satu.conf <<'CONF'
SITE_DIR="site-satu"
HEALTH_URL="http://127.0.0.1:8765/current/"
HEALTH_RETRIES=1
HEALTH_DELAY=0
CONF
CONF=satu.conf bash rollback.sh > /dev/null 2>&1
assert_eq 2 "$?" "rollback tanpa release lebih tua keluar 2"

# --- ringkasan ----------------------------------------------------------------
if [ "$FAIL" -eq 0 ]; then
    printf 'PASSED: %d/%d test lulus\n' "$PASS" "$PASS"
else
    printf 'FAILED: %d/%d test lulus\n' "$PASS" "$((PASS + FAIL))"
fi
[ "$FAIL" -eq 0 ]