Memindahkan server mirip memindahkan toko ke gedung baru. Pemilik toko yang cerdas tidak menutup toko berhari-hari. Pertama, ia menata rak di gedung baru lebih dulu. Kemudian, pada malam pindahan ia memasang papan “sedang pindah”, mengangkut stok terakhir, lalu mengganti alamat di papan nama. Keesokan paginya, pelanggan datang ke alamat baru tanpa merasa ada pindahan. Prinsip yang sama berlaku saat kami melakukan migrasi server ke Proxmox: persiapannya panjang, tetapi cutover-nya hanya sekitar 20 menit.
Pada malam pergantian 1 ke 2 November 2026, kami memindahkan seluruh isi VPS dev ke tiga container LXC di Proxmox lokal kantor. Isinya aplikasi hotel, lima aplikasi billing, toko online, aplikasi kasir, bot, dan semua alat monitoring. Karena Proxmox kami berada di belakang Starlink tanpa IP publik, semua domain masuk lewat VPS jembatan. Cara membangun jembatan itu sudah kami bahas di artikel Cara Akses Proxmox dari Internet Tanpa IP Publik (WireGuard + VPS). Oleh karena itu, artikel ini fokus pada urutan migrasinya dalam bentuk checklist, lengkap dengan dua hal yang sempat terlupa.

Gambaran Migrasi Server ke Proxmox: Sebelum dan Sesudah
Sebelum migrasi, satu VPS 2 core dengan RAM 2 GB menampung 15 domain sekaligus. Sesudah migrasi, setiap kelompok aplikasi mendapat container sendiri, sementara VPS jembatan hanya meneruskan trafik dan menjalankan monitoring.
| Komponen | Sebelum | Sesudah |
|---|---|---|
| Aplikasi hotel (Laravel + PostgreSQL) | VPS dev | LXC 101, Debian 13 |
| Billing dev (5 aplikasi PHP + MySQL) | VPS dev | LXC 102, Ubuntu 24.04 |
| Toko, kasir, hosting, bot (PHP 8.3 sampai 8.5) | VPS dev | LXC 103, Ubuntu 24.04 |
| Uptime Kuma, Beszel, NOC, watchdog | VPS dev | VPS jembatan |
| Sertifikat SSL dan certbot | VPS dev | Tetap di setiap LXC (SNI passthrough) |
Kenapa monitoring tidak ikut pindah ke lokal? Jalur Starlink kadang putus beberapa detik. Akibatnya, monitor yang berjalan di lokal akan menganggap semua server mati dan mengirim alarm palsu. Lebih buruk lagi, saat internet kantor benar-benar mati, alarmnya juga ikut mati. Karena itu, kami menaruh monitoring di VPS jembatan yang punya koneksi stabil.

Tahap 1: Inventaris Server Lama
Pertama, catat semua yang berjalan di server lama. Kesalahan migrasi hampir selalu berasal dari hal kecil yang tidak tercatat, misalnya cron milik satu user atau file config di /etc. Skrip berikut hanya membaca, jadi Anda aman menjalankannya kapan saja. Simpan kedua bagian berurutan dalam satu file inventaris.sh, lalu jalankan sebagai root.
Bagian 1: Layanan, Cron, dan Vhost
#!/bin/bash
# inventaris.sh - jalankan di server LAMA sebagai root, simpan hasilnya
echo "== layanan berjalan"
systemctl list-units --type=service --state=running --no-legend | awk '{print $1}' \
| grep -vE "^(systemd|dbus|ssh|cron|rsyslog|getty|serial|polkit|unattended|user@|networkd|snapd|multipathd|qemu|udisks|ModemManager|chrony|fwupd)" \
| tr '\n' ' '; echo
echo "== cron"
ls /etc/cron.d
for u in $(cut -d: -f1 /etc/passwd); do
crontab -u "$u" -l 2>/dev/null | grep -vE '^(#|$)' | sed "s/^/[$u] /"
done
echo "== vhost nginx (server_name | root/proxy)"
for f in /etc/nginx/sites-enabled/*; do
n=$(grep -m1 -oP "server_name\s+\K[^;]+" "$f")
r=$(grep -m1 -oP "^\s*root\s+\K[^;]+" "$f")
p=$(grep -m1 -oP "(proxy|fastcgi)_pass\s+\K[^;]+" "$f")
echo "$(basename "$f") | $n | $r $p"
done
Bagian pertama membuang layanan bawaan sistem dari daftar, sehingga yang tersisa hanya layanan buatan Anda, misalnya worker antrian atau monitoring. Selanjutnya, perulangan crontab -u memeriksa crontab setiap user, bukan hanya root. Terakhir, daftar vhost memperlihatkan domain, folder aplikasi, dan versi PHP-FPM milik setiap situs.
Bagian 2: Port, Config Buatan Sendiri, dan Ukuran Data
echo "== port yang mendengar"
ss -tlnp | awk 'NR>1{print $4}' | sort -u | tr '\n' ' '; echo
echo "== file config di /etc yang bukan milik paket"
for f in /etc/*.conf /etc/*.env; do
[ -f "$f" ] && ! dpkg -S "$f" >/dev/null 2>&1 && echo " $f"
done
echo "== ukuran data"
du -sh /var/www/* /opt/* 2>/dev/null | sort -h | tail -8
Perintah dpkg -S mencari paket pemilik sebuah file. Jika tidak ada paket yang memilikinya, besar kemungkinan Anda sendiri yang membuat file itu, sehingga file tersebut wajib ikut pindah. Daftar ini memang tetap memuat beberapa file sistem seperti resolv.conf. Namun, nama asing di daftar itulah yang perlu Anda perhatikan.

Checklist yang Sering Terlewat
| Item | Lokasi umum | Cara memeriksa |
|---|---|---|
| Crontab per user | spool cron | crontab -u USER -l |
| Unit systemd buatan sendiri | /etc/systemd/system | systemctl cat NAMA |
| Config di luar folder aplikasi | /etc/*.conf, /opt | dpkg -S, baca kode |
| Sertifikat Let’s Encrypt | /etc/letsencrypt | folder live, archive, renewal |
| Role database dan password | PostgreSQL atau MySQL | pg_dumpall --roles-only |
| File upload pengguna | storage/app | du -sh |
| Sudoers dan SSH key milik cron | /etc/sudoers.d, ~/.ssh | ls -la |
Tahap 2: Salin dan Uji Paralel Sebelum Hari H
Selanjutnya, salin seluruh aplikasi ke LXC selagi server lama masih melayani pengunjung. Kami menyalin folder aplikasi utuh, termasuk patch lokal yang belum masuk Git, beserta sertifikat milik domain masing-masing. Untuk PostgreSQL, kami mengambil role lengkap dengan hash password aslinya. Dengan begitu, file .env di server baru tidak perlu berubah sama sekali.
# di server LAMA: role PostgreSQL dengan hash password asli + dump awal sudo -u postgres pg_dumpall --roles-only | grep -E "ROLE (toko|hotel)( |;)" > pg_roles.sql for d in toko hotel; do sudo -u postgres pg_dump -Fc $d > $d.dump; done mysqldump --single-transaction --routines --triggers --set-gtid-purged=OFF \ --databases kasir | gzip > kasir.sql.gz
Setelah aplikasi berjalan di LXC, uji setiap domain lewat VPS jembatan tanpa mengubah DNS. Opsi --resolve memaksa curl memakai IP tertentu untuk sebuah domain, sehingga koneksi, SNI, dan sertifikat berjalan persis seperti pengunjung sungguhan.
VPS=203.0.113.10
for d in app.contoh.com toko.contoh.com www.contoh-klien.com; do
printf "%-26s %s\n" $d "$(curl -s -m 40 -o /dev/null \
-w '%{http_code} ssl=%{ssl_verify_result} %{time_total}s' \
--resolve $d:443:$VPS https://$d/)"
done
# app.contoh.com 200 ssl=0 0.383148s
# toko.contoh.com 200 ssl=0 0.372762s
Nilai ssl=0 berarti sertifikat valid. Jadi, Anda boleh lanjut ke cutover hanya jika semua domain menjawab dengan kode yang wajar (200, 301, atau 302) dan ssl=0.
Tahap 3: Bekukan Server Lama
Inilah awal cutover. Tujuannya sederhana: menghentikan semua perubahan data di server lama, supaya dump terakhir benar-benar final. Kami menulis satu skrip yang bisa Anda balik kapan saja, karena skrip ini memindahkan file alih-alih menghapusnya. Simpan kedua bagian berurutan dalam satu file bekukan.sh.
Bagian 1: Mode Maintenance dan Matikan Cron
set -u
O=/root/cutover; rm -rf $O; mkdir -p $O/db $O/files
# 1. maintenance: pengunjung melihat halaman 503, data berhenti berubah
for a in hotel:8.4 toko:8.5 kasir:8.3; do d=${a%%:*}; v=${a##*:}; (cd /var/www/$d && php$v artisan down --retry=60 2>&1 | tail -1); done
# 2. cron, worker, dan monitoring OFF (cadangan supaya bisa dibalik)
mkdir -p /root/cron.d-off; for c in hotel toko kasir; do [ -f /etc/cron.d/$c ] && mv /etc/cron.d/$c /root/cron.d-off/; done
crontab -u deploy -l > /root/crontab-deploy.bak && crontab -u deploy -r
for s in hotel-queue toko-queue uptime-kuma beszel-hub noc; do systemctl disable --now $s 2>/dev/null; echo "$s=$(systemctl is-active $s)"; done
sleep 3
Perintah artisan down --retry=60 menampilkan halaman maintenance dan menyuruh browser mencoba lagi 60 detik kemudian. Setelah itu, skrip memindahkan file cron ke /root/cron.d-off dan menyimpan crontab user ke file cadangan. Kami juga mematikan monitoring di server lama, karena monitoring baru di VPS akan menyala. Tanpa langkah ini, Anda akan menerima alarm ganda dari dua tempat.
Bagian 2: Dump Data Final
# 3. dump data final + file upload + data monitoring for d in hotel toko; do su postgres -c "pg_dump -Fc $d" > $O/db/$d.dump; echo "$d $(stat -c%s $O/db/$d.dump)"; done mysqldump --single-transaction --routines --triggers --databases kasir | gzip > $O/db/kasir.sql.gz; echo "kasir $(stat -c%s $O/db/kasir.sql.gz)" for a in hotel toko kasir; do tar cpzf $O/files/$a-storage-app.tgz -C /var/www/$a/storage app; done tar cpzf $O/files/kuma-data.tgz -C /opt/uptime-kuma data tar cpzf $O/files/beszel-data.tgz -C /opt/beszel beszel_data cd /root && tar cf cutover.tar cutover && chmod 644 cutover.tar && ls -la cutover.tar
Bagian kedua membuat dump PostgreSQL format custom (-Fc), dump MySQL terkompresi, arsip file upload, dan data monitoring. Kemudian, semuanya masuk ke satu file cutover.tar. Di server kami, seluruh data final hanya berukuran 6 MB, sehingga pembekuan sampai dump selesai dalam kurang dari satu menit.

Pindahkan Bundel ke Setiap LXC
# di laptop: ambil bundel lewat sudo (scp biasa gagal, file ada di /root) ssh lama 'sudo -n cat /root/cutover.tar' > cutover.tar scp cutover.tar root@pve:/root/ # di host Proxmox: kirim ke LXC yang butuh, lalu ekstrak for i in 101 103; do pct push $i /root/cutover.tar /root/cutover.tar && pct exec $i -- tar xf /root/cutover.tar -C /root && pct exec $i -- rm /root/cutover.tar; done
Perintah scp pertama kami gagal dengan pesan No such file or directory, karena user SSH tidak berhak membaca /root. Solusinya cukup membaca file lewat sudo cat lalu mengalirkannya ke laptop. Selanjutnya, pct push menyalin file dari host Proxmox langsung ke dalam container.
Tahap 4: Pulihkan Data Final di LXC
Di setiap LXC, jalankan skrip pemulihan. Skrip ini menghentikan worker, mengganti database dengan dump final, memasang file upload, membersihkan cache, lalu menyalakan aplikasi.
set -euo pipefail
C=/root/cutover
systemctl stop toko-queue
for d in toko; do
su postgres -c "psql -qc 'DROP DATABASE IF EXISTS $d WITH (FORCE)'"; su postgres -c "createdb -O $d $d"
cp $C/db/$d.dump /tmp/$d.dump; chown postgres /tmp/$d.dump
su postgres -c "pg_restore -d $d /tmp/$d.dump" 2>&1 | grep -iE "error" | head -3 || true; rm /tmp/$d.dump
done
zcat $C/db/kasir.sql.gz | mysql
for a in toko kasir; do rm -rf /var/www/$a/storage/app; tar xpzf $C/files/$a-storage-app.tgz -C /var/www/$a/storage; done
for a in toko:8.5 kasir:8.3; do d=${a%%:*}; v=${a##*:}; (cd /var/www/$d && sudo -u www-data HOME=/tmp php$v artisan optimize:clear >/dev/null 2>&1; sudo -u www-data HOME=/tmp php$v artisan config:cache >/dev/null 2>&1; php$v artisan up 2>&1 | tail -1) || true; done
systemctl start toko-queue; systemctl is-active toko-queue
rm -rf $C
Opsi WITH (FORCE) memutus koneksi yang masih menempel ke database sebelum DROP. Kemudian, perintah pg_restore mengisi ulang database, dan grep hanya menampilkan baris error supaya output tetap ringkas. Setelah itu, cocokkan jumlah data dengan server lama. Di kasus kami, aplikasi hotel menampilkan 43 organisasi dan 54 user, sama persis dengan sumbernya.
Pada saat yang sama, kami menyalakan monitoring di VPS jembatan memakai data Uptime Kuma dan Beszel dari bundel. Dengan demikian, riwayat uptime dan aturan alert tetap utuh.
Tahap 5: Ganti DNS ke VPS Jembatan
Sekarang giliran DNS. Ubah record A setiap domain dari IP server lama ke IP VPS jembatan. Namun, ubah hanya record milik server lama. Domain produksi yang tinggal di server lain, misalnya app atau @ milik aplikasi live, jangan Anda sentuh.
# cek IP dan TTL dari resolver publik, sebelum dan sesudah perubahan for d in app.contoh.com toko.contoh.com contoh-klien.com; do printf "%-26s %s\n" $d "$(dig +short @1.1.1.1 $d A | tail -1)" done dig +noall +answer @1.1.1.1 app.contoh.com A # app.contoh.com. 300 IN A 203.0.113.10
Angka 300 pada output dig adalah TTL dalam detik. Jika TTL domain Anda masih 3600 atau lebih, turunkan menjadi 300 sehari sebelum cutover. Akibatnya, resolver di seluruh dunia hanya menyimpan IP lama paling lama lima menit.
Tahap 6: Uji dari Luar dengan DNS Publik
Jangan menguji dari laptop Anda sendiri. Laptop bisa menyimpan cache DNS lama, punya entri /etc/hosts, atau bahkan memakai antivirus yang mencegat HTTPS. Sebaiknya Anda menguji dari server lain di internet, tanpa --resolve, supaya hasilnya sama dengan yang pengunjung lihat.
for d in app.contoh.com toko.contoh.com contoh-klien.com www.contoh-klien.com; do
ip=$(dig +short @1.1.1.1 $d A | tail -1)
printf "%-26s %-15s %s\n" $d "$ip" \
"$(curl -s -m 40 -o /dev/null -w '%{http_code} ssl=%{ssl_verify_result}' https://$d/)"
done
Hasil pertama kami hampir sempurna. Semua domain menjawab dengan ssl=0, kecuali satu subdomain www milik klien yang menjawab 000 ssl=1. Kode 000 berarti curl gagal tersambung sama sekali, sehingga tidak ada respons HTTP. Kasus kedua di bawah membahas penyebabnya.

Contoh Kasus 1: Config yang Tertinggal di /etc
Setelah monitoring menyala di VPS, layanan collector NOC tetap berstatus activating. Status ini artinya systemd terus menyalakan ulang layanan yang langsung mati. Log-nya langsung menunjukkan penyebabnya.
$ journalctl -u mss-noc -n 3 --no-pager python3[6346]: conf = json.load(open(CONF)) python3[6346]: FileNotFoundError: [Errno 2] No such file or directory: '/etc/mss-noc.conf' systemd[1]: mss-noc.service: Failed with result 'exit-code'.
Ternyata, collector membaca token Uptime Kuma dan Beszel dari /etc/mss-noc.conf, dan file ini tidak ikut di bundel karena letaknya di luar folder aplikasi. Kami lalu menyalinnya tanpa menampilkan isinya di layar, karena file tersebut berisi token.
ssh lama 'sudo -n cat /etc/mss-noc.conf' > mss-noc.conf scp mss-noc.conf jembatan:/tmp/ ssh jembatan 'sudo install -m 600 -o root /tmp/mss-noc.conf /etc/mss-noc.conf && rm /tmp/mss-noc.conf && sudo systemctl restart mss-noc' rm -f mss-noc.conf
Perintah install -m 600 -o root menyalin file sekaligus mengatur hak akses dan pemiliknya dalam satu langkah. Pelajaran dari kasus ini: selain dpkg -S, cari juga path /etc yang tertulis di dalam kode Anda, misalnya dengan grep -rn "/etc/" /opt /usr/local/sbin --include=*.py --include=*.sh.
Contoh Kasus 2: Subdomain www yang Tak Pernah Jalan
Kembali ke subdomain www yang gagal tadi. Penyelidikan kami menemukan dua penyebab. Pertama, peta SNI di VPS jembatan hanya memuat domain utama. Kedua, sertifikat di LXC juga hanya mencakup domain utama.
openssl x509 -in /etc/letsencrypt/live/contoh-klien.com/cert.pem -noout -ext subjectAltName # DNS:contoh-klien.com
Uniknya, subdomain ini ternyata juga tidak pernah jalan di server lama, dan selama ini tidak ada yang menyadarinya. Migrasi justru membongkar masalah lama itu, karena kali ini kami menguji setiap record DNS satu per satu. Untuk memperbaikinya, kami menambahkan www ke peta SNI port 443 dan peta port 80 di VPS.
# /etc/nginx/stream.d/sni-router.conf (VPS jembatan)
contoh-klien.com lxc_apps;
www.contoh-klien.com lxc_apps;
# /etc/nginx/sites-available/00-default-catchall (port 80)
contoh-klien.com 10.10.10.13;
www.contoh-klien.com 10.10.10.13;
Vhost Redirect dan Sertifikat Baru di LXC
Selanjutnya, kami membuat vhost khusus www di LXC 103 yang mengalihkan pengunjung ke domain utama. Blok port 80 sekaligus melayani verifikasi certbot lewat folder webroot.
server {
listen 80;
server_name www.contoh-klien.com;
real_ip_header X-Forwarded-For;
location ^~ /.well-known/acme-challenge/ { root /var/www/acme; default_type text/plain; }
location / { return 301 https://contoh-klien.com$request_uri; }
}
server {
listen 443 ssl http2;
server_name www.contoh-klien.com;
ssl_certificate /etc/letsencrypt/live/www.contoh-klien.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/www.contoh-klien.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
return 301 https://contoh-klien.com$request_uri;
}
certbot certonly --webroot -w /var/www/acme -d www.contoh-klien.com --cert-name www.contoh-klien.com \ --non-interactive --agree-tos --register-unsafely-without-email nginx -t && systemctl reload nginx
Awalnya kami menulis http2 on; seperti di LXC Debian 13, dan nginx -t langsung gagal. Ternyata Nginx 1.24 bawaan Ubuntu 24.04 belum mengenal direktif itu. Karena itu, kami memakai bentuk lama listen 443 ssl http2. Setelah reload, uji ulang dari luar menghasilkan 301 ke domain utama dengan ssl=0.
Tahap 7: Matikan Server Lama dan Rapikan Monitoring
Sebelum mematikan server lama, siapkan jalan pulang. Jika terjadi masalah besar, Anda cukup mengembalikan file dari /root/cron.d-off, memasang crontab dari file .bak, menyalakan layanan dengan systemctl enable --now, lalu menjalankan artisan up. Kemudian, kembalikan DNS ke IP lama. Untungnya, kami tidak membutuhkan langkah ini.
Setelah semua domain lolos uji dari luar, server dev kami matikan. Lalu, kami merapikan tiga hal di monitoring: menghapus sistem server lama dari Beszel, mengganti nama grup “Server Dev” di Uptime Kuma menjadi “Server Lokal (Proxmox)”, dan membetulkan label alert disk di skrip watchdog. Terakhir, kami menguji ulang 15 domain dari server produksi sebagai titik luar, dan semuanya tetap hijau.
| Jam (WIB) | Kegiatan | Catatan |
|---|---|---|
| 23:50 | Inventaris akhir server lama | Data monitoring hanya 22 MB |
| 23:51 | Bekukan server lama dan dump data | Bundel final 6 MB |
| 23:53 | Pulihkan data di LXC 101 dan 103 | scp gagal, pakai sudo cat |
| 23:54 | Nyalakan monitoring di VPS jembatan | Collector NOC gagal, config tertinggal |
| 00:04 | Ganti DNS semua domain | TTL 300 detik |
| 00:05 | Uji dari luar dengan DNS publik | Satu subdomain www gagal |
| 00:12 | Server lama mati, uji ulang | 15 domain OK |
Kesalahan Umum Saat Migrasi Server ke Proxmox
- Langsung mengganti DNS tanpa uji paralel. Uji setiap domain dengan
curl --resolvelebih dulu, supaya masalah sertifikat dan vhost muncul sebelum pengunjung melihatnya. - Lupa membekukan server lama. Jika aplikasi lama masih menerima transaksi setelah dump, data itu hilang saat Anda pindah.
- Menghapus cron alih-alih memindahkannya. Simpan cadangan supaya rollback cukup dengan satu perintah
mv. - Hanya menyalin folder aplikasi. Config di
/etc, unit systemd, crontab user, dan role database sering tinggal di server lama. - Monitoring menyala di dua tempat. Matikan monitoring lama tepat saat monitoring baru menyala, supaya tidak ada alarm ganda.
- Menguji dari laptop sendiri. Cache DNS dan antivirus bisa memberi hasil palsu. Uji dari server luar dengan resolver publik.
- Melewatkan subdomain www. Periksa SAN sertifikat dan peta SNI untuk setiap nama, bukan hanya domain utama.
- Menyalin config Nginx antar versi begitu saja. Direktif
http2 on;baru tersedia di Nginx 1.25.1. Selalu jalankannginx -tsebelum reload.
Kesimpulan
Migrasi server ke Proxmox tanpa downtime yang terasa bukan soal kecepatan menyalin data, melainkan soal urutan. Pertama, inventaris semua layanan, cron, dan config. Kemudian, salin dan uji paralel dengan curl --resolve selagi server lama masih melayani. Setelah itu, cutover cukup berisi empat langkah singkat: bekukan, pulihkan data final, ganti DNS, lalu uji dari luar.
Selain itu, siapkan diri untuk menemukan hal yang terlupa. Di kasus kami, dua masalah muncul justru saat uji akhir, yaitu satu file config di /etc dan satu subdomain yang ternyata tidak pernah jalan. Dengan checklist di atas dan skrip yang bisa Anda balik, cutover 15 domain kami selesai dalam sekitar 20 menit, dan pengunjung hanya melihat halaman maintenance selama beberapa menit.
Baca Juga
- Cara Akses Proxmox dari Internet Tanpa IP Publik (WireGuard + VPS)
- Cara Membuat Dashboard Monitoring Server (NOC) dengan Uptime Kuma dan Beszel
- Keamanan Server Linux: Tutorial Hardening SSH, Firewall UFW, CrowdSec, dan Alarm Telegram
Referensi: Proxmox VE: pct(1), Laravel Maintenance Mode, PostgreSQL pg_dump, curl –resolve, Nginx ngx_http_v2_module.

