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?
- Why GitHub Actions for Deployment?
- Prerequisites
- Server Setup
- Security Considerations
- Troubleshooting
- Conclusion
- Source Code
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:
- Deploying to a new version while the old version continues to run
- Testing the new version before directing traffic to it
- Gradually shifting traffic from the old version to the new version
- 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:
- Integration with GitHub: Seamless integration with your code repository
- Workflow Automation: Define deployment workflows as code
- Environment Secrets: Secure storage for sensitive deployment credentials
- Matrix Builds: Test across multiple environments simultaneously
- Artifact Management: Store and retrieve build artifacts between jobs
- Community Actions: Leverage pre-built actions for common deployment tasks
Prerequisites
Before implementing ZDD with GitHub Actions, ensure you have:
- A GitHub Repository: Hosting your Laravel application
- A VPS Server: Running Ubuntu/Debian (we'll use Ubuntu in this guide)
- SSH Access: To your VPS server
- Domain Name: Pointed to your server's IP address
- 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 domainSSH_USER
: Your VPS usernameSSH_KEY
: The private SSH key generated aboveSSH_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
-
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
- Check file ownership:
-
Database Migration Failures
- Check database credentials in
.env
- Run migrations manually:
php artisan migrate --force
- Check database credentials in
-
Nginx Configuration Issues
- Test Nginx configuration:
sudo nginx -t
- Check Nginx logs:
sudo tail -f /home/deployer/laravel/shared/storage/logs/nginx_error.log
- Test Nginx configuration:
-
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
- Check PHP-FPM status:
-
Symlink Problems
- Ensure symlinks are created correctly:
ls -la /home/deployer/laravel/current
- Recreate symlinks manually if needed
- Ensure symlinks are created correctly:
Debugging Deployment
To debug deployment issues:
-
Enable Verbose Output
- Add
-v
to composer commands - Add
--verbose
to artisan commands
- Add
-
Check GitHub Actions Logs
- Review the complete workflow logs in GitHub
- Look for specific error messages
-
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!