Skip to content

Instantly share code, notes, and snippets.

@markshust
Last active August 1, 2025 05:18
Show Gist options
  • Save markshust/e47ae1d202b6b44917db7a40274204ea to your computer and use it in GitHub Desktop.
Save markshust/e47ae1d202b6b44917db7a40274204ea to your computer and use it in GitHub Desktop.
Zero-downtime Laravel deployment with GitHub Actions

GitHub Actions Deploy Script - Environment Variables Setup Guide

This guide explains how to set up the required environment variables (GitHub Secrets) for the Laravel deployment GitHub Actions workflow.

Overview

The deployment script uses GitHub Secrets to securely store sensitive information like SSH keys and server details. These secrets are referenced in the workflow using the ${{ secrets.SECRET_NAME }} syntax.

Required Secrets

1. DEPLOY_SERVER_KEY

Description: The private SSH key used to authenticate with your deployment server.

How to generate:

# On your local machine, generate a new SSH key pair
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/github_deploy_key

# Copy the public key to your server
ssh-copy-id -i ~/.ssh/github_deploy_key.pub your-user@your-server-ip

# Or manually add it to the server's authorized_keys
cat ~/.ssh/github_deploy_key.pub | ssh your-user@your-server-ip "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys"

What to store: Copy the entire contents of the private key file (~/.ssh/github_deploy_key), including the -----BEGIN OPENSSH PRIVATE KEY----- and -----END OPENSSH PRIVATE KEY----- lines.

2. DEPLOY_SERVER_USER

Description: The username for SSH connection to your deployment server.

Example values:

  • deploy
  • ubuntu
  • ec2-user
  • root (not recommended for security)

Best practice: Create a dedicated deployment user with limited permissions:

# On your server
sudo adduser deploy
sudo usermod -aG www-data deploy

3. DEPLOY_SERVER_IP

Description: The IP address or hostname of your deployment server.

Example values:

  • 192.168.1.100
  • your-server.example.com
  • deploy.myapp.com

4. DEPLOY_SERVER_DIR

Description: The directory name where your Laravel application is deployed on the server.

Example values:

  • myapp
  • laravel-app
  • production

Note: This will be used as part of the path /var/www/{DEPLOY_SERVER_DIR}/. The script expects the following directory structure on your server:

/var/www/{DEPLOY_SERVER_DIR}/
├── current/          # Symlink to active release
└── releases/         # Directory containing timestamped releases
    ├── 20240115-143052/
    ├── 20240115-151230/
    └── 20240115-160845/

How to Add Secrets to GitHub

  1. Navigate to your GitHub repository
  2. Click on Settings (in the repository navigation)
  3. In the left sidebar, click Secrets and variablesActions
  4. Click New repository secret
  5. For each secret:
    • Enter the secret name (e.g., DEPLOY_SERVER_KEY)
    • Enter the secret value
    • Click Add secret

Server Prerequisites

Before using this deployment script, ensure your server has:

  1. Required software installed:

    • PHP 8.4 with FPM
    • Composer
    • Node.js 20.x and npm
    • Git
    • Supervisor (for Horizon)
  2. Directory structure created:

    sudo mkdir -p /var/www/{your-app-name}/releases
    sudo chown -R {deploy-user}:www-data /var/www/{your-app-name}
    sudo chmod -R 755 /var/www/{your-app-name}
  3. Initial deployment:

    • The script expects a current symlink to exist
    • For first deployment, manually clone and set up your app:
    cd /var/www/{your-app-name}/releases
    git clone https://github.com/your-username/your-repo.git $(date +%Y%m%d-%H%M%S)
    cd {timestamp-directory}
    composer install
    npm install
    npm run build
    # Configure .env file
    php artisan key:generate
    php artisan migrate
    ln -s /var/www/{your-app-name}/releases/{timestamp-directory} /var/www/{your-app-name}/current
  4. Sudo permissions for the deploy user: Add to /etc/sudoers (use visudo):

    deploy ALL=(ALL) NOPASSWD: /usr/bin/supervisorctl start horizon
    deploy ALL=(ALL) NOPASSWD: /bin/systemctl reload php8.4-fpm
    

Security Best Practices

  1. Use a dedicated deploy user instead of root
  2. Restrict SSH key usage by adding command restrictions in authorized_keys:
    command="cd /var/www/* && $SSH_ORIGINAL_COMMAND",no-port-forwarding,no-X11-forwarding,no-agent-forwarding ssh-ed25519 AAAAC3...
    
  3. Use strong SSH keys (Ed25519 recommended)
  4. Rotate secrets regularly
  5. Limit GitHub Actions to specific branches (already configured for main branch only)

Troubleshooting

SSH Connection Issues

  • Verify the SSH key is correctly formatted (no extra spaces or line breaks)
  • Check server SSH configuration allows key-based authentication
  • Ensure the deploy user has the correct permissions

Permission Errors

  • Verify the deploy user owns the application directory
  • Check that the deploy user is in the www-data group
  • Ensure proper sudo permissions are configured

Build Failures

  • Check Node.js and PHP versions match your local development environment
  • Verify all required PHP extensions are installed on the server
  • Ensure sufficient disk space for builds and releases

Example Complete Setup

Here's a complete example for a Laravel app called "myapp":

  1. Create secrets in GitHub:

    • DEPLOY_SERVER_KEY: (your private SSH key content)
    • DEPLOY_SERVER_USER: deploy
    • DEPLOY_SERVER_IP: 203.0.113.10
    • DEPLOY_SERVER_DIR: myapp
  2. Server directory structure:

    /var/www/myapp/
    ├── current -> /var/www/myapp/releases/20240115-160845
    └── releases/
        └── 20240115-160845/
            ├── app/
            ├── bootstrap/
            ├── config/
            ├── ...
            └── .env
    
  3. Deployment flow:

    • Push to main branch triggers the workflow
    • Script creates a new timestamped release directory
    • Copies current release, pulls latest code
    • Installs dependencies only if changed
    • Builds assets only if needed
    • Runs migrations and caching
    • Atomically switches the current symlink
    • Reloads services
    • Cleans up old releases (keeps last 3)
name: Deploy Laravel App
run-name: ${{ github.event.head_commit.message }}
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Setup SSH
uses: webfactory/[email protected]
with:
ssh-private-key: ${{ secrets.DEPLOY_SERVER_KEY }}
- name: Deploy to server
run: |
ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 ${{ secrets.DEPLOY_SERVER_USER }}@${{ secrets.DEPLOY_SERVER_IP }} "
# Create timestamped release directory
RELEASE_DIR=\"/var/www/${{ secrets.DEPLOY_SERVER_DIR }}/releases/\$(date +%Y%m%d-%H%M%S)\" &&
mkdir -p \"\$RELEASE_DIR\" &&
# Clone current release as base
cp -r /var/www/${{ secrets.DEPLOY_SERVER_DIR }}/current/. \"\$RELEASE_DIR\" &&
cd \"\$RELEASE_DIR\" &&
# Store current dependency and asset hashes
COMPOSER_HASH_OLD=\$(test -f composer.lock && sha256sum composer.lock | cut -d' ' -f1 || echo 'none') &&
NPM_HASH_OLD=\$(test -f package-lock.json && sha256sum package-lock.json | cut -d' ' -f1 || echo 'none') &&
FRONTEND_HASH_OLD=\$(find resources/js resources/css resources/views -type f -name '*.js' -o -name '*.ts' -o -name '*.vue' -o -name '*.css' -o -name '*.scss' -o -name '*.blade.php' 2>/dev/null | sort | xargs cat 2>/dev/null | sha256sum | cut -d' ' -f1 || echo 'none') &&
VITE_CONFIG_HASH_OLD=\$(test -f vite.config.js && sha256sum vite.config.js | cut -d' ' -f1 || echo 'none') &&
# Update code in new release
git reset --hard HEAD && git clean -df &&
git pull origin main &&
# Check if dependencies and assets changed
COMPOSER_HASH_NEW=\$(test -f composer.lock && sha256sum composer.lock | cut -d' ' -f1 || echo 'none') &&
NPM_HASH_NEW=\$(test -f package-lock.json && sha256sum package-lock.json | cut -d' ' -f1 || echo 'none') &&
FRONTEND_HASH_NEW=\$(find resources/js resources/css resources/views -type f -name '*.js' -o -name '*.ts' -o -name '*.vue' -o -name '*.css' -o -name '*.scss' -o -name '*.blade.php' 2>/dev/null | sort | xargs cat 2>/dev/null | sha256sum | cut -d' ' -f1 || echo 'none') &&
VITE_CONFIG_HASH_NEW=\$(test -f vite.config.js && sha256sum vite.config.js | cut -d' ' -f1 || echo 'none') &&
# Install composer dependencies only if changed
if [ \"\$COMPOSER_HASH_OLD\" != \"\$COMPOSER_HASH_NEW\" ]; then
echo 'Composer dependencies changed, installing...' &&
composer install --no-interaction --prefer-dist --no-dev --optimize-autoloader
else
echo 'Composer dependencies unchanged, skipping install'
fi &&
# Install npm dependencies only if changed
if [ \"\$NPM_HASH_OLD\" != \"\$NPM_HASH_NEW\" ]; then
echo 'NPM dependencies changed, installing...' &&
npm ci --cache ~/.npm --prefer-offline
else
echo 'NPM dependencies unchanged, skipping install'
fi &&
# Build frontend assets only if needed
if [ \"\$NPM_HASH_OLD\" != \"\$NPM_HASH_NEW\" ] || [ \"\$FRONTEND_HASH_OLD\" != \"\$FRONTEND_HASH_NEW\" ] || [ \"\$VITE_CONFIG_HASH_OLD\" != \"\$VITE_CONFIG_HASH_NEW\" ] || [ ! -d public/build ]; then
echo 'Frontend assets or dependencies changed, building...' &&
npm run build
else
echo 'Frontend assets unchanged, skipping build'
fi &&
# Run migrations and optimizations in new release
php artisan migrate --force &&
php artisan optimize:clear &&
php artisan config:cache &&
php artisan route:cache &&
# Atomic switch to new release (zero downtime!)
ln -nfs \"\$RELEASE_DIR\" /var/www/${{ secrets.DEPLOY_SERVER_DIR }}/current &&
# Gracefully reload services after switch
php artisan horizon:terminate &&
sudo supervisorctl start horizon &&
sudo systemctl reload php8.4-fpm &&
# Cleanup old releases (keep last 3: current, previous, and one backup)
cd /var/www/${{ secrets.DEPLOY_SERVER_DIR }}/releases &&
OLD_RELEASES=\$(ls -t | tail -n +4) &&
if [ -n \"\$OLD_RELEASES\" ]; then
echo \"Cleaning up old releases: \$OLD_RELEASES\" &&
echo \"\$OLD_RELEASES\" | xargs rm -rf
else
echo \"No old releases to clean up\"
fi
"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment