Deploy Laravel ke VPS Tanpa Downtime: Git, Composer, dan Migrasi yang Aman

 


Bayangkan skenario ini: Anda menekan tombol deploy pada Jumat malam, pengunjung masih ramai, lalu halaman mendadak blank. Bukan karena kodenya salah total — seringkali karena proses deploy menimpa file yang sedang dibaca PHP-FPM, atau migrasi database jalan setengah-setengah sementara kode lama masih melayani request. Artikel ini membedah urutan deploy yang lebih rapi, dengan satu prinsip sederhana: siapkan rilis baru di samping rilis lama, baru pindahkan penunjuknya ketika semuanya sudah siap.

Ini adalah artikel ketiga dari seri Deploy Laravel ke VPS. Dua artikel sebelumnya membahas persiapan VPS dan konfigurasi Nginx + PHP-FPM. Kalau Anda baru sampai di sini, pastikan server block Nginx Anda sudah mengarah ke direktori public dan PHP-FPM berjalan normal sebelum mengikuti langkah di bawah.

Gambaran pola rilis berdampingan

Alih-alih menimpa folder aplikasi yang sedang live, Anda membuat struktur seperti ini di server:

/var/www/laravel-app/
├── releases/
│   ├── 20261026080000/
│   └── 20261026090000/
├── shared/
│   ├── .env
│   └── storage/
└── current -> releases/20261026090000

Nginx mengarah ke /var/www/laravel-app/current/public. Saat deploy baru, kode disiapkan di folder releases yang baru, dependency di-install di sana, migrasi dijalankan, cache dibangun — dan barulah symlink current dipindahkan. Perpindahan symlink itu satu operasi atomik di sistem file: request yang datang sesudahnya membaca rilis baru, request yang sedang berjalan menyelesaikan pekerjaannya dengan file lama. Inilah inti dari “tanpa downtime” di level ini.

Catatan penting soal database: symlink tidak bisa mengembalikan migrasi. Begitu kolom ditambahkan atau diubah, kode lama harus tetap bisa hidup berdampingan dengan skema baru sampai deploy selesai. Aturan praktisnya: deploy yang menambah kolom dulu, baru deploy kode yang memakai kolom itu — atau pastikan kode lama tidak pecah membaca kolom baru. Untuk perubahan destruktif seperti menghapus kolom, dalam dua rilis terpisah: rilis pertama berhenti memakai kolom tersebut, rilis kedua baru menghapusnya.

Prasyarat sebelum mulai

  • VPS Ubuntu dengan Nginx dan PHP-FPM sudah terpasang (lihat artikel 02 seri ini);
  • repository Git bisa diakses dari server lewat deploy key;
  • database produksi sudah dibuat dan kredensial dipegang;
  • Composer terpasang, composer --version tanpa error;
  • cadangan database terbaru sudah dibuat.

Perintah cadangan:

mysqldump -u laravel_user -p laravel_db > ~/backup-laravel-db-sebelum-deploy.sql

Hasil yang diharapkan: file SQL di home directory, ls -lh ~/backup-laravel-db-sebelum-deploy.sql ukuran masuk akal bukan nol byte. Bila error akses ditolak, periksa user dan hak akses sebelum melanjutkan.

Langkah 1: Buat folder rilis baru dan tarik kode

cd /var/www/laravel-app
RELEASE=$(date +%Y%m%d%H%M%S)
mkdir -p releases/$RELEASE
git clone --depth 1 --branch main git@github.com:username/nama-repo.git releases/$RELEASE

Ganti username/nama-repo.git dan main bila berbeda. Opsi --depth 1 membuat clone lebih cepat. Hasil yang diharapkan: folder berisi kode lengkap termasuk composer.json dan artisan, periksa dengan ls releases/$RELEASE | head. Kesalahan umum: clone permission denied karena deploy key belum ditambahkan atau key server belum dikenali, uji dengan ssh -T git@github.com. Pola pull di folder yang sama bisa jalan untuk proyek kecil tetapi kehilangan keunggulan kembali ke rilis lama hanya dengan memindahkan symlink; untuk seri ini pola folder rilis dipakai sampai akhir.

Langkah 2: Pasang file .env dan direktori storage bersama

ln -s /var/www/laravel-app/shared/.env releases/$RELEASE/.env
rm -rf releases/$RELEASE/storage
ln -s /var/www/laravel-app/shared/storage releases/$RELEASE/storage

Isi .env produksi:

APP_NAME="Nama Aplikasi"
APP_ENV=production
APP_DEBUG=false
APP_URL=https://domain-anda.com
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel_db
DB_USERNAME=laravel_user
DB_PASSWORD=kata-sandi-database

Tiga baris pertama paling sering menimbulkan masalah diam-diam: APP_DEBUG=false wajib di produksi, APP_URL harus cocok dengan domain Nginx karena dipakai untuk URL absolut di email dan notifikasi. Kesalahan umum: semua URL di email mengarah ke localhost karena APP_URL masih default, perbaiki di shared/.env bukan di file .env rilis, lalu bersihkan cache konfigurasi (dibahas di Langkah 6).

Langkah 3: Install dependency dengan Composer

cd releases/$RELEASE
composer install --no-dev --optimize-autoloader --no-interaction

Alasan tiap opsi: --no-dev melewatkan package pengembangan, --optimize-autoloader membuat peta class lebih cepat, --no-interaction mencegah berhenti menunggu jawaban, penting untuk skrip/GitHub Actions. Hasil yang diharapkan: baris terakhir output menyatakan instalasi selesai tanpa peringatan merah, ls vendor/bin menampilkan binary, php artisan --version menampilkan versi Laravel. Kesalahan umum pertama: memori habis di VPS kecil (beri swap cukup atau COMPOSER_MEMORY_LIMIT=-1 hanya bila paham konsekuensinya), kesalahan kedua: composer.lock tidak ada di repository (versi dependency bisa bergeser antar deploy, pastikan composer.lock ter-commit).

Langkah 4: Kunci aplikasi dan symlink storage

php artisan key:generate

Perintah ini menulis APP_KEY ke shared/.env lewat symlink, jalankan sekali saja, jangan jadikan bagian skrip deploy rutin karena mengganti kunci membuat data terenkripsi lama tidak bisa dibaca.

php artisan storage:link

Hasil yang diharapkan: output menyatakan symlink dibuat dari public/storage ke storage/app/public, ls -l public/ menunjukkan storage sebagai link. Bila output menyatakan link sudah ada, itu normal pada rilis yang memakai storage bersama. Kesalahan umum: file upload lama hilang karena folder storage rilis baru tidak dihubungkan ke shared/storage, periksa dengan readlink releases/$RELEASE/storage — hasilnya harus mengarah ke shared/storage.


Langkah 5: Migrasi database dengan --force

php artisan migrate --force

Opsi --force diperlukan karena Laravel menolak migrasi di produksi tanpa konfirmasi, tanpa opsi ini perintah menggantung menunggu jawaban interaktif. Sebelum menekan enter, periksa cadangan sudah dibuat dan migrasi sudah dibaca (apakah ada yang menghapus kolom/tabel, pastikan rilis live tidak lagi membaca kolom itu; migrasi yang hanya menambah tabel/kolom risikonya relatif kecil). Hasil yang diharapkan: output menampilkan daftar migrasi dengan status DONE tanpa error, bila sebuah migrasi gagal Laravel berhenti di titik itu, jangan langsung mengulang membabi buta — baca pesan error, periksa tabel migrations untuk batch terakhir, putuskan memperbaiki migrasi lalu melanjutkan atau memulihkan cadangan dan membatalkan rilis. Kesalahan umum: migrasi gagal karena kolom sudah ada (migrasi pernah dijalankan setengah jalan secara manual), periksa skema aktual dengan SHOW COLUMNS FROM nama_tabel; di MySQL sebelum memperbaiki atau mencatat manual, dan dokumentasikan perubahan di luar migrasi.

Langkah 6: Bangun cache konfigurasi, route, dan view

php artisan optimize

Perintah ini membungkus cache konfigurasi, event, route, view. Perintah granular: php artisan config:cache, php artisan route:cache, php artisan view:cache, php artisan event:cache. Peringatan dokumentasi Laravel: setelah config:cache, file .env tidak lagi dibaca dan pemanggilan env() di luar file konfigurasi mengembalikan null, seluruh nilai harus tersedia lewat file-file di config/ saat cache dibangun, bila shared/.env diubah sesudah deploy perubahan tidak berlaku sampai cache dibangun ulang (php artisan optimize lagi atau php artisan config:cache saja). Kesalahan umum: route cache gagal karena Closure (route:cache hanya bekerja untuk route yang memakai controller, PHP tidak bisa serialisasi Closure, pindahkan ke controller atau lewati perintah itu). Bila perlu membongkar semua cache, php artisan optimize:clear — untuk troubleshooting, bukan langkah rutin deploy.

Langkah 7: Bangun aset frontend bila ada

Bila memakai Vite, di folder rilis:

npm ci
npm run build

npm ci memasang dependency persis sesuai package-lock.json, berbeda dengan npm install. Hasil yang diharapkan: folder public/build terbentuk berisi aset dan manifest.json, bila kosong halaman memuat tanpa CSS/JavaScript. Periksa public/build/manifest.json sebelum memindahkan symlink. Untuk proyek tanpa frontend build, lewati langkah ini.

Langkah 8: Pindahkan symlink dan muat ulang PHP-FPM

cd /var/www/laravel-app
ln -sfn releases/$RELEASE current
sudo systemctl reload php8.2-fpm

Sesuaikan versi PHP-FPM, periksa dengan systemctl list-units | grep php bila ragu; reload bukan restart, agar worker FPM menyelesaikan request yang sedang berjalan lalu memuat kode baru. Verifikasi:

readlink current
curl -s -o /dev/null -w "%{http_code}\n" https://domain-anda.com/
curl -s https://domain-anda.com/up

Hasil yang diharapkan: readlink menampilkan folder rilis baru, request pertama mengembalikan 200, endpoint /up juga 200 bila aplikasi berhasil boot. Bila /up mengembalikan 500, buka storage/logs/laravel.log di rilis baru dan baca baris error terakhir sebelum memutarbalikkan symlink. Terakhir beri tahu worker queue:

cd current
php artisan queue:restart

Perintah ini memberi sinyal worker menyelesaikan job aktif lalu berhenti, sehingga Supervisor memulai worker baru dengan kode terbaru; melewatkan langkah ini berarti email dan job latar tetap diproses kode lama sampai worker di-restart manual.

Checklist rollback: bila rilis baru bermasalah

Pindahkan symlink kembali ke rilis sebelumnya:

cd /var/www/laravel-app
ln -sfn releases/<rilis-sebelumnya> current
sudo systemctl reload php8.2-fpm
cd current
php artisan optimize
php artisan queue:restart

Bila migrasi terakhir penyebab masalah dan punya perintah down yang aman, php artisan migrate:rollback --step=1 — hanya bila yakin rollback skema tidak menghapus data yang sudah ditulis pengguna sejak deploy, bila ragu jangan rollback skema, perbaiki dengan migrasi lanjutan. Bila penyebabnya aset frontend, cukup jalankan ulang npm run build dari kode yang benar tanpa menyentuh database. Catat apa yang salah dan tambahkan pengecekan itu ke proses deploy berikutnya. Bersihkan rilis lama secara berkala tetapi sisakan minimal dua rilis terakhir, perintah ls -1dt releases/*/ | tail -n +4 | xargs -r rm -rf (menghapus semua rilis kecuali tiga terbaru, sesuaikan dengan ruang disk, jangan pernah menjalankan pembersihan sebelum rilis baru terverifikasi stabil).

Tabel ringkasan

TahapPerintah / tindakanTanda selesai
Siapkan rilisgit clone --depth 1 ke releases/$RELEASEFolder berisi artisan dan composer.json
Konfigurasi bersamaSymlink shared/.env dan shared/storageAPP_DEBUG=false, APP_URL benar
Dependencycomposer install --no-dev --optimize-autoloader --no-interactionphp artisan --version jalan
Kunci (sekali saja)php artisan key:generateAPP_KEY terisi di shared/.env
Storage publikphp artisan storage:linkpublic/storage adalah symlink
Migrasiphp artisan migrate --forceSemua migrasi DONE, tanpa error
Cachephp artisan optimizeHalaman utama 200, /up 200
Asetnpm ci && npm run buildpublic/build/manifest.json ada
Go liveln -sfn releases/$RELEASE current + reload FPMreadlink current menunjuk rilis baru
Workerphp artisan queue:restartWorker memproses job dengan kode baru
RollbackPindah symlink + reload FPM + optimizeSitus kembali normal tanpa deploy ulang

Checklist akhir sebelum Anda menutup terminal

  • cadangan database dibuat dan ukurannya bukan nol;
  • folder rilis baru berisi kode lengkap, bukan clone setengah jalan;
  • shared/.env terhubung, APP_DEBUG=false, APP_URL sesuai domain;
  • composer install selesai tanpa error, vendor terisi;
  • migrate --force selesai, semua migrasi DONE;
  • optimize dijalankan, route tanpa Closure bila route cache aktif;
  • public/build/manifest.json ada bila memakai Vite;
  • symlink current menunjuk rilis baru dan /up mengembalikan 200;
  • queue:restart dijalankan agar worker memakai kode baru;
  • minimal dua rilis lama masih tersimpan untuk rollback.

Deploy tanpa downtime bukan soal alat mahal, soal urutan — siapkan semuanya di samping, verifikasi satu per satu, baru pindahkan penunjuknya. Begitu urutan ini menjadi kebiasaan, deploy Jumat malam berhenti terasa seperti undian.

Bagian mana dari urutan deploy Anda yang paling sering bermasalah — migrasi, cache, atau worker queue yang lupa di-restart? Ceritakan di komentar, dan artikel berikutnya akan membahas sisi yang sering luput itu: memastikan queue, scheduler, dan Horizon tetap hidup di VPS.

Baca juga: panduan deploy otomatis ke VPS dengan GitHub Actions untuk mengotomatiskan urutan di artikel ini dari push sampai go live, dan Docker Compose untuk self-hosting dari nol bila Anda lebih suka membungkus Laravel dan database dalam container. Log dari tugas terjadwal bisa dibaca lewat panduan membaca log server dengan journalctl dan logrotate.

Komentar