Automating Laravel Deploys to a VPS with GitHub Actions: Tests, Migrations, and Zero-Downtime Releases
Manual deploys have one weakness that only shows up once you ship often: every step depends on whoever happens to be at the keyboard. SSH in, pull, install dependencies, build assets, migrate, rebuild caches — miss one step or mistype one command, and your visitors find out before you do. This article moves that whole sequence into GitHub Actions, with a deliberate twist that separates it from generic deploy tutorials: the step order is built around what Laravel actually needs, not just around copying files to a server.
It assumes the server groundwork is done: Nginx points at the public directory, PHP-FPM is running, and the release layout follows the no-downtime approach from the earlier article in this series. If that part is not in place yet, follow the links at the end first. Here the focus is automation: every push to main runs tests, builds assets, ships code to the VPS, and activates the new release without overwriting the code that is currently serving traffic.
The shape of the pipeline, before any YAML
One push to main triggers one workflow with two jobs in sequence. The first job, test, checks the code on a GitHub runner: install PHP dependencies, run the test suite, and build front-end assets. It never touches the server. Only when that job passes does the second job, deploy, run. That separation matters: production never receives code that failed its tests, and a broken build shows up in the Actions log instead of in a user complaint.
On the server, a deploy never overwrites the live application directory. Each release gets its own folder under /var/www/my-app/releases/, for example /var/www/my-app/releases/20261026081400. Inside that folder, the .env file and the storage directory are not copies — they are symlinks into a shared/ directory that lives outside any release. Only after everything finishes — dependencies installed, assets built, migrations run, caches rebuilt — does the current symlink move to the new release. Nginx itself stays untouched: your server block keeps pointing at /var/www/my-app/current/public, and only the symlink target changes.
That order matters for Laravel because the three things that most often break a Laravel deploy do not exist for a static site: database migrations that need the --force flag in production, configuration caches that can freeze old .env values if they are not rebuilt, and queue workers still holding old code in memory until they get a restart signal. The workflow below handles all three explicitly.
Step 1: Prepare the directory layout on the server
Run these commands once, over SSH, as your deploy user (not root, if you can avoid it):
sudo mkdir -p /var/www/my-app/{releases,shared}
sudo chown -R deploy:www-data /var/www/my-app
sudo chmod -R 775 /var/www/my-app/sharedReplace deploy with your username and my-app with your project name. What you should see afterwards: an empty releases directory and a shared directory ready to hold .env and storage. Next, place the production .env file at /var/www/my-app/shared/.env and check its contents:
APP_ENV=production
APP_DEBUG=false
APP_URL=https://your-domain.comAPP_DEBUG=false is not a formality. With debugging on in production, a Laravel error page can leak pieces of your configuration. The php artisan config:cache command later on will freeze these values, so verify .env now, not after a failed deploy.
The shared storage tree also needs creating once:
mkdir -p /var/www/my-app/shared/storage/{app/public,framework/{cache/data,sessions,views},logs}A common trap at this stage: giving the directories to root, so the PHP-FPM worker running as www-data cannot write logs. The symptom shows up later as a 500 error whose log never even gets written. Ownership of deploy:www-data with mode 775 avoids that trap from the start.
Step 2: Create a deploy-only SSH key
Do not reuse your personal SSH key for Actions. Generate a pair that serves this deploy path alone:
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/id_ed25519_deployThis produces two files: ~/.ssh/id_ed25519_deploy (the private key — never share it) and ~/.ssh/id_ed25519_deploy.pub (the public key). Copy the public key to the server, into the deploy user's authorized_keys file:
ssh-copy-id -i ~/.ssh/id_ed25519_deploy.pub deploy@YOUR-SERVER-IPTest it from your local machine:
ssh -i ~/.ssh/id_ed25519_deploy deploy@YOUR-SERVER-IP "echo connected"You should see connected printed with no password prompt. If a password is still asked for, check permissions on the server: ~/.ssh must be 700 and authorized_keys must be 600. Fix them with:
chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keysStep 3: Register secrets in the GitHub repository
Open your repository on GitHub, go to Settings → Secrets and variables → Actions, and add these four secrets:
| Secret | Contents |
|---|---|
| SSH_HOST | Your VPS IP or domain |
| SSH_USER | The deploy user, for example deploy |
| SSH_PRIVATE_KEY | The full contents of ~/.ssh/id_ed25519_deploy, including its first and last lines |
| SSH_PORT | The SSH port, usually 22 |
One thing worth understanding about secrets: GitHub redacts their values in logs, but a workflow you write can still leak them if you deliberately print a variable to output. Never add an echo step to inspect a secret's value. If a deploy fails on authentication, check the spelling of the secret names in your YAML first, and make sure the pasted private key is complete rather than cut off at the last line.
Step 4: Write the workflow
Create .github/workflows/deploy.yml in the repository:
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/my-app
RELEASE="$APP/releases/$(date +%Y%m%d%H%M%S)"
git clone --depth 1 --branch main git@github.com:your-username/your-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-fpmNow the parts that decide whether this actually works — the parts generic tutorials usually rush past.
needs: test on the deploy job. Without it, both jobs run in parallel and the server can receive code whose tests failed. With needs, GitHub holds the deploy job until the test job reports success.
concurrency with cancel-in-progress: false. Two pushes to main in quick succession must not trample each other mid-deploy. This setting makes the second run wait in line instead of cancelling the first one halfway through a migration. Cancelling a deploy mid-migration is the fastest way to end up with a half-applied database schema.
set -euo pipefail at the top of the script. If one command fails, the script stops right there. Without it, Bash carries on to the next command as if nothing happened, and the current symlink can swing over to a release whose composer install failed.
ln -sfn followed by mv -Tf. Together they make the symlink swap atomic: there is no moment when current points at nothing. Pointing ln -sfn straight at current looks shorter, but it is less safe when another process reads the symlink at the same instant.
Migrations run before the symlink swap. Migrations execute while the old code is still serving traffic. That is why your migrations must be backward-compatible: adding a column is fine, dropping a column the old code still reads is not. Breaking changes get split across two releases: the first adds the new structure, the next removes the old one once the new code is live. No automation can fix this for you — it lives in how you write migrations.
php artisan optimize and queue:restart. The optimize command rebuilds the configuration, route, event, and view caches for the new release, so no stale cache from the old release carries over. queue:restart signals already-running workers to finish their current job and then stop, so Supervisor starts them again on the new code. Skip that signal and a worker can spend hours processing jobs with old code.
php artisan storage:link. This creates the public/storage symlink pointing at storage/app/public inside the fresh release. Because public is cloned fresh with every release, that symlink cannot be inherited from the previous release — it has to be recreated each time. Miss it and every file uploaded through the public disk returns a 404, even though the files themselves are intact in shared/storage.
Cleaning up old releases. The line ls -1t | tail -n +6 keeps the five newest releases and removes the rest. Without cleanup, a VPS disk slowly fills with release folders. You can adjust the number; the point is to always keep more than one release, because the previous release is your fastest rollback path.
Reloading PHP-FPM. The service name php8.3-fpm in the example matches the PHP version installed on your server — swap 8.3 for yours. A reload, rather than a restart, is chosen so in-flight PHP processes are not cut off abruptly.
Step 5: Test with a real push
Commit the workflow file and push to main. Open the Actions tab in the repository and watch the first run. A healthy sequence looks like this: the test job goes green, the deploy job goes green, and the deploy log shows no errors from composer install or migrate. Then verify from the server:
ls -la /var/www/my-app/current
php artisan --versionThe first command should show the current symlink pointing at the release folder with the newest timestamp. The second, run from inside current, confirms Artisan boots with the new configuration.
Then open the app in a browser and check three things: the homepage loads, one file under /storage/ opens, and one queue-backed action (an emailed notification, for example) actually goes out. If any of those fail, do not guess — the troubleshooting article at the end of this series walks through the diagnosis step by step.
The failures that come up most often
Deploy fails at composer install with a missing PHP extension. The error message usually names the extension. The fix belongs on the server, not in the workflow: install what is asked for, for example sudo apt install php8.3-mbstring php8.3-xml, then re-run the workflow from the Actions tab. No empty commit is needed.
migrate fails because a column already exists. This almost always means a migration ran partway once and is being run again. Check its status first:
cd /var/www/my-app/current
php artisan migrate:statusThat shows which migrations are recorded and which are not, so you can see exactly where it stopped before deciding on a fix.
route:cache fails inside optimize because of a closure route. Routes defined as closures cannot be serialized into the cache. The fix is to move that logic into a controller or an invokable class. Until then, replace php artisan optimize with the individual cache commands — config:cache, view:cache, and event:cache — and skip route:cache.
Deploy succeeds but the app shows the old pages. The usual suspects are OPcache in PHP-FPM still holding old bytecode, or queue workers that never restarted. The PHP-FPM reload and queue:restart at the end of the script cover both; if you removed either from the script, this symptom is what comes back.
The workflow never runs at all. Check three things: the file is really in .github/workflows/ on the main branch (not only on a feature branch), the YAML indentation is valid (two spaces, consistent), and the Actions tab is not disabled for the repository. Bad indentation is the most trivial cause, and also the most common.
Rollback: when a new release has to come back
The release layout makes rollback a symlink move, not a fresh deploy. If a new release misbehaves, point current back at the previous release:
APP=/var/www/my-app
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-fpmOne caveat: rolling back code does not roll back migrations. If the new release's migration already changed the schema in a way the old code cannot work with, moving the symlink back is not enough — you need a separate fix-up migration. That is the other reason backward-compatible migrations got so much space above: they keep rollback a genuinely safe option, not just a theory.
Summary table
| Stage | Key command or setting | Sign of success |
|---|---|---|
| Server layout | mkdir -p /var/www/my-app/{releases,shared} | releases and shared exist, owned by deploy:www-data |
| SSH key | ssh-keygen -t ed25519 + ssh-copy-id | Passwordless SSH prints connected |
| GitHub secrets | SSH_HOST, SSH_USER, SSH_PRIVATE_KEY, SSH_PORT | The workflow can authenticate to the VPS |
| Tests | php artisan test, npm run build | The test job is green before deploy runs |
| Deploy | needs: test, concurrency, release script | The current symlink points at the newest release |
| Migration and cache | migrate --force, optimize, storage:link | Latest schema, fresh caches, /storage/ files reachable |
| Workers | queue:restart, PHP-FPM reload | Jobs run on the new code |
| Rollback | Move the symlink to the previous release | The app returns to the previous version with no fresh deploy |
Final checklist
- The /var/www/my-app/{releases,shared} layout exists with correct ownership
- A deploy-only SSH key is created, its public half installed, and passwordless login tested
- All four GitHub secrets are registered, complete and untruncated
- .github/workflows/deploy.yml sits on the main branch with valid YAML syntax
- The test job runs before deploy via needs: test
- Back-to-back pushes cannot collide, thanks to concurrency
- The deploy script uses set -euo pipefail so it fails early, not halfway
- Migrations are backward-compatible; migrate --force, optimize, storage:link, and queue:restart are all in the script
- The symlink swap uses the ln -sfn + mv -Tf pattern, and old releases are pruned while keeping five
- A trial push went green, and the homepage, a /storage/ file, and one queued job were checked directly
- The symlink rollback procedure is understood before it is ever actually needed
A tidy workflow does not make releases risk-free — it makes the risk visible, ordered, and repeatable with the same result every time. Once this path runs, the next job is not adding complexity but protecting the discipline that keeps it safe: backward-compatible migrations, tests that actually run, and old releases that are never all deleted at once.
Have you ever had a deploy fail halfway through a migration, or a cache that refused to go stale-free? Tell us in the comments which step breaks most often in your setup — those answers shape the next troubleshooting article.
Baca juga: the manual deploy guide this release layout builds on, Deploy Laravel without downtime using Git, Composer, and safe migrations; for the general-purpose Actions pattern, Deploy automatically to a VPS with GitHub Actions; and for container foundations on other projects, Docker Compose for self-hosting from zero to running.


Komentar
Posting Komentar