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/shared

Ganti 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:

SecretIsi
SSH_HOSTIP atau domain VPS Anda
SSH_USERUser deploy, misalnya deploy
SSH_PRIVATE_KEYSeluruh isi file ~/.ssh/id_ed25519_deploy (termasuk baris pembuka dan penutupnya)
SSH_PORTPort 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-fpm

Mari 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

TahapPerintah atau pengaturan kunciTanda berhasil
Struktur servermkdir -p /var/www/nama-aplikasi/{releases,shared}Direktori releases dan shared ada, milik deploy:www-data
Kunci SSHssh-keygen -t ed25519 + ssh-copy-idSSH tanpa kata sandi menampilkan terhubung
Secret GitHubSSH_HOST, SSH_USER, SSH_PRIVATE_KEY, SSH_PORTWorkflow bisa autentikasi ke VPS
Testphp artisan test, npm run buildJob test hijau sebelum deploy jalan
Deployneeds: test, concurrency, skrip rilisSymlink current menunjuk rilis terbaru
Migrasi dan cachemigrate --force, optimize, storage:linkSkema terbaru, cache segar, file /storage/ bisa diakses
Workerqueue:restart, reload PHP-FPMJob diproses dengan kode baru
RollbackPindah symlink ke rilis sebelumnyaAplikasi 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.yml ada di branch main dan sintaks YAML-nya valid
  • Job test berjalan sebelum job deploy lewat needs: test
  • Dua push berurutan tidak saling menimpa berkat concurrency
  • Skrip deploy memakai set -euo pipefail agar gagal di awal, bukan setengah jalan
  • Migrasi ditulis backward-compatible; migrate --force, optimize, storage:link, dan queue:restart semua 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