2026-08-21 · 10 min
Zero-Downtime Deploy Blue-Green di VPS Single Core dengan Nginx dan systemd
Klien saya di Jakarta punya VPS Hetzner CAX11 single core dengan dua service Node.js yang sebelumnya di-deploy pakai PM2 restart. Setiap kali ada deploy, ada window 5-10 detik di mana aplikasi sedang restart dan user mendapat 502. Kecil tapi nyata, dan mereka minta diperbaiki tanpa pindah ke infra yang lebih kompleks. Setup blue-green dengan nginx dan systemd adalah solusi yang saya pasang dalam satu sore.
Kenapa bukan Docker atau Kubernetes
Pertanyaan pertama biasanya: kenapa tidak pakai Docker saja? Jawabannya pragmatis. VPS single core dengan 2 GB RAM sudah cukup untuk beban aplikasi mereka. Menambahkan Docker daemon, container registry, dan overhead orchestration untuk sebuah service Node.js yang ukurannya 80 MB bukan keputusan yang bijak di sini. Systemd sudah ada, nginx sudah ada, dan keduanya sudah terbukti stabil bertahun-tahun di production.
Blue-green di level proses OS ini juga lebih mudah di-debug oleh engineer yang belum familiar dengan container. journalctl -u app@blue lebih transparan dari docker logs.
Arsitektur yang saya pakai
Internet → Nginx (port 80/443)
↓
upstream active
↓
┌─────────────────┐
│ app@blue :3001 │ ← slot aktif
│ app@green :3002 │ ← slot idle / standby
└─────────────────┘
systemd template units
Nginx tidak tahu soal blue atau green. Ia hanya tahu “upstream saat ini ada di port X”. Saat deploy, kita deploy ke port yang idle, verifikasi, lalu ubah ke mana nginx mengarah.
Setup systemd template unit
Template unit systemd pakai karakter @ di nama file. Satu file unit melayani beberapa instance dengan nama yang berbeda.
Buat file /etc/systemd/system/[email protected]:
[Unit]
Description=App instance %i
After=network.target
[Service]
Type=simple
User=deploy
WorkingDirectory=/srv/app
Environment=NODE_ENV=production
EnvironmentFile=/srv/app/.env
ExecStart=/usr/bin/node /srv/app/dist/server.js
Restart=on-failure
RestartSec=5
# Port ditentukan lewat environment per-instance
EnvironmentFile=/srv/app/instances/%i.env
[Install]
WantedBy=multi-user.target
Buat direktori dan file environment per instance:
mkdir -p /srv/app/instances
# /srv/app/instances/blue.env
echo "PORT=3001" > /srv/app/instances/blue.env
# /srv/app/instances/green.env
echo "PORT=3002" > /srv/app/instances/green.env
Aktifkan kedua instance:
systemctl daemon-reload
systemctl enable app@blue app@green
systemctl start app@blue
# green sengaja tidak distart dulu — ia slot standby
Cek status:
systemctl status app@blue
journalctl -u app@blue -f
Konfigurasi nginx dengan upstream yang bisa di-swap
Trik utamanya ada di sini. Pisahkan definisi upstream ke file terpisah yang bisa diganti tanpa menyentuh konfigurasi nginx utama.
/etc/nginx/conf.d/app-upstream.conf:
upstream app_active {
server 127.0.0.1:3001;
keepalive 32;
}
/etc/nginx/sites-available/app.conf:
include /etc/nginx/conf.d/app-upstream.conf;
server {
listen 80;
server_name app.example.com;
location /health {
proxy_pass http://app_active;
proxy_read_timeout 5s;
}
location / {
proxy_pass http://app_active;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
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_cache_bypass $http_upgrade;
# Buffer settings untuk menghindari partial response saat reload
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
}
}
Simpan link active ke file state:
echo "blue" > /srv/app/active-slot
Script deploy
Ini script yang saya pakai. Simpan di /srv/app/deploy.sh:
#!/usr/bin/env bash
set -euo pipefail
APP_DIR="/srv/app"
NGINX_UPSTREAM="/etc/nginx/conf.d/app-upstream.conf"
ACTIVE_SLOT_FILE="$APP_DIR/active-slot"
# 1. Tentukan slot yang akan dipakai untuk versi baru
CURRENT_SLOT=$(cat "$ACTIVE_SLOT_FILE")
if [[ "$CURRENT_SLOT" == "blue" ]]; then
NEW_SLOT="green"
NEW_PORT=3002
OLD_PORT=3001
else
NEW_SLOT="blue"
NEW_PORT=3001
OLD_PORT=3002
fi
echo "Deploy ke slot: $NEW_SLOT (port $NEW_PORT)"
echo "Slot aktif saat ini: $CURRENT_SLOT (port $OLD_PORT)"
# 2. Deploy kode baru ke direktori staging
DEPLOY_DIR="$APP_DIR/releases/$(date +%Y%m%d-%H%M%S)"
mkdir -p "$DEPLOY_DIR"
rsync -az --delete ./dist/ "$DEPLOY_DIR/dist/"
rsync -az package.json "$DEPLOY_DIR/"
# Update symlink
ln -sfn "$DEPLOY_DIR" "$APP_DIR/current"
# 3. Mulai instance baru
systemctl start "app@$NEW_SLOT"
echo "Menunggu instance $NEW_SLOT siap..."
# 4. Health check loop — tunggu hingga ready atau timeout
RETRIES=30
INTERVAL=2
for i in $(seq 1 $RETRIES); do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:$NEW_PORT/health" || true)
if [[ "$STATUS" == "200" ]]; then
echo "Instance $NEW_SLOT sehat (attempt $i)"
break
fi
if [[ $i -eq $RETRIES ]]; then
echo "ERROR: Instance $NEW_SLOT tidak merespons setelah $((RETRIES * INTERVAL)) detik"
systemctl stop "app@$NEW_SLOT"
exit 1
fi
sleep $INTERVAL
done
# 5. Swap upstream nginx
cat > "$NGINX_UPSTREAM" <<EOF
upstream app_active {
server 127.0.0.1:$NEW_PORT;
keepalive 32;
}
EOF
nginx -t && nginx -s reload
echo "Nginx di-reload, traffic sekarang ke $NEW_SLOT (port $NEW_PORT)"
# 6. Catat slot aktif yang baru
echo "$NEW_SLOT" > "$ACTIVE_SLOT_FILE"
# 7. Tunggu sebentar lalu matikan slot lama
echo "Menunggu 30 detik sebelum mematikan slot lama ($CURRENT_SLOT)..."
sleep 30
# Cek apakah ada permintaan rollback manual
if [[ "$(cat $ACTIVE_SLOT_FILE)" != "$NEW_SLOT" ]]; then
echo "Rollback terdeteksi, biarkan $CURRENT_SLOT tetap berjalan"
exit 0
fi
systemctl stop "app@$CURRENT_SLOT"
echo "Deploy selesai. Active slot: $NEW_SLOT"
Jalankan dengan:
chmod +x /srv/app/deploy.sh
sudo /srv/app/deploy.sh
Rollback
Karena slot lama masih berjalan selama 30 detik setelah swap, rollback semudah:
# Swap upstream kembali ke port lama
cat > /etc/nginx/conf.d/app-upstream.conf <<EOF
upstream app_active {
server 127.0.0.1:3001;
keepalive 32;
}
EOF
nginx -s reload
echo "blue" > /srv/app/active-slot
Atau bungkus jadi script rollback.sh yang membaca state dari active-slot dan melakukan kebalikan swap.
Trade-off yang perlu diakui
Kebutuhan RAM dobel. Di window deploy, dua instance berjalan sekaligus. Di VPS 2 GB dengan Node.js yang makan 200-400 MB per proses, ini tidak masalah. Tapi kalau aplikasi Anda butuh 800 MB, kalkulasi ulang sebelum pakai setup ini.
Stateful in-memory tidak bisa langsung. Kalau ada local cache atau in-memory session yang tidak ter-share antara blue dan green, user bisa mendapat respons berbeda setelah swap. Solusinya eksternalisasi state ke Redis atau database sebelum mengadopsi blue-green. Ini bukan kelemahan blue-green, tapi keharusan arsitektur untuk setup apapun yang mau zero-downtime.
Single point of failure di nginx. Kalau nginx turun, kedua slot tidak bisa diakses. Untuk setup VPS single node ini adalah tradeoff yang sudah diterima; kalau Anda butuh HA di layer nginx, itu masuk ke diskusi yang berbeda.
Konfigurasi nginx perlu hak akses. Script deploy perlu bisa menulis ke /etc/nginx/conf.d/ dan menjalankan nginx -s reload. Di production saya pakai sudoers rule yang spesifik:
# /etc/sudoers.d/deploy-nginx
deploy ALL=(ALL) NOPASSWD: /bin/systemctl start app@*, /bin/systemctl stop app@*, /usr/sbin/nginx -t, /usr/sbin/nginx -s reload
Jangan beri akses sudo penuh ke user deploy.
Verdict
Setup ini cocok untuk VPS single node dengan beban aplikasi yang wajar dan tim yang tidak mau overhead Kubernetes atau Docker Compose hanya untuk menghilangkan deploy downtime. Dua syarat utama: aplikasi Anda stateless (atau state ada di external store), dan RAM cukup untuk dua instance berjalan bersamaan.
Kalau kedua syarat itu terpenuhi, waktu setup kurang dari satu jam, rollback di bawah satu menit, dan tidak ada dependency baru yang perlu dipelajari tim. Untuk klien Jakarta tadi, ini sudah berjalan lebih dari enam bulan di production tanpa insiden deploy.
Kalau Anda sudah pakai Docker dan butuh setup serupa di level container, logikanya sama persis — hanya saja unit systemd diganti container, dan swap upstream nginx tetap menjadi mekanisme yang sama.
Ditulis oleh Reza Pradipta