🚀 Zero Downtime Deployment with GitHub Actions: A Complete Guide

Today we'll take the next step and implement a robust Zero Downtime Deployment (ZDD) strategy using GitHub Actions. This approach ensures your application remains available to users even during deployments.

📋 Table of Contents

What is Zero Downtime Deployment?

Zero Downtime Deployment (ZDD) is a deployment strategy that ensures your application remains available to users throughout the entire deployment process. Unlike traditional deployments where the application is completely unavailable during updates, ZDD maintains service continuity by:

  1. Deploying to a new version while the old version continues to run
  2. Testing the new version before directing traffic to it
  3. Gradually shifting traffic from the old version to the new version
  4. Maintaining the old version as a fallback in case of issues

This approach minimizes the risk of service disruption and provides a seamless experience for your users.

Why GitHub Actions for Deployment?

GitHub Actions offers several advantages for implementing ZDD:

  1. Integration with GitHub: Seamless integration with your code repository
  2. Workflow Automation: Define deployment workflows as code
  3. Environment Secrets: Secure storage for sensitive deployment credentials
  4. Matrix Builds: Test across multiple environments simultaneously
  5. Artifact Management: Store and retrieve build artifacts between jobs
  6. Community Actions: Leverage pre-built actions for common deployment tasks

Prerequisites

Before implementing ZDD with GitHub Actions, ensure you have:

  1. A GitHub Repository: Hosting your Laravel application
  2. A VPS Server: Running Ubuntu/Debian (we'll use Ubuntu in this guide)
  3. SSH Access: To your VPS server
  4. Domain Name: Pointed to your server's IP address
  5. SSL Certificate: For secure HTTPS connections (Let's Encrypt recommended)

Server Setup

1. Create Deployment User

First, create a dedicated user for deployments:

# Create a new user
sudo adduser deployer
sudo usermod -aG www-data deployer

# Add permissions for deployer user
sudo echo "deployer ALL=(ALL) NOPASSWD:/usr/bin/chmod, /usr/bin/chown" | sudo tee /etc/sudoers.d/deployer
sudo echo "deployer ALL=(ALL) NOPASSWD:/usr/bin/systemctl restart php8.4-fpm" | sudo tee /etc/sudoers.d/deployer
sudo echo "deployer ALL=(ALL) NOPASSWD:/usr/bin/systemctl restart nginx" | sudo tee /etc/sudoers.d/deployer
sudo echo "deployer ALL=(ALL) NOPASSWD:/usr/bin/systemctl reload nginx" | sudo tee /etc/sudoers.d/deployer
sudo echo "deployer ALL=(ALL) NOPASSWD:/usr/bin/supervisorctl" | sudo tee /etc/sudoers.d/deployer

2. Install Required Software

2.1 Install the necessary software packages:

# Update the system
apt update
apt install nginx php-fpm mariadb-server ufw fail2ban acl supervisor
sudo apt install php8.4-cli php8.4-common php8.4-curl php8.4-xml php8.4-mbstring php8.4-zip php8.4-mysql php8.4-gd php8.4-intl php8.4-bcmath php8.4-redis php8.4-imagick php8.4-pgsql php8.4-sqlite3 php8.4-tokenizer php8.4-dom php8.4-fileinfo php8.4-iconv php8.4-simplexml php8.4-opcache
sudo systemctl restart php8.4-fpm

2.2 Install Node + PM2 for SSR (optional)

# Download and install nvm:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash

# in lieu of restarting the shell
\. "$HOME/.nvm/nvm.sh"

# Download and install Node.js:
nvm install 22

# Install PM2
npm install -g pm2

# Configure PM2
pm2 startup
pm2 save --force

3. Configure Nginx

Create a new Nginx configuration file:

sudo nano /etc/nginx/sites-available/laravel

Add the following configuration:

server {
    listen 80;
    listen [::]:80;
    server_name __;

    root /home/deployer/laravel/current/public;
    index index.php;

    access_log /home/deployer/laravel/shared/storage/logs/nginx_access.log;
    error_log /home/deployer/laravel/shared/storage/logs/nginx_error.log;

    add_header X-Frame-Options "SAMEORIGIN";
    add_header X-Content-Type-Options "nosniff";
    add_header X-XSS-Protection "1; mode=block";
    add_header Referrer-Policy "strict-origin-when-cross-origin";
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header Permissions-Policy "geolocation=(), midi=(), sync-xhr=(), microphone=(), camera=(), magnetometer=(), gyroscope=(), fullscreen=(self), payment=()";
    server_tokens off;

    charset utf-8;
    sendfile on;
    tcp_nopush on;
    tcp_nodelay on;
    keepalive_timeout 65;
    client_max_body_size 100M;

    location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot|webp)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
        log_not_found off;
        try_files $uri =404;
    }

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location = /favicon.ico { access_log off; log_not_found off; }
    location = /robots.txt  { access_log off; log_not_found off; }

    error_page 404 /index.php;

    location ~ ^/index\.php(/|$) {
        fastcgi_pass unix:/var/run/php/php8.4-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;

        fastcgi_buffer_size 128k;
        fastcgi_buffers 4 256k;
        fastcgi_busy_buffers_size 256k;
        fastcgi_read_timeout 300;

        fastcgi_hide_header X-Powered-By;
    }

    location ~ /\.(?!well-known).* {
        deny all;
        access_log off;
        log_not_found off;
    }

    gzip on;
    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_types text/plain text/css text/xml application/json application/javascript application/xml+rss application/atom+xml image/svg+xml;
    gzip_min_length 1024;
    gzip_buffers 16 8k;
    gzip_disable "MSIE [1-6]\.";
}

Enable the site:

sudo ln -s /etc/nginx/sites-available/laravel /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl restart nginx

4. Configure PHP-FPM

Update the PHP-FPM configuration:

sudo nano /etc/php/8.4/fpm/pool.d/www.conf

Add the following configuration:

[www]
user = deployer
group = www-data

listen = /var/run/php/php8.3-fpm.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660

pm = dynamic
pm.max_children = 20
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 6
pm.process_idle_timeout = 10s
pm.max_requests = 500

access.log = /home/deployer/laravel/shared/storage/logs/php-fpm-access.log
slowlog = /home/deployer/laravel/shared/storage/logs/php-fpm-slow.log

php_admin_value[error_log] = /home/deployer/laravel/shared/storage/logs/php-fpm-error.log
php_admin_flag[log_errors] = on

php_admin_value[open_basedir] = /home/deployer/laravel/current/:/home/deployer/laravel/releases/:/home/deployer/laravel/shared/:/tmp/:/var/lib/php/sessions/
php_admin_value[disable_functions] = "exec,passthru,shell_exec,system,proc_open,popen"
php_admin_flag[expose_php] = off
php_admin_value[memory_limit] = 256M
php_admin_value[max_execution_time] = 120

php_admin_value[realpath_cache_size] = 4096K
php_admin_value[realpath_cache_ttl] = 600
php_admin_value[opcache.enable] = 1
php_admin_value[opcache.memory_consumption] = 128

5. Configure PHP

Update the PHP configuration:

sudo nano /etc/php/8.4/fpm/php.ini

Add the following configuration:

[PHP]
expose_php = Off
max_execution_time = 30
max_input_time = 60
memory_limit = 256M
error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT
display_errors = Off
display_startup_errors = Off
log_errors = On
error_log = /home/deployer/laravel/shared/storage/logs/php-error.log

opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=32
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
opcache.enable_cli=0
opcache.jit_buffer_size=256M
opcache.jit=1235

realpath_cache_size=4096K
realpath_cache_ttl=600

session.gc_probability=1
session.gc_divisor=100
session.gc_maxlifetime=1440
session.save_handler = redis
session.save_path = "tcp://127.0.0.1:6379"

upload_max_filesize = 64M
post_max_size = 64M
file_uploads = On

max_input_vars = 5000
request_order = "GP"
variables_order = "GPCS"

[Date]
date.timezone = Europe/Warsaw

6. Set Up Directory Structure

Create the directory structure for deployments:

# Create structure with proper permissions
sudo mkdir -p /home/deployer/laravel/{releases,shared}
sudo chown -R deployer:www-data /home/deployer/laravel
sudo chmod -R 2775 /home/deployer/laravel

# Shared folders setup
sudo mkdir -p /home/deployer/laravel/shared/storage/{app,framework,logs}
sudo mkdir -p /home/deployer/laravel/shared/storage/framework/{cache,sessions,views}
sudo chmod -R 775 /home/deployer/laravel/shared
sudo chmod -R 775 /home/deployer/laravel/shared/storage
sudo chown -R deployer:www-data /home/deployer/laravel/shared/storage

# Set ACL for future files
sudo setfacl -Rdm g:www-data:rwx /home/deployer/laravel

7. Set Up SSH Key for GitHub Actions (as deployer user)

# Create SSH directory
mkdir -p ~/.ssh
chmod 700 ~/.ssh

# Generate SSH key
ssh-keygen -t rsa -b 4096 -C "github-actions-deploy"

# Add public key to authorized_keys
cat ~/.ssh/id_rsa.pub >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys

# Display the private key
cat ~/.ssh/id_rsa

8. Configure Supervisor

# Create Supervisor configuration file
sudo nano /etc/supervisor/conf.d/laravel.con
[program:laravel-worker]
command=/usr/bin/php /home/deployer/laravel/current/artisan queue:work --timeout=3600 --tries=3 --sleep=3 --stop-when-empty
autostart=true
autorestart=true
user=deployer
numprocs=1
stdout_logfile=/home/deployer/laravel/shared/storage/logs/laravel-worker.log
stderr_logfile=/home/deployer/laravel/shared/storage/logs/laravel-worker.log
# Update main Supervisor configuration file
sudo nano /etc/supervisor/supervisord.conf
; supervisor config file

[unix_http_server]
file=/var/run/supervisor.sock   ; (the path to the socket file)
chmod=0700                       ; sockef file mode (default 0700)

[supervisord]
logfile=/var/log/supervisor/supervisord.log ; (main log file;default $CWD/supervisord.log)
pidfile=/var/run/supervisord.pid ; (supervisord pidfile;default supervisord.pid)
childlogdir=/var/log/supervisor            ; ('AUTO' child log dir, default $TEMP)

; the below section must remain in the config file for RPC
; (supervisorctl/web interface) to work, additional interfaces may be
; added by defining them in separate rpcinterface: sections
[rpcinterface:supervisor]
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface

[supervisorctl]
serverurl=unix:///var/run/supervisor.sock ; use a unix:// URL  for a unix socket

; The [include] section can just contain the "files" setting.  This
; setting can list multiple files (separated by whitespace or
; newlines).  It can also contain wildcards.  The filenames are
; interpreted as relative to this file.  Included files *cannot*
; include files themselves.

[include]
files = /etc/supervisor/conf.d/*.con
# Start Supervisor
sudo supervisorctl start all

# Useful commands
sudo supervisorctl reread
sudo supervisorctl  update
sudo supervisorctl restart all
sudo supervisorctl  status

9. Add GitHub Secrets

Add the following secrets to your GitHub repository:

  • SSH_HOST: Your VPS IP address or domain
  • SSH_USER: Your VPS username
  • SSH_KEY: The private SSH key generated above
  • SSH_PORT: The SSH port (default is 22)

Add variable for .env production file

  • ENV_FILE: The contents of your .env file

10. Create GitHub Actions Workflow

Create a new file at .github/workflows/workflow.yml in your repository:

name: Zero Downtime Deployment

on:
  push:
    branches:
      - main

jobs:
  test:
    name: 🧪 Test & Lint
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
          extensions: mbstring, dom, fileinfo, sqlite3, zip, gd, intl, redis, imagick
          coverage: xdebug
          tools: composer:v2

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'

      - name: Copy .env.testing
        run: cp .env.testing .env

      - name: Get Composer cache directory
        id: composer-cache
        run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT

      - name: Cache Composer dependencies
        uses: actions/cache@v4
        with:
          path: |
            vendor
            ${{ steps.composer-cache.outputs.dir }}
          key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
          restore-keys: ${{ runner.os }}-composer-

      - name: Install Composer dependencies
        run: composer install --prefer-dist --no-progress

      - name: Install NPM dependencies
        run: npm ci

      - name: Build assets for tests
        run: npm run build

      - name: Generate Ziggy config for tests
        run: php artisan ziggy:generate

      - name: Run code quality checks
        run: |
          composer larastan
          composer pint
          npm run format
          npm run types
          npm run lint

      - name: Run tests (with Pest)
        env:
          DB_CONNECTION: sqlite
          DB_DATABASE: ':memory:'
          SESSION_DRIVER: array
        run: ./vendor/bin/pest

  build:
    name: 🏗️ Build Release
    needs: test
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Create .env file from GitHub Variables
        run: |
          echo "${{ vars.ENV_FILE }}" > .env

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
          extensions: mbstring, dom, fileinfo, sqlite3, zip, gd, intl, redis, imagick
          tools: composer:v2

      - name: Install Composer dependencies
        run: composer install --optimize-autoloader --no-dev --prefer-dist --no-interaction --no-progress

      - name: Generate Ziggy config
        run: php artisan ziggy:generate

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'

      - name: Install NPM dependencies
        run: npm ci

      - name: Install dotenv-cli
        run: npm install -g dotenv-cli

      - name: Build assets and SSR
        run: dotenv -e .env -- npm run build:ssr

      - name: Create release archive
        run: |
          mkdir release
          shopt -s extglob
          cp -r !(release|.git|tests|node_modules|release.tar.gz) release/
          tar -czf release.tar.gz -C release .
          rm -rf release

      - name: Upload release artifact
        uses: actions/upload-artifact@v4
        with:
          name: release
          path: release.tar.gz

  deploy:
    name: 🚀 Deploy to Server
    needs: build
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup SSH Key
        uses: webfactory/[email protected]
        with:
          ssh-private-key: ${{ secrets.SSH_KEY }}

      - name: Setup known_hosts
        run: |
          mkdir -p ~/.ssh
          ssh-keyscan -p ${{ secrets.SSH_PORT }} ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts

      - name: Download release artifact
        uses: actions/download-artifact@v4
        with:
          name: release
          path: .

      - name: Create .env file from GitHub Variables
        run: |
          echo "${{ vars.ENV_FILE }}" > .env

      - name: Upload release to server
        run: |
          scp -vvv -P ${{ secrets.SSH_PORT }} release.tar.gz ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:/home/${{ secrets.SSH_USER }}/laravel/

      - name: Upload .env file to shared directory
        run: |
          scp -P ${{ secrets.SSH_PORT }} .env ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:/home/${{ secrets.SSH_USER }}/laravel/shared/.env

      - name: Run deploy script on server
        run: |
          ssh -p ${{ secrets.SSH_PORT }} ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} 'bash -s' < ./deploy.sh

11. Deploy Script (deploy.sh)

#!/bin/bash

set -e
set -o pipefail

APP_USER="deployer"
APP_GROUP="www-data"
APP_BASE="/home/$APP_USER/laravel"
RELEASES_DIR="$APP_BASE/releases"
SHARED_DIR="$APP_BASE/shared"
CURRENT_LINK="$APP_BASE/current"
NOW=$(date +%Y-%m-%d-%H%M%S)-$(openssl rand -hex 3)
RELEASE_DIR="$RELEASES_DIR/$NOW"
ARCHIVE_NAME="release.tar.gz"

# Load NVM and get current Node.js version
export NVM_DIR="/home/$APP_USER/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
NODE_VERSION=$(nvm current)
PM2="$NVM_DIR/versions/node/$NODE_VERSION/bin/pm2"

echo "▶️ Using Node.js version: $NODE_VERSION"
echo "▶️ PM2 path: $PM2"

# Verify PM2 exists
if [ ! -f "$PM2" ]; then
    echo "❌ PM2 not found at $PM2"
    exit 1
fi

echo "▶️ Create directories..."
mkdir -p "$RELEASES_DIR" "$SHARED_DIR/storage" "$SHARED_DIR/bootstrap_cache"

mkdir -p "$SHARED_DIR/storage/framework/"{views,cache,sessions}
mkdir -p "$SHARED_DIR/storage/logs"

echo "▶️ Unpacking release..."
mkdir -p "$RELEASE_DIR"
tar -xzf "$APP_BASE/$ARCHIVE_NAME" -C "$RELEASE_DIR"
rm -f "$APP_BASE/$ARCHIVE_NAME"

echo "▶️ Setting up symlinks..."
rm -rf "$RELEASE_DIR/storage"
ln -s "$SHARED_DIR/storage" "$RELEASE_DIR/storage"

rm -rf "$RELEASE_DIR/bootstrap/cache"
ln -s "$SHARED_DIR/bootstrap_cache" "$RELEASE_DIR/bootstrap/cache"

ln -sf "$SHARED_DIR/.env" "$RELEASE_DIR/.env"
ln -sf "$SHARED_DIR/database/database.sqlite" "$RELEASE_DIR/database/database.sqlite"
ln -sf "$SHARED_DIR/public/sitemap.xml" "$RELEASE_DIR/public/sitemap.xml"

echo "▶️ Optimizing application..."
cd "$RELEASE_DIR"
php artisan optimize:clear

# Reset opcache if available
if command -v opcache_reset &> /dev/null; then
    echo "▶️ Resetting OPcache..."
    php -r "opcache_reset();" || true
fi

# Reset Redis cache if available
if command -v redis-cli &> /dev/null; then
    echo "▶️ Flushing Redis cache..."
    redis-cli FLUSHALL || true
fi

php artisan optimize
php artisan storage:link

echo "▶️ Running database migrations..."
php artisan migrate --force

echo "▶️ Managing SSR server with PM2..."
# Stop current SSR server gracefully
$PM2 stop laravel 2>/dev/null || echo "No previous SSR server to stop"

# Update symlink first
echo "▶️ Updating current symlink..."
ln -sfn "$RELEASE_DIR" "$CURRENT_LINK"

echo "▶️ Restarting PHP-FPM to apply new code..."
if sudo systemctl restart php8.3-fpm; then
    echo "✅ PHP-FPM restarted successfully"
else
    echo "❌ Failed to restart PHP-FPM!"
    exit 1
fi

# Start SSR server from new release
cd "$CURRENT_LINK"
echo "▶️ Starting SSR server..."
$PM2 delete laravel 2>/dev/null || true
$PM2 start ecosystem.config.json

# Save PM2 process list
$PM2 save

# Wait a moment for SSR to start
sleep 3

# Verify SSR is running
echo "▶️ Verifying SSR server..."
if ! $PM2 describe laravel &>/dev/null; then
    echo "❌ SSR server failed to start!"
    exit 1
fi

echo "▶️ Cleaning old releases (keeping 5 latest)..."
cd "$RELEASES_DIR"
ls -dt */ | tail -n +6 | xargs -r rm -rf

echo "▶️ Current deployment status:"
$PM2 list

echo "▶️ Restarting Supervisor services..."
sudo supervisorctl restart all

echo "▶️ Checking health status..."
curl http://localhost/health

echo "✅ Deployment successful: $NOW"
exit 0

12. Set Up SSL with Let's Encrypt

Install and configure SSL:

# Install Certbot
sudo apt install -y certbot python3-certbot-nginx

# Obtain SSL certificate
sudo certbot --nginx -d your-domain.com

# Set up auto-renewal
sudo systemctl status certbot.timer

Security Considerations

1. SSH Key Management

Store your SSH keys securely in GitHub Secrets:

# Generate SSH key
ssh-keygen -t rsa -b 4096 -C "github-actions-deploy"

# Add to GitHub Secrets
# SSH_HOST: Your server IP
# SSH_USER: deployer
# SSH_KEY: The private key content

2. Environment Variables

Keep sensitive information in GitHub Secrets:

# Add to GitHub Secrets
# ENV_FILE: The contents of your .env file

3. File Permissions

Ensure proper file permissions:

chmod -R 775 /home/deployer/laravel/current/storage
chmod -R 775 /home/deployer/laravel/current/bootstrap/cache

4. Firewall Configuration

Configure your server's firewall:

sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable

Troubleshooting

Common Issues and Solutions

  1. Permission Denied Errors

    • Check file ownership: sudo chown -R deployer:www-data /home/deployer/laravel
    • Check file permissions: sudo chmod -R 775 /home/deployer/laravel
  2. Database Migration Failures

    • Check database credentials in .env
    • Run migrations manually: php artisan migrate --force
  3. Nginx Configuration Issues

    • Test Nginx configuration: sudo nginx -t
    • Check Nginx logs: sudo tail -f /home/deployer/laravel/shared/storage/logs/nginx_error.log
  4. PHP-FPM Issues

    • Check PHP-FPM status: sudo systemctl status php8.4-fpm
    • Check PHP-FPM logs: sudo tail -f /home/deployer/laravel/shared/storage/logs/php-fpm-error.log
  5. Symlink Problems

    • Ensure symlinks are created correctly: ls -la /home/deployer/laravel/current
    • Recreate symlinks manually if needed

Debugging Deployment

To debug deployment issues:

  1. Enable Verbose Output

    • Add -v to composer commands
    • Add --verbose to artisan commands
  2. Check GitHub Actions Logs

    • Review the complete workflow logs in GitHub
    • Look for specific error messages
  3. SSH into Server During Deployment

    • Add a step to your workflow to keep the connection open
    • Inspect the server state during deployment

Source Code

You can find the complete code for this article on GitHub. Feel free to clone it and use it as a starting point for your own projects.


Follow me on LinkedIn for more Laravel and DevOps content!

Would you like to learn more about any specific aspect of Zero Downtime Deployment? Leave a comment below!

Comments (0)
Leave a comment

© 2025 All rights reserved.