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 --versiontanpa 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/upHasil 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
| Tahap | Perintah / tindakan | Tanda selesai |
|---|---|---|
| Siapkan rilis | git clone --depth 1 ke releases/$RELEASE | Folder berisi artisan dan composer.json |
| Konfigurasi bersama | Symlink shared/.env dan shared/storage | APP_DEBUG=false, APP_URL benar |
| Dependency | composer install --no-dev --optimize-autoloader --no-interaction | php artisan --version jalan |
| Kunci (sekali saja) | php artisan key:generate | APP_KEY terisi di shared/.env |
| Storage publik | php artisan storage:link | public/storage adalah symlink |
| Migrasi | php artisan migrate --force | Semua migrasi DONE, tanpa error |
| Cache | php artisan optimize | Halaman utama 200, /up 200 |
| Aset | npm ci && npm run build | public/build/manifest.json ada |
| Go live | ln -sfn releases/$RELEASE current + reload FPM | readlink current menunjuk rilis baru |
| Worker | php artisan queue:restart | Worker memproses job dengan kode baru |
| Rollback | Pindah symlink + reload FPM + optimize | Situs 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/.envterhubung,APP_DEBUG=false,APP_URLsesuai domain;composer installselesai tanpa error,vendorterisi;migrate --forceselesai, semua migrasi DONE;optimizedijalankan, route tanpa Closure bila route cache aktif;public/build/manifest.jsonada bila memakai Vite;- symlink
currentmenunjuk rilis baru dan/upmengembalikan 200; queue:restartdijalankan 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
Posting Komentar