Deploy Otomatis Laravel ke VPS dengan GitHub Actions: Test, Migrate, dan Rilis Tanpa Downtime
Deploy manual punya satu kelemahan yang baru terasa saat rilis makin sering: tiap langkah bergantung pada ingatan orang yang sedang memegang keyboard. SSH ke server, git pull, composer install, npm run build, migrate, lalu membersihkan cache — satu langkah terlewat, satu perintah salah ketik, dan pengunjung yang menanggung akibatnya. Artikel ini memindahkan seluruh rangkaian itu ke GitHub Actions, dengan satu penekanan yang membedakannya dari tutorial deploy generik: urutan langkahnya dirancang mengelilingi kebutuhan Laravel, bukan sekadar menyalin file ke server.
Asumsinya, fondasi server Anda sudah beres: Nginx mengarah ke direktori public, PHP-FPM berjalan, dan struktur rilis di server sudah mengikuti pola artikel sebelumnya tentang deploy tanpa downtime. Kalau belum, baca dulu panduan tersebut lewat tautan di akhir artikel. Di sini fokusnya adalah otomasi: setiap push ke branch main menjalankan test, membangun aset, mengirim kode ke VPS, lalu mengaktifkan rilis baru tanpa menimpa kode yang sedang melayani trafik.
Gambaran alurnya, sebelum menyentuh YAML
Satu push ke main memicu satu workflow dengan dua job yang berurutan. Job pertama, test, memeriksa kode di runner GitHub: instal dependensi PHP, jalankan test suite, dan membangun aset front-end. Job ini tidak menyentuh server sama sekali. Baru kalau job test lolos, job kedua, deploy, berjalan. Pemisahan ini penting: server produksi tidak pernah menerima kode yang gagal test, dan kegagalan build ketahuan di log Actions, bukan dari keluhan pengunjung.
Di sisi server, deploy tidak menimpa direktori aplikasi yang sedang aktif. Setiap rilis mendapat folder sendiri di bawah /var/www/nama-aplikasi/releases/, misalnya /var/www/nama-aplikasi/releases/20261026081400. Di dalam folder itu, file .env dan direktori storage bukan salinan, melainkan symlink ke direktori shared/ yang hidup di luar rilis. Setelah semua langkah selesai — dependensi terpasang, aset terbangun, migrasi jalan, cache terbentuk — barulah symlink current dipindah ke rilis baru. Nginx sendiri tidak diubah: server block Anda tetap mengarah ke /var/www/nama-aplikasi/current/public, sehingga yang berubah hanya target symlink itu.
Kenapa urutan ini penting untuk Laravel? Karena tiga hal yang paling sering membuat deploy Laravel gagal tidak ada di aplikasi web statis: migrasi basis data yang harus jalan dengan flag --force di produksi, cache konfigurasi yang bisa membekukan nilai .env lama kalau tidak dibangun ulang, dan worker queue yang masih memegang kode lama di memori sampai diberi sinyal restart. Workflow di bawah menangani ketiganya secara eksplisit.
Langkah 1: Siapkan struktur direktori di server
Jalankan perintah berikut sekali saja, lewat SSH, sebagai user deploy Anda (bukan root kalau memungkinkan):
sudo mkdir -p /var/www/nama-aplikasi/{releases,shared}
sudo chown -R deploy:www-data /var/www/nama-aplikasi
sudo chmod -R 775 /var/www/nama-aplikasi/sharedGanti deploy dengan nama user Anda dan nama-aplikasi dengan nama proyek Anda. Hasil yang diharapkan: direktori releases masih kosong, direktori shared siap menampung .env dan storage. Setelah itu, letakkan file .env produksi di /var/www/nama-aplikasi/shared/.env dan pastikan isinya benar:
APP_ENV=production APP_DEBUG=false APP_URL=https://domain-anda.com
Nilai APP_DEBUG=false bukan formalitas. Kalau bernilai true di produksi, halaman error Laravel bisa membocorkan potongan konfigurasi. Perintah php artisan config:cache nanti akan membekukan nilai-nilai ini, jadi periksa .env sekarang, bukan setelah deploy gagal.
Direktori storage bersama juga perlu dibuat sekali:
mkdir -p /var/www/nama-aplikasi/shared/storage/{app/public,framework/{cache/data,sessions,views},logs}Kesalahan umum di tahap ini: memberi kepemilikan direktori ke root, lalu worker PHP-FPM yang berjalan sebagai www-data tidak bisa menulis log. Gejalanya muncul belakangan sebagai error 500 yang log-nya justru tidak tertulis. Kepemilikan deploy:www-data dengan mode 775 menghindari jebakan itu sejak awal.
Langkah 2: Buat kunci SSH khusus deploy
Jangan memakai kunci SSH pribadi Anda untuk Actions. Buat sepasang kunci yang hanya dipakai jalur deploy ini:
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/id_ed25519_deploy
Perintah ini menghasilkan dua file: ~/.ssh/id_ed25519_deploy (kunci privat, jangan pernah dibagikan) dan ~/.ssh/id_ed25519_deploy.pub (kunci publik). Salin kunci publik ke server, ke file authorized_keys milik user deploy:
ssh-copy-id -i ~/.ssh/id_ed25519_deploy.pub deploy@IP-SERVER-ANDA
Uji dari mesin lokal Anda:
ssh -i ~/.ssh/id_ed25519_deploy deploy@IP-SERVER-ANDA "echo terhubung"
Hasil yang diharapkan: baris terhubung tercetak tanpa meminta kata sandi. Kalau masih diminta kata sandi, periksa izin di server: direktori ~/.ssh harus 700 dan file authorized_keys harus 600. Perintah berikut memperbaikinya:
chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys
Langkah 3: Daftarkan secret di repositori GitHub
Buka repositori Anda di GitHub, masuk ke Settings → Secrets and variables → Actions, lalu tambahkan empat secret berikut:
| Secret | Isi |
|---|---|
| SSH_HOST | IP atau domain VPS Anda |
| SSH_USER | User deploy, misalnya deploy |
| SSH_PRIVATE_KEY | Seluruh isi file ~/.ssh/id_ed25519_deploy (termasuk baris pembuka dan penutupnya) |
| SSH_PORT | Port SSH, biasanya 22 |
Satu hal yang wajib dipahami tentang secret: GitHub menyensor nilainya di log, tapi workflow yang Anda tulis tetap bisa membocorkannya kalau Anda mencetak variabel ke output dengan sengaja. Jangan pernah menambahkan langkah echo untuk memeriksa isi secret. Kalau deploy gagal karena autentikasi, periksa dulu ejaan nama secret di YAML dan pastikan kunci privat yang ditempel lengkap, bukan terpotong di baris terakhir.
Langkah 4: Tulis workflow-nya
Buat file .github/workflows/deploy.yml di repositori:
name: Deploy Laravel
on:
push:
branches: [main]
concurrency:
group: deploy-production
cancel-in-progress: false
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, xml, ctype, json, bcmath
coverage: none
- name: Install PHP dependencies
run: composer install --no-interaction --prefer-dist
- name: Run tests
run: php artisan test
- name: Build frontend assets
run: |
npm ci
npm run build
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy to VPS
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: ${{ secrets.SSH_PORT }}
script: |
set -euo pipefail
APP=/var/www/nama-aplikasi
RELEASE="$APP/releases/$(date +%Y%m%d%H%M%S)"
git clone --depth 1 --branch main git@github.com:username-anda/nama-repo.git "$RELEASE"
cd "$RELEASE"
ln -s "$APP/shared/.env" .env
rm -rf storage
ln -s "$APP/shared/storage" storage
composer install --no-dev --optimize-autoloader --no-interaction --prefer-dist
npm ci
npm run build
php artisan storage:link
php artisan migrate --force
php artisan optimize
php artisan queue:restart
ln -sfn "$RELEASE" "$APP/current_tmp"
mv -Tf "$APP/current_tmp" "$APP/current"
cd "$APP/releases"
ls -1t | tail -n +6 | xargs -r rm -rf
sudo systemctl reload php8.3-fpmMari bedah bagian yang paling menentukan, karena di sinilah tutorial generik biasanya berhenti terlalu dini.
needs: test pada job deploy. Tanpa baris ini, kedua job berjalan paralel dan server bisa menerima kode yang test-nya gagal. Dengan needs, GitHub menahan job deploy sampai job test berstatus sukses.
concurrency dengan cancel-in-progress: false. Dua push berurutan ke main tidak boleh saling menimpa di tengah jalan. Pengaturan ini membuat run kedua antre, bukan membatalkan run pertama di tengah migrasi. Membatalkan deploy di tengah migrasi adalah cara tercepat mendapat skema basis data setengah jalan.
set -euo pipefail di awal skrip. Kalau satu perintah gagal, skrip berhenti di tempat. Tanpa ini, skrip Bash melanjutkan ke perintah berikutnya seolah tidak terjadi apa-apa, dan symlink current bisa berpindah ke rilis yang composer-nya gagal.
ln -sfn lalu mv -Tf. Kombinasi ini membuat pergantian symlink menjadi atomik: tidak ada momen ketika current mengarah ke tempat yang tidak ada. Perintah ln -sfn langsung ke current saja terlihat lebih pendek, tapi tidak seaman dua langkah ini ketika ada proses yang membaca symlink di saat yang bersamaan.
Urutan migrate sebelum pergantian symlink. Migrasi jalan selagi kode lama masih melayani trafik. Karena itu migrasi Anda harus backward-compatible: menambah kolom boleh, menghapus kolom yang masih dibaca kode lama tidak boleh. Perubahan yang merusak kompatibilitas dipecah jadi dua rilis: rilis pertama menambah struktur baru, rilis berikutnya menghapus yang lama setelah kode baru aktif. Ini satu-satunya bagian yang tidak bisa diperbaiki oleh otomasi — disiplin ini ada di cara Anda menulis migrasi.
php artisan optimize dan queue:restart. Perintah optimize membangun ulang cache konfigurasi, route, event, dan view untuk rilis baru, sehingga tidak ada cache usang dari rilis lama yang terbawa. Perintah queue:restart memberi sinyal pada worker yang sudah berjalan agar berhenti setelah menyelesaikan job yang sedang diproses, lalu Supervisor menjalankannya lagi dengan kode baru. Tanpa sinyal ini, worker bisa berjam-jam memproses job dengan kode lama.
php artisan storage:link. Perintah ini membuat symlink public/storage ke storage/app/public di dalam rilis baru. Karena direktori public ikut ter-clone segar setiap rilis, symlink ini tidak bisa diwariskan dari rilis lama — ia harus dibuat ulang setiap kali. Kalau dilewatkan, semua file yang diunggah lewat disk public mendadak 404 padahal file-nya masih utuh di shared/storage.
Pembersihan rilis lama. Baris ls -1t | tail -n +6 menyimpan lima rilis terbaru dan menghapus sisanya. Tanpa pembersihan, disk VPS perlahan penuh oleh folder rilis. Angka lima ini bisa Anda sesuaikan; yang penting, selalu sisakan lebih dari satu rilis, karena rilis sebelumnya adalah jalur rollback tercepat Anda.
Reload PHP-FPM. Nama service php8.3-fpm di contoh ini menyesuaikan versi PHP yang terpasang di server Anda — ganti 8.3 dengan versi Anda. Reload, bukan restart, dipilih supaya proses PHP yang sedang berjalan tidak diputus paksa.
Langkah 5: Uji dengan push sungguhan
Commit file workflow, lalu push ke main. Buka tab Actions di repositori dan amati run pertama. Urutan log yang sehat: job test hijau, job deploy hijau, dan di akhir log deploy tidak ada pesan error dari composer install maupun migrate. Setelah itu, verifikasi dari server:
ls -la /var/www/nama-aplikasi/current php artisan --version
Perintah pertama harus menunjukkan symlink current mengarah ke folder rilis dengan stempel waktu terbaru. Perintah kedua, dijalankan dari dalam direktori current, memastikan Artisan bisa boot dengan konfigurasi baru.
Lalu buka aplikasi di browser dan cek tiga hal: halaman utama termuat, satu file di /storage/ bisa dibuka, dan satu aksi yang memakai queue (misalnya email notifikasi) benar-benar terkirim. Kalau salah satu gagal, jangan langsung menebak — artikel troubleshooting di akhir seri ini membahas alur diagnosisnya langkah demi langkah.
Kesalahan yang paling sering muncul
Deploy gagal di composer install karena ekstensi PHP hilang. Pesan error-nya biasanya menyebut ekstensi yang tidak ditemukan. Solusinya ada di server, bukan di workflow: pasang ekstensi yang diminta, misalnya sudo apt install php8.3-mbstring php8.3-xml, lalu jalankan ulang workflow dari tab Actions dengan tombol re-run. Tidak perlu push commit kosong.
migrate gagal karena kolom sudah ada. Ini hampir selalu tanda migrasi pernah jalan sebagian, lalu dijalankan ulang. Cek statusnya dulu:
cd /var/www/nama-aplikasi/current php artisan migrate:status
Perintah ini menampilkan migrasi mana yang sudah tercatat dan mana yang belum, sehingga Anda bisa melihat titik berhentinya sebelum memutuskan perbaikan.
route:cache gagal di dalam optimize karena ada route closure. Route yang didefinisikan sebagai closure tidak bisa diserialisasi ke cache. Solusinya: pindahkan logika closure itu ke controller atau invokable class. Sampai itu dilakukan, ganti php artisan optimize dengan perintah cache yang terpisah — config:cache, view:cache, dan event:cache — sambil melewatkan route:cache.
Deploy sukses tapi aplikasi menampilkan halaman lama. Penyebab paling umum adalah OPcache di PHP-FPM yang masih memegang bytecode lama, atau worker queue yang belum restart. Reload PHP-FPM dan queue:restart di akhir skrip menangani keduanya; kalau Anda menghapus salah satu dari skrip, gejala ini yang kembali.
Workflow tidak berjalan sama sekali. Periksa tiga hal: file benar-benar berada di .github/workflows/ pada branch main (bukan hanya di branch fitur), sintaks YAML valid (indentasi dua spasi, konsisten), dan tab Actions di repositori tidak dinonaktifkan. Kesalahan indentasi adalah penyebab paling sepele sekaligus paling sering.
Rollback: saat rilis baru harus ditarik
Keuntungan struktur rilis adalah rollback tidak memerlukan deploy ulang. Kalau rilis baru bermasalah, arahkan current kembali ke rilis sebelumnya:
APP=/var/www/nama-aplikasi PREVIOUS=$(ls -1t $APP/releases | sed -n 2p) ln -sfn "$APP/releases/$PREVIOUS" "$APP/current_tmp" mv -Tf "$APP/current_tmp" "$APP/current" php artisan queue:restart sudo systemctl reload php8.3-fpm
Catatan penting: rollback kode tidak me-rollback migrasi. Kalau migrasi rilis baru sudah mengubah skema dengan cara yang tidak kompatibel dengan kode lama, mengembalikan symlink saja tidak cukup — Anda perlu migrasi perbaikan tersendiri. Inilah alasan lain kenapa migrasi backward-compatible dibahas panjang di atas: ia membuat rollback tetap menjadi opsi yang aman, bukan sekadar teori.
Tabel ringkasan
| Tahap | Perintah atau pengaturan kunci | Tanda berhasil |
|---|---|---|
| Struktur server | mkdir -p /var/www/nama-aplikasi/{releases,shared} | Direktori releases dan shared ada, milik deploy:www-data |
| Kunci SSH | ssh-keygen -t ed25519 + ssh-copy-id | SSH tanpa kata sandi menampilkan terhubung |
| Secret GitHub | SSH_HOST, SSH_USER, SSH_PRIVATE_KEY, SSH_PORT | Workflow bisa autentikasi ke VPS |
| Test | php artisan test, npm run build | Job test hijau sebelum deploy jalan |
| Deploy | needs: test, concurrency, skrip rilis | Symlink current menunjuk rilis terbaru |
| Migrasi dan cache | migrate --force, optimize, storage:link | Skema terbaru, cache segar, file /storage/ bisa diakses |
| Worker | queue:restart, reload PHP-FPM | Job diproses dengan kode baru |
| Rollback | Pindah symlink ke rilis sebelumnya | Aplikasi kembali ke versi sebelumnya tanpa deploy ulang |
Checklist akhir
- Struktur
/var/www/nama-aplikasi/{releases,shared}sudah dibuat dan kepemilikannya benar - Kunci SSH khusus deploy dibuat, kunci publik terpasang, login tanpa kata sandi sudah diuji
- Empat secret GitHub terdaftar lengkap, tanpa nilai yang terpotong
- File
.github/workflows/deploy.ymlada di branchmaindan sintaks YAML-nya valid - Job
testberjalan sebelum jobdeploylewatneeds: test - Dua push berurutan tidak saling menimpa berkat
concurrency - Skrip deploy memakai
set -euo pipefailagar gagal di awal, bukan setengah jalan - Migrasi ditulis backward-compatible;
migrate --force,optimize,storage:link, danqueue:restartsemua ada di skrip - Pergantian symlink memakai pola
ln -sfn+mv -Tf, dan rilis lama dibersihkan dengan menyisakan lima rilis - Push percobaan sudah hijau, halaman utama, file
/storage/, dan satu job queue sudah dicek langsung - Prosedur rollback lewat symlink sudah dipahami sebelum benar-benar dibutuhkan
Satu workflow yang rapi tidak membuat rilis jadi bebas risiko — ia membuat risiko jadi terlihat, berurutan, dan bisa diulang dengan hasil yang sama. Setelah jalur ini berjalan, pekerjaan berikutnya bukan menambah kerumitan, melainkan menjaga disiplin yang membuat jalur ini aman: migrasi yang kompatibel ke belakang, test yang benar-benar dijalankan, dan rilis lama yang tidak dihapus semua.
Pernahkah Anda mengalami deploy yang gagal di tengah migrasi atau cache yang tidak kunjung segar? Ceritakan langkah mana yang paling sering bermasalah di setup Anda lewat komentar — jawaban Anda bisa jadi bahan artikel troubleshooting berikutnya.
Baca juga: panduan deploy manual yang menjadi dasar struktur rilis di artikel ini, Deploy Laravel tanpa downtime dengan Git, Composer, dan migrasi yang aman; kalau Anda baru menyiapkan servernya, Deploy otomatis ke VPS dengan GitHub Actions menjelaskan pola Actions untuk aplikasi umum; dan untuk fondasi container di proyek lain, Docker Compose untuk self-hosting dari nol sampai jalan.


Komentar
Posting Komentar