Cover image for Zero-Downtime Deploy to a VPS with GitHub Actions

Dinesh Wijethunga

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/   ← symlink

Nginx points at /var/www/app/current. When you deploy:

  1. Clone the new release into releases/$(date +%Y%m%d%H%M%S)/
  2. Link shared/.env and shared/storage/ into the new release
  3. Run composer install, php artisan migrate --force, cache config/routes
  4. Flip the current symlink to the new release (atomic — zero downtime)
  5. Reload PHP-FPM
  6. 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_keys

The 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.sh

Branch 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.


Originally published at dineshstack.com — read the full version with code samples and updates there.