Zero-Downtime Deploy to a VPS with GitHub Actions

Managed deployment services like Laravel Forge and Envoyer are excellent and I use them on larger client projects. But you don't need them. Everything they do is SSH commands and symlinks, and you can do those yourself for free.
This is the deployment pattern I use on side projects and small client sites. It costs nothing beyond the VPS ($6/month on Hetzner or DigitalOcean).
The Releases Pattern
Instead of deploying directly to /var/www/app, you deploy to a timestamped folder:
/var/www/app/
├── releases/
│ ├── 20260101120000/ ← previous deployment
│ └── 20260708093045/ ← current deployment
├── shared/
│ ├── .env ← shared across all releases
│ └── storage/ ← shared uploads and logs
└── current -> releases/20260708093045/ ← symlinkNginx points at /var/www/app/current. When you deploy:
- Clone the new release into
releases/$(date +%Y%m%d%H%M%S)/ - Link
shared/.envandshared/storage/into the new release - Run
composer install,php artisan migrate --force, cache config/routes - Flip the
currentsymlink to the new release (atomic — zero downtime) - Reload PHP-FPM
- Delete releases older than the last 5
If anything fails in steps 1–3, the symlink was never flipped. Users see nothing. Rollback is one command: ln -sfn releases/previous current && sudo systemctl reload php8.4-fpm.
Server Setup (One-Time)
# Install PHP 8.4 and extensions
sudo apt-get install -y php8.4-fpm php8.4-cli php8.4-mbstring php8.4-pdo \
php8.4-mysql php8.4-bcmath php8.4-gd php8.4-zip php8.4-intl
# Create directory structure
sudo mkdir -p /var/www/app/{releases,shared}
sudo chown -R www-data:www-data /var/www/app
# Create the deploy user and add SSH key
sudo adduser deploy --disabled-password
sudo mkdir -p /home/deploy/.ssh
# paste your CI public key into authorized_keys
sudo nano /home/deploy/.ssh/authorized_keys
sudo chmod 700 /home/deploy/.ssh
sudo chmod 600 /home/deploy/.ssh/authorized_keysThe Deploy Script
Create scripts/deploy.sh in your repository:
#!/usr/bin/env bash
set -euo pipefail
DEPLOY_PATH="/var/www/app"
RELEASE="$DEPLOY_PATH/releases/$(date +%Y%m%d%H%M%S)"
CURRENT="$DEPLOY_PATH/current"
SHARED="$DEPLOY_PATH/shared"
KEEP_RELEASES=5
echo "→ Creating release: $RELEASE"
mkdir -p "$RELEASE"
cp -r /tmp/app-release/. "$RELEASE/"
echo "→ Linking shared resources"
rm -rf "$RELEASE/storage"
ln -sfn "$SHARED/storage" "$RELEASE/storage"
ln -sfn "$SHARED/.env" "$RELEASE/.env"
echo "→ Installing dependencies"
cd "$RELEASE"
composer install --no-dev --optimize-autoloader --no-interaction --quiet
echo "→ Running migrations"
php artisan migrate --force
echo "→ Caching"
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
echo "→ Flipping symlink"
ln -sfn "$RELEASE" "$CURRENT"
echo "→ Reloading PHP-FPM"
sudo systemctl reload php8.4-fpm
echo "→ Cleaning old releases (keeping $KEEP_RELEASES)"
ls -1dt "$DEPLOY_PATH/releases/"* | tail -n +$((KEEP_RELEASES + 1)) | xargs rm -rf
echo "✓ Deployed: $RELEASE"The GitHub Actions Deploy Job
deploy-production:
name: Deploy to Production
runs-on: ubuntu-latest
needs: tests
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment: production
steps:
- uses: actions/checkout@v4
- name: Upload release to server
uses: appleboy/[email protected]
with:
host: ${{ secrets.VPS_HOST }}
username: deploy
key: ${{ secrets.VPS_SSH_KEY }}
source: "api/."
target: "/tmp/app-release"
- name: Run deploy script
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.VPS_HOST }}
username: deploy
key: ${{ secrets.VPS_SSH_KEY }}
script_stop: true
script: bash /tmp/app-release/scripts/deploy.shBranch Protection: Locking the Gate
Once this is working, go to GitHub → Settings → Branches → Add rule for main:
- ✅ Require status checks to pass before merging
- Select: Code Quality and Tests (PHP 8.4, MySQL 8.4)
- ✅ Require branches to be up to date before merging
Now nothing can reach production without passing your full CI pipeline. This is the complete loop: write code → push → CI validates → deploy. The last post covers what to do when that loop breaks.
Frequently Asked Questions
How does the symlink deploy pattern achieve zero downtime?
How do I roll back a bad Laravel deploy?
Senior Full Stack Developer · Building SaaS products & teaching Laravel/React · 10+ years experience · Founder of Orion360 · Based in Dubai, UAE.
Was this post helpful?
Reviews & Ratings
Sign in to leave a review.