Menyewa VPS pertama kali terasa seperti menerima kunci rumah kosong: ada alamat, ada pintu, tetapi belum ada apa pun di dalamnya. Proyek ini mengisi rumah itu sampai layak dihuni situs web — kita menyiapkan server Debian dari nol, menulis konfigurasi nginx untuk satu situs lengkap dengan halaman statis dan backend API, lalu membangun alat deployment sendiri yang memindahkan situs ke versi baru dalam satu perintah, lengkap dengan health check dan jalan pulang ke versi lama secepat symlink digeser. Di akhir proyek, satu VPS menerima pengunjung lewat port 80 dan 443, backend API hanya bicara lewat loopback, dan mengganti versi situs bukan lagi ritual salin-berkas manual yang menahan napas.

Alur yang kita bangun bekerja dalam tiga lapisan yang saling mengunci:

  • persiapan server — user khusus bernama deployer, kunci SSH, firewall yang hanya membuka port 22, 80, dan 443, serta rencana struktur webroot /var/www/site,
  • konfigurasi nginx — satu server block yang melayani berkas statis dari webroot itu sekaligus meneruskan /api/ ke backend di loopback, dibungkus kompresi gzip dan header keamanan,
  • deployment otomatis — deploy.sh menyalin artifact build ke folder releases/STAMP, menggeser symlink current, memverifikasi lewat health check HTTP, dan mengembalikan semuanya bila pemeriksaan gagal.

Tampilan Program Akhir #

Sesi berikut menunjukkan hasil akhir proyek. Kedua perintah dijalankan dari folder kerja yang berisi deploy.conf, deploy.sh, dan rollback.sh, dengan server latihan sedang berjalan di port 8765:

console
$ BUILD_DIR=build-v2 STAMP=20260115-0930 bash deploy.sh
[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
$ readlink site/current
releases/20260115-0930
$ curl -s http://127.0.0.1:8765/current/
<h1>Situs v2</h1>
$ bash rollback.sh
[rollback] current -> releases/20260115-0900
[rollback] health check OK: http://127.0.0.1:8765/current/
[rollback] selesai tanpa error
$ readlink site/current
releases/20260115-0900

Tampilannya ringkas, tetapi di baliknya tersimpan keputusan desain yang menjadi inti proyek: versi situs hidup sebagai folder release yang utuh dan tidak pernah ditimpa, current hanyalah symlink yang menentukan versi mana yang dilayani, health check memastikan versi baru benar-benar menjawab HTTP sebelum dianggap sukses, dan rollback hanyalah memindahkan symlink itu kembali — bukan menyalin ulang berkas.

Arsitektur Akhir #

Seluruh keputusan di atas bertemu dalam satu gambar berikut:

text
Internet
   │ port 80 / 443
   ▼
[ ufw ]  hanya port 22 (SSH), 80 (HTTP), 443 (HTTPS) terbuka
   │
   ▼
[ nginx ]
   ├── location /      → melayani berkas statis
   │                      root = /var/www/site/current
   │                      current ──┐ symlink, digeser deploy.sh
   │                                 ▼
   │                    /var/www/site/releases/
   │                      ├── 20260115-0900   versi lama
   │                      └── 20260115-0930   versi aktif
   │
   └── location /api/  → proxy_pass http://127.0.0.1:8080/
                          backend hanya mendengarkan loopback,
                          tidak bisa dijangkau dari luar VPS

Setiap panah di sana kita bangun dan uji satu per satu: firewall di Bagian 1, dua cabang nginx di Bagian 2, dan mekanisme penggeser current di Bagian 3.

Tujuan Pembelajaran #

Setelah menyelesaikan proyek ini, kita mampu:

  • menyiapkan VPS baru dengan checklist yang bisa diulang: user khusus, kunci SSH, firewall minimal, dan zona waktu,
  • memvalidasi konfigurasi sshd serta aturan firewall langsung dari output teksnya lewat awk,
  • menulis server block nginx lengkap: static site, reverse proxy /api/, gzip, header keamanan, dan server_tokens off,
  • membedah directive demi directive dan memvalidasi struktur konfigurasi dengan skrip pemeriksa sederhana,
  • menerapkan pola deployment release-folder plus symlink current yang membuat pergantian versi bersifat atomik,
  • membangun deploy.sh dengan health check ber-retry dan rollback otomatis, ditemani rollback.sh untuk pembatalan manual,
  • membuktikan seluruh klaim itu lewat test suite bash yang menjalankan server latihan sungguhan.

Prasyarat #

Proyek ini merangkai materi dari beberapa section sekaligus. Chapter yang paling sering dirujuk:

  • Web Server — percakapan HTTP, kode status, virtual host, dan server mini http.server,
  • nginx — server block, pola sites-available/sites-enabled, proxy_pass, dan nginx -t,
  • ufw dan firewalld — aturan allow dan kebiasaan menutup semua port lain.

Tahapan Pengerjaan #

Proyek dikerjakan dalam tiga bagian berurutan, dan tiap bagian diakhiri dengan sesuatu yang bisa diperiksa:

  1. Bagian 1: Persiapan Server — checklist VPS, user deployer dengan kunci SSH, validasi konfigurasi sshd, aturan firewall, dan rencana struktur webroot.
  2. Bagian 2: Konfigurasi Web Server — server block nginx lengkap dibedah per directive, backend uji untuk /api/, dan skrip validasi struktur konfigurasi.
  3. Bagian 3: Hardening dan Deployment — header keamanan dan TLS, lalu deploy.sh + rollback.sh dengan health check dan rollback yang dibuktikan bekerja.

Mulailah dari Bagian 1 karena keputusan fondasi di sana — nama user, port yang terbuka, bentuk webroot — dipakai terus sampai skrip terakhir. Berkas proyek (config/ berisi dua server block dan deploy.conf, deploy/ berisi kedua skrip, tests/run-tests.sh, serta docs/README.txt) tersedia pada bagian Berkas Sumber di bawah halaman setiap bagian, sehingga hasil pekerjaan bisa dibandingkan dengan versi referensi kapan saja.

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 ]