Backup Server Otomatis: Database & File Tiap Jam, Salinan Off-site, dan Watchdog

Backup server otomatis: database dan file tiap jam, salinan off-site, watchdog, dan uji restore

Bayangkan Anda menyimpan akta rumah. Fotokopinya Anda taruh di laci yang sama, lalu suatu malam rumah kebakaran. Akta asli dan fotokopinya hangus bersamaan. Karena itu, orang yang berhati-hati menitipkan salinan ke rumah saudara di kota lain, lalu sesekali mengecek apakah salinan itu masih ada dan masih terbaca. Prinsip yang sama berlaku untuk backup server otomatis: salinan lokal saja tidak cukup, salinan di tempat lain wajib ada, dan harus ada “penjaga” yang memastikan salinan terbaru benar-benar lahir.

Pada tutorial ini kita membangun sistem backup yang sama dengan yang kami jalankan untuk server billing ISP kami. Isinya tiga skrip Bash: backup database dan file konfigurasi tiap jam, salinan off-site yang server lain tarik sendiri, serta watchdog yang memberi alert jika backup basi atau rusak. Selain itu, kita juga menguji restore, karena backup yang belum pernah Anda pulihkan belum terbukti. Kami sudah menguji semua kode di server uji, lalu mengganti IP, nama host, dan password dengan nilai contoh.

Diagram alur backup server otomatis: backup.sh tiap jam di server utama, rsync pull ke server off-site, watchdog dan alert
Tiga skrip, tiga jadwal cron, dua lokasi penyimpanan.

Kenapa Backup Server Otomatis Butuh Tiga Bagian?

Sampai Juni 2026, server billing kami belum punya backup yang layak. Memang ada sinkronisasi harian ke server dev, tetapi kami sudah menyanitasi data itu: token WhatsApp, key payment gateway, dan password router sengaja kami kosongkan. Artinya, sinkronisasi itu aman untuk sandbox, namun tidak berguna untuk restore. Sejak saat itu kami memecah tugas backup menjadi tiga bagian seperti tabel berikut.

BagianLokasiJadwalTugasSimpan
backup.shServer utamaTiap jam, menit :30Dump database, arsip config, validasi, rotasi7 hari
pull_backup.shServer off-siteTiap jam, menit :45Tarik file baru lewat rsync over SSH30 hari
watchdog_backup.shServer off-siteTiap 15 menitCek umur dan keutuhan file terbaru, kirim alert–
uji_restore.shServer off-siteBerkalaPulihkan ke database sementara lalu periksa isinya–

Sebagai gambaran ukuran, setiap jam server billing kami menghasilkan dump database utama sekitar 2,2 MB, dump RADIUS 7,4 MB, dan arsip config 13 KB (semua tergzip). Dalam 7 hari, folder lokal berisi 578 file sebesar 1,6 GB. Sementara itu, server off-site menyimpan 2.232 file sebesar 4,9 GB untuk 30 hari.

Persiapan: User MySQL Khusus Backup

Pertama, jangan memakai user root atau user aplikasi untuk backup. Buat user khusus yang hanya boleh membaca. Contoh berikut memakai database tokodemo di MariaDB 11.8.

CREATE USER 'appbackup'@'localhost' IDENTIFIED BY 'GantiPasswordKuat#2026';
GRANT SELECT, SHOW VIEW, TRIGGER, LOCK TABLES, EVENT ON tokodemo.* TO 'appbackup'@'localhost';
GRANT SHOW CREATE ROUTINE ON tokodemo.* TO 'appbackup'@'localhost';

Baris kedua cukup untuk tabel, view, trigger, dan event. Kemudian baris ketiga memberi akses membaca stored procedure di MariaDB 11.3 ke atas. Di MySQL 8, hak serupa bernama SHOW_ROUTINE dan berlaku global. Selanjutnya, buat user Linux appbackup dengan sudo useradd -m -s /bin/bash appbackup agar file backup punya pemilik sendiri.

Simpan Password di File Opsi, Bukan di Skrip

Skrip produksi lama kami menulis password langsung di perintah mysqldump -p.... Akibatnya, log selalu penuh peringatan Using a password on the command line interface can be insecure, dan password ikut terlihat di daftar proses. Cara yang lebih rapi adalah file opsi milik user backup.

sudo -u appbackup tee /home/appbackup/.backup.cnf >/dev/null <<'EOF'
[client]
user=appbackup
password="GantiPasswordKuat#2026"
EOF
sudo chmod 600 /home/appbackup/.backup.cnf

Perhatikan tanda kutip pada password. Di server uji, kami awalnya menulis password tanpa kutip, dan dump langsung gagal dengan Access denied. Ternyata MySQL membaca tanda # sebagai awal komentar, sehingga password terpotong. Untungnya, validasi di skrip berikut langsung menangkap kegagalan itu.

Langkah 1: Skrip backup.sh di Server Utama

Skrip ini cukup panjang, jadi kita bahas per bagian. Simpan ketiga bagian berurutan dalam satu file /home/appbackup/backup.sh.

Bagian 1: Variabel dan Kunci Anti-Tumpang-Tindih

#!/bin/bash
# backup.sh - backup database + file konfigurasi, dijalankan cron tiap jam
set -uo pipefail

DIR=/home/appbackup/backups
LOG=/home/appbackup/backup.log
CNF=/home/appbackup/.backup.cnf
DBS="tokodemo"
FILES="/srv/tokodemo/config"
KEEP_DAYS=7
MIN_SIZE=1000
TS=$(date +%Y%m%d_%H%M%S)

mkdir -p "$DIR" && chmod 700 "$DIR"
exec 9>/tmp/appbackup.lock
flock -n 9 || { echo "[$(date)] skip: backup lain masih jalan" >>"$LOG"; exit 0; }

Isi DBS dengan beberapa nama database yang terpisah spasi, dan FILES dengan folder config yang tidak masuk Git. Kemudian flock -n memastikan hanya satu backup yang berjalan. Misalnya, jika dump jam 10:30 masih berjalan saat cron 11:30 mulai, proses kedua langsung keluar dan mencatat “skip”.

Bagian 2: Dump Setiap Database dan Validasi

status=OK
for db in $DBS; do
  out="$DIR/${db}_$TS.sql.gz"
  mysqldump --defaults-extra-file="$CNF" --single-transaction --quick \
    --routines --triggers --no-tablespaces "$db" 2>>"$LOG" | gzip > "$out"
  rc=${PIPESTATUS[0]}
  sz=$(stat -c%s "$out")
  if [ "$rc" -ne 0 ] || [ "$sz" -lt "$MIN_SIZE" ] ||
     ! zcat "$out" | tail -n 1 | grep -q "Dump completed"; then
    echo "[$(date)] GAGAL dump $db (rc=$rc size=${sz}B)" >>"$LOG"
    mv "$out" "$out.gagal"
    status=GAGAL
  fi
done

Opsi --single-transaction membuat snapshot InnoDB yang konsisten tanpa mengunci tabel, sehingga aplikasi tetap bisa menulis selama dump berjalan. Selain itu, --quick mengalirkan baris satu per satu agar RAM tetap hemat. Validasi memakai tiga pemeriksaan: exit code mysqldump (lewat PIPESTATUS, karena exit code pipa biasanya milik gzip), ukuran minimal, dan baris penutup -- Dump completed. Jika salah satu gagal, file berganti nama menjadi .gagal agar tidak terlihat seperti backup sehat.

Bagian 3: Arsip Config, Izin File, dan Rotasi

tar czf "$DIR/files_$TS.tar.gz" $FILES 2>>"$LOG" || status=GAGAL
chmod 600 "$DIR"/*_"$TS"* 2>/dev/null

if [ "$status" = OK ]; then
  find "$DIR" -maxdepth 1 -type f \( -name '*.gz' -o -name '*.gagal' \) \
    -mtime +"$KEEP_DAYS" -delete
fi

total=$(du -sh "$DIR" | cut -f1)
echo "[$(date)] backup $status ts=$TS total=$total" >>"$LOG"

File config berisi password database dan API key, jadi chmod 600 wajib. Kemudian rotasi hanya berjalan kalau backup kali ini sukses. Dengan demikian, saat database bermasalah berhari-hari, skrip tidak terus menghapus backup lama yang justru menjadi satu-satunya salinan sehat.

Jalankan dan Pasang Cron

sudo chmod 700 /home/appbackup/backup.sh
sudo -u appbackup /home/appbackup/backup.sh
sudo -u appbackup tail -n 2 /home/appbackup/backup.log
# [Mon Nov  2 17:25:02 UTC 2026] backup OK ts=20261102_172502 total=36K

# cron user appbackup: tiap jam menit 30
echo "30 * * * * /home/appbackup/backup.sh" | sudo crontab -u appbackup -

Kami memilih menit 30, bukan menit 0, karena banyak cron lain (generate invoice, isolir pelanggan) berjalan di awal jam. Setelah itu, cek journalctl -u cron pada menit 30 berikutnya untuk memastikan cron benar-benar memanggil skrip.

Log backup server otomatis di server billing produksi: crontab, file dump tiap jam, error koneksi database RADIUS, dan log pull off-site
Tangkapan asli dari server billing kami, IP dan nama user kami sensor. Perhatikan baris error 12 Agustus yang tetap berakhir “backup OK”, serta log pull di server off-site.

Contoh Kasus: Satu Database Gagal, Log Tetap Bilang OK

Pada 12 Agustus 2026 pukul 22:30 UTC, server database RADIUS kami tidak bisa terhubung (error 2003, timeout 110). Akibatnya, dump RADIUS jam itu kosong. Namun, log tetap menulis “backup OK”, karena skrip produksi kami saat itu hanya memvalidasi database billing utama. Kami baru menyadarinya saat membaca ulang log untuk artikel ini. Oleh karena itu, versi di atas memvalidasi setiap database di dalam loop, dan watchdog di langkah 3 juga memeriksa file terbaru per database.

Langkah 2: Salinan Off-site dengan rsync Pull

Backup yang hanya tinggal di server utama akan ikut hilang saat disk rusak, VPS terhapus, atau server kena ransomware. Karena itu, server kedua di lokasi fisik berbeda harus menyimpan salinan. Ada dua arah: server utama mengirim (push) atau server off-site menarik (pull). Kami memilih pull, karena server utama tidak perlu menyimpan kredensial apa pun ke server off-site. Jadi, penyerang yang menguasai server utama tetap tidak bisa menghapus salinan off-site.

Key SSH yang Hanya Bisa Membaca

Di server off-site, buat user offsite dan key SSH khusus tanpa passphrase, lalu daftarkan public key-nya di server utama dengan pembatas rrsync.

# di server off-site, sebagai user offsite
ssh-keygen -t ed25519 -N "" -C offsite-pull -f ~/.ssh/id_pull
cat ~/.ssh/id_pull.pub

# di server utama: satu baris di /home/appbackup/.ssh/authorized_keys
command="/usr/bin/rrsync -ro /home/appbackup/backups/",restrict ssh-ed25519 AAAAC3...contoh offsite-pull

Skrip rrsync ikut terpasang bersama paket rsync di Ubuntu. Opsi -ro membatasi key ini hanya untuk membaca folder backups, sedangkan restrict mematikan shell, port forwarding, dan PTY. Kami mengujinya dengan dua percobaan: menjalankan cat /etc/passwd lewat key ini, lalu mengirim file ke server utama. Hasilnya, rrsync menolak keduanya.

Skrip pull_backup.sh

#!/bin/bash
# pull_backup.sh - jalan di server OFF-SITE, menarik backup dari server utama
set -uo pipefail

SRC=appbackup@203.0.113.10:
DST=/home/offsite/salinan
LOG=/home/offsite/pull.log
KEY=/home/offsite/.ssh/id_pull
KEEP_DAYS=30

mkdir -p "$DST" && chmod 700 "$DST"
exec 9>/tmp/offsite_pull.lock
flock -n 9 || { echo "[$(date)] skip: pull lain masih jalan" >>"$LOG"; exit 0; }

rsync -az --timeout=60 --exclude='*.gagal' \
  -e "ssh -i $KEY -o BatchMode=yes -o StrictHostKeyChecking=accept-new" \
  "$SRC" "$DST/" >>"$LOG" 2>&1
rc=$?

find "$DST" -maxdepth 1 -type f -name '*.gz' -mtime +"$KEEP_DAYS" -delete

cnt=$(find "$DST" -maxdepth 1 -name '*.gz' | wc -l)
newest=$(ls -t "$DST"/*.sql.gz 2>/dev/null | head -1)
echo "[$(date)] pull rc=$rc total=$cnt terbaru=$(basename "${newest:-none}")" >>"$LOG"

Path sumber cukup appbackup@203.0.113.10:, karena rrsync sudah mengarahkan key ke folder backups. Kemudian -a mempertahankan waktu file asli, dan detail ini penting untuk watchdog. Sengaja tidak ada --delete, sehingga file yang sudah terhapus rotasi 7 hari di sumber tetap tersimpan 30 hari di sini. Pasang cron 45 * * * * untuk user offsite.

Uji backup server otomatis di server uji: rrsync menolak perintah lain, rotasi lokal, salinan off-site, dan alert watchdog
Tangkapan asli dari server uji kami (nama host kami sensor): rrsync menolak akses selain baca, lalu watchdog mencatat STALE, PULIH, dan RUSAK.

Contoh Kasus: Memindahkan Server Off-site

Awalnya, server off-site kami adalah VPS dev. Pada November 2026 kami mematikan VPS dev dan pindah ke Proxmox lokal. Berkat model pull, server billing sama sekali tidak perlu berubah. Kami cukup menyalin 4,8 GB backup lama ke container baru, memasang skrip dan cron yang sama, lalu memastikan firewall server billing tetap mengizinkan IP keluar container. Hasilnya, tarikan pertama di lokasi baru langsung selesai dengan rc=0.

Langkah 3: Watchdog Umur Backup

Backup bisa berhenti diam-diam: cron mati, disk penuh, password berubah, atau server off-site tidak bisa menghubungi sumber. Karena itu, watchdog mengukur hal yang paling jujur, yaitu umur file terbaru di server off-site. Satu pemeriksaan ini sekaligus menangkap kegagalan backup dan kegagalan pull.

Bagian 1: Konfigurasi dan Fungsi Alert

#!/bin/bash
# watchdog_backup.sh - cek umur & keutuhan backup terbaru, alert hanya saat status berubah
set -uo pipefail

DST=/home/offsite/salinan
DBS="tokodemo"
MAX_AGE_MIN=150
STATE=/home/offsite/.wd_backup
LOG=/home/offsite/watchdog.log
TG_TOKEN=""
TG_CHAT=""

kirim_alert() {
  echo "[$(date)] ALERT: $1" >>"$LOG"
  [ -z "$TG_TOKEN" ] && return
  curl -s --max-time 20 "https://api.telegram.org/bot$TG_TOKEN/sendMessage" \
    -d chat_id="$TG_CHAT" --data-urlencode text="$1" >>"$LOG" 2>&1
  echo >>"$LOG"
}

Isi TG_TOKEN dan TG_CHAT dengan token bot dan chat ID Telegram Anda. Jika keduanya kosong, alert hanya masuk ke log, cocok untuk uji coba. Kami menguji format permintaannya dengan token palsu, dan API Telegram membalas 401 Unauthorized. Artinya, permintaan sudah sampai dan hanya token yang salah.

Bagian 2: Cek Umur, Keutuhan, dan Perubahan Status

cur=OK; info=""
for db in $DBS; do
  newest=$(ls -t "$DST"/${db}_*.sql.gz 2>/dev/null | head -1)
  if [ -z "$newest" ]; then
    cur=STALE; info="$info $db: tidak ada file."; continue
  fi
  age=$(( ( $(date +%s) - $(stat -c %Y "$newest") ) / 60 ))
  if [ "$age" -gt "$MAX_AGE_MIN" ]; then
    cur=STALE; info="$info $db: terbaru ${age} menit lalu."
  elif ! gzip -t "$newest" 2>/dev/null; then
    cur=RUSAK; info="$info $db: $(basename "$newest") gzip rusak."
  else
    info="$info $db: ${age} menit, utuh."
  fi
done

prev=$(cat "$STATE" 2>/dev/null || echo OK)
if [ "$cur" != "$prev" ]; then
  if [ "$cur" = OK ]; then
    kirim_alert "PULIH: backup $(hostname) segar kembali.$info"
  else
    kirim_alert "BACKUP $cur di $(hostname):$info"
  fi
  echo "$cur" > "$STATE"
fi

Watchdog membandingkan status sekarang dengan status sebelumnya di file state. Hasilnya, alert hanya terkirim saat status berubah: sekali saat bermasalah dan sekali saat pulih. Pola ini sama dengan watchdog billing kami yang memantau uptime, cron, dan disk, jadi ponsel tidak berbunyi tiap 15 menit. Pasang cron */15 * * * * untuk user offsite.

Diagram ambang watchdog backup server otomatis 150 menit: dua siklus gagal memicu alert STALE lalu PULIH
Ambang 150 menit baru memicu alert setelah dua siklus backup gagal berturut-turut.

Uji Watchdog dengan Memundurkan Waktu File

# pura-pura backup terakhir lahir 3 jam lalu
for f in ~/salinan/tokodemo_20261102_*.sql.gz; do touch -d "3 hours ago" "$f"; done
./watchdog_backup.sh; ./watchdog_backup.sh; cat ~/watchdog.log
# [Mon Nov  2 17:26:49 UTC 2026] ALERT: BACKUP STALE di ServerDemo: tokodemo: terbaru 180 menit lalu.

Walaupun watchdog berjalan dua kali, alert hanya muncul sekali. Setelah itu, kami menjalankan backup dan pull lagi, dan watchdog menulis “PULIH”. Kami juga memotong satu file dump dengan head -c 5000, dan watchdog langsung melaporkan RUSAK. Menariknya, pull berikutnya otomatis memperbaiki file itu, karena rsync melihat ukurannya berbeda dari sumber.

Contoh Kasus: Watchdog Ikut Mati dan Port SMTP Terblokir

Watchdog billing kami dulu berjalan di VPS dev dan mengirim alert lewat email Brevo. Masalah pertama, VPS itu memblokir port SMTP keluar 25, 465, dan 587, sehingga kami harus memakai port alternatif 2525. Masalah kedua lebih halus: saat VPS dev kami matikan, watchdog ikut mati tanpa suara. Oleh karena itu, kami memindahkan watchdog ke VPS jembatan yang berdiri sendiri. Pelajarannya, letakkan watchdog di server yang berbeda dari yang ia awasi, dan pastikan jalur alert-nya pernah teruji.

Langkah 4: Uji Restore, Bagian yang Paling Sering Terlewat

Skrip berikut memulihkan backup terbaru ke database sementara, menghitung isinya, lalu menghapusnya lagi. Jalankan dari server off-site dengan user MySQL yang hanya punya hak penuh atas database uji, misalnya GRANT ALL ON `tokodemo\_uji\_restore`.* TO 'offsite'@'localhost'.

#!/bin/bash
# uji_restore.sh - pulihkan backup terbaru ke database sementara lalu periksa isinya
set -euo pipefail

DST=/home/offsite/salinan
DB=tokodemo
UJI=${DB}_uji_restore
SQL="mysql --defaults-extra-file=/home/offsite/.restore.cnf"
trap '$SQL -e "DROP DATABASE IF EXISTS $UJI"' EXIT

file=$(ls -t "$DST"/${DB}_*.sql.gz | head -1)
echo "File    : $(basename "$file")"
gzip -t "$file" && echo "gzip    : utuh"

$SQL -e "DROP DATABASE IF EXISTS $UJI; CREATE DATABASE $UJI"
zcat "$file" | sed -E 's/DEFINER=`[^`]+`@`[^`]+`//g' | $SQL "$UJI"

for t in $($SQL -N -e "SHOW TABLES" "$UJI"); do
  printf "%-12s %s baris\n" "$t" "$($SQL -N -e "SELECT COUNT(*) FROM \`$t\`" "$UJI")"
done
echo "Trigger : $($SQL -N -e "SHOW TRIGGERS" "$UJI" | wc -l)"
echo "Routine : $($SQL -N -e "SHOW PROCEDURE STATUS WHERE Db='$UJI'" | wc -l)"
echo "Restore : BERHASIL (database uji dihapus otomatis)"

Perintah trap ... EXIT menghapus database uji apa pun hasilnya, termasuk saat restore gagal di tengah jalan. Kemudian baris sed membuang klausa DEFINER dari trigger dan procedure, dan alasannya ada di contoh kasus berikut.

Contoh Kasus: Restore Pertama Langsung Gagal

Versi pertama skrip ini belum punya baris sed. Hasilnya, restore berhenti di baris 3057 dengan ERROR 1227: Access denied; you need (at least one of) the SET USER privilege(s). Penyebabnya, mysqldump menulis trigger lengkap dengan DEFINER=`root`@`localhost`, sementara user restore bukan root. Seandainya kami baru mengetahuinya saat bencana sungguhan, proses pemulihan bisa tertunda sementara semua orang panik. Setelah kami menambahkan sed, restore berhasil: 3.000 invoice, 500 pelanggan, 1 trigger, dan 1 procedure, persis sama dengan sumbernya.

Uji restore backup server otomatis: error DEFINER pada percobaan pertama lalu restore berhasil dan data terverifikasi
Tangkapan asli dari server uji kami (nama host kami sensor): restore pertama gagal karena DEFINER, percobaan kedua berhasil.

Restore Sungguhan Saat Bencana

# 1. database: buat ulang lalu isi dari salinan off-site
sudo mysql -e "CREATE DATABASE tokodemo"
sudo zcat /home/offsite/salinan/tokodemo_20261102_172649.sql.gz | sudo mysql tokodemo

# 2. config: ekstrak ke folder sementara, bandingkan dulu, baru salin
mkdir -p /tmp/pulih && sudo tar xzf /home/offsite/salinan/files_20261102_172649.tar.gz -C /tmp/pulih
sudo diff -r /srv/tokodemo/config /tmp/pulih/srv/tokodemo/config && echo "identik"

Di server uji, kami sengaja menjalankan DROP DATABASE tokodemo, lalu memulihkannya dengan perintah di atas. Setelah itu, trigger pembayaran dan procedure total_lunas langsung bekerja lagi. Untuk config, jangan langsung mengekstrak ke /. Bandingkan dulu dengan diff, karena arsip lama bisa menimpa password yang baru saja Anda ganti.

Kesalahan Umum

  • Hanya menyimpan backup di server yang sama. Disk rusak atau VPS terhapus akan melenyapkan data dan backup sekaligus.
  • Menganggap file ada berarti backup sehat. Dump gagal tetap menghasilkan file gzip 20 byte. Selalu cek exit code, ukuran, dan baris “Dump completed”.
  • Memakai --skip-comments atau --compact. Kedua opsi ini membuang baris “Dump completed”, sehingga validasi selalu gagal.
  • Password berisi # tanpa tanda kutip di file .cnf. MySQL memotong password di tanda itu.
  • Memakai rsync --delete pada salinan off-site. Rotasi atau penghapusan di sumber ikut menghapus salinan Anda.
  • Watchdog di server yang sama dengan yang ia awasi. Saat server itu mati, alert juga mati.
  • Tidak pernah menguji restore. Masalah DEFINER, charset, atau hak akses baru ketahuan saat Anda paling butuh.

Kesimpulan

Backup server otomatis yang bisa Anda andalkan terdiri dari tiga bagian yang saling menjaga. Pertama, backup.sh membuat dump konsisten tiap jam dengan --single-transaction dan menolak hasil yang cacat. Kedua, server off-site menarik salinan lewat key read-only, sehingga server utama tidak bisa menghapusnya. Ketiga, watchdog mengukur umur file terbaru dan hanya berbunyi saat status berubah.

Namun, sistem ini baru terbukti setelah Anda berhasil memulihkannya. Di kasus kami, uji restore pertama justru gagal karena DEFINER, dan log produksi pernah menulis “OK” padahal satu database kosong. Karena itu, jadwalkan uji restore berkala, baca log sesekali, dan pindahkan watchdog ke server yang berbeda. Dengan demikian, saat bencana benar-benar datang, Anda tinggal menjalankan perintah yang sudah teruji.

Baca Juga

Referensi: MariaDB: mariadb-dump, MySQL 8.0: mysqldump, rsync(1) manual, flock(1), Telegram Bot API: sendMessage.

Ali Akbar

Software Developer yang fokus mengembangkan aplikasi berbasis Web dan Desktop. Senang mempelajari teknologi baru terutama di bidang web design dan web development.

View all posts by Ali Akbar →

Tinggalkan Balasan

Alamat email Anda tidak akan dipublikasikan. Ruas yang wajib ditandai *