Zero-Downtime Blue-Green Deployment Pipelines for Laravel Applications on OVH
Understanding the Blue-Green Deployment Pattern
The blue-green deployment strategy is a technique for releasing software that minimizes downtime and risk. It involves maintaining two identical production environments, referred to as “Blue” and “Green.” At any given time, one environment (e.g., Blue) is serving live production traffic, while the other (e.g., Green) is idle. To deploy a new version, the new code is deployed to the idle environment (Green). Once tested and validated, traffic is switched from the Blue environment to the Green environment. The Blue environment then becomes the idle environment, ready for the next deployment.
This approach offers several advantages:
- Zero Downtime: Traffic is switched instantaneously, eliminating user-facing downtime.
- Instant Rollback: If issues arise with the new deployment, traffic can be immediately switched back to the previous stable environment (Blue).
- Reduced Risk: The new version is thoroughly tested in a production-like environment before being exposed to live traffic.
Prerequisites and Infrastructure Setup on OVH
For this setup, we’ll assume you have:
- An OVH Public Cloud project.
- At least two identical Virtual Machines (VMs) or Kubernetes nodes that will serve as your “Blue” and “Green” environments. For simplicity, we’ll focus on VMs here, but the principles apply to Kubernetes.
- A load balancer (e.g., OVHcloud Load Balancer or HAProxy) to direct traffic to either the Blue or Green environment.
- SSH access to your VMs.
- A deployment mechanism (e.g., a CI/CD pipeline using GitLab CI, GitHub Actions, or Jenkins).
- Your Laravel application code managed in a Git repository.
- Composer and Node.js/NPM installed on your deployment server and target VMs.
We’ll configure two VMs, let’s call them app-blue-1 and app-green-1. Both will run a web server (Nginx) and PHP-FPM, serving the same Laravel application. A load balancer will sit in front of them.
Configuring the Web Server (Nginx)
We need a consistent Nginx configuration that can serve the Laravel application. The key is to have a mechanism to easily switch the root directory or upstream server block based on which environment is active.
On both app-blue-1 and app-green-1, create a directory for your application code. For example, /var/www/laravel-app. Within this directory, you’ll have subdirectories for each deployment, e.g., /var/www/laravel-app/releases/20231027100000.
Here’s a sample Nginx configuration that uses a symbolic link to point to the current release. This is a common pattern in deployment tools like Capistrano or Deployer.
# /etc/nginx/sites-available/laravel-app.conf
server {
listen 80;
server_name your-domain.com;
root /var/www/laravel-app/current; # This symlink will be managed by the deployment script
index index.php index.html index.htm;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
# Ensure this socket path is correct for your PHP-FPM setup
fastcgi_pass unix:/var/run/php/php8.1-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
# Deny access to hidden files
location ~ /\.ht {
deny all;
}
# Cache static assets for a year
location ~* \.(css|js|jpg|jpeg|gif|png|svg|ico|webp|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public";
}
}
After creating this file, enable the site and restart Nginx:
sudo ln -s /etc/nginx/sites-available/laravel-app.conf /etc/nginx/sites-enabled/ sudo nginx -t sudo systemctl restart nginx
Deployment Scripting with Deployer
We’ll use Deployer, a popular PHP deployment tool, to manage the deployment process. It handles tasks like cloning the repository, running composer/npm, migrating the database, and managing symbolic links.
First, install Deployer on your local machine or a dedicated deployment server:
composer global require deployer/deployer export PATH="$HOME/.config/composer/vendor/bin:$PATH"
Next, create a deploy.php file in the root of your Laravel project:
<?php
namespace Deployer;
require 'recipe/laravel.php';
// Project name
set('application', 'my_laravel_app');
// Production server(s)
set('servers', [
['host' => 'app-blue-1.your-ovh-domain.com', 'user' => 'deployer', 'deploy_path' => '/var/www/laravel-app'],
['host' => 'app-green-1.your-ovh-domain.com', 'user' => 'deployer', 'deploy_path' => '/var/www/laravel-app'],
]);
// Set SSH options if needed (e.g., for key-based authentication)
// set('ssh_options', ['keys' => ['~/.ssh/id_rsa']]);
// Set Git repository
set('repository', '[email protected]:your-username/your-repo.git');
set('branch', 'main'); // Or your deployment branch
// Set the directory where releases will be stored
set('releases_dir', 'releases');
// Set the directory for the current release symlink
set('current_path', 'current');
// Set the directory for shared files and directories
set('shared_dirs', ['storage', 'bootstrap/cache']);
set('shared_files', ['.env']);
// Optional: Set the directory for writable files
set('writable_dirs', ['storage']);
// Optional: Set the directory for logs
set('log_dir', 'logs');
// Laravel specific tasks
// Set the path to the Artisan executable
set('artisan', 'artisan');
// Database migrations
task('artisan:migrate', function () {
run('{{release_path}}/{{bin/php}} {{release_path}}/{{artisan}} migrate --force');
});
// Clear cache
task('artisan:cache:clear', function () {
run('{{release_path}}/{{bin/php}} {{release_path}}/{{artisan}} cache:clear');
});
// Route cache
task('artisan:route:cache', function () {
run('{{release_path}}/{{bin/php}} {{release_path}}/{{artisan}} route:cache');
});
// Config cache
task('artisan:config:cache', function () {
run('{{release_path}}/{{bin/php}} {{release_path}}/{{artisan}} config:cache');
});
// Clear view cache
task('artisan:view:cache', function () {
run('{{release_path}}/{{bin/php}} {{release_path}}/{{artisan}} view:cache');
});
// Composer install
task('deploy:vendors', function () {
// Ensure composer is available on the server
if (!has('previous_release')) {
run('cd {{release_path}} && composer install --no-dev --optimize-autoloader');
} else {
run('cd {{release_path}} && composer install --no-dev --optimize-autoloader --prefer-dist');
}
});
// NPM install and build
task('deploy:npm_install', function () {
// Ensure npm/yarn is available on the server
run('cd {{release_path}} && npm install && npm run build');
});
// Tasks to run after deployment
after('deploy:update_code', 'deploy:vendors');
after('deploy:update_code', 'deploy:npm_install'); // Uncomment if you use npm/yarn for assets
after('deploy:update_code', 'artisan:cache:clear');
after('deploy:update_code', 'artisan:route:cache');
after('deploy:update_code', 'artisan:config:cache');
after('deploy:update_code', 'artisan:view:cache');
after('deploy:symlink', 'artisan:migrate'); // Run migrations after symlinking
// Optional: Add a health check task
task('deploy:healthcheck', function () {
// This is a placeholder. You'd typically make an HTTP request to a health check endpoint.
// For simplicity, we'll just check if the index.php is accessible.
$response = run('curl -s -o /dev/null -w "%{http_code}" http://{{host}}/');
if ($response !== '200') {
writeln("Health check failed for {{host}} with HTTP code: {$response}");
// You might want to trigger a rollback here
// abort("Health check failed.");
} else {
writeln("Health check passed for {{host}}.");
}
});
// After symlinking and before activating, run health check
// after('deploy:symlink', 'deploy:healthcheck');
// Optional: Define a rollback task for migrations if needed
// task('rollback:artisan:migrate', function () {
// // This is complex as you'd need to run inverse migrations.
// // For simplicity, we often rely on reverting the symlink.
// writeln('Rollback for migrations is not automatically handled. Manual intervention may be required.');
// });
// before('rollback', 'rollback:artisan:migrate');
// Default deploy task
desc('Deploys your project');
task('deploy', [
'deploy:prepare',
'deploy:release',
'deploy:update_code',
'deploy:shared',
'deploy:vendors',
'deploy:npm_install', // Uncomment if you use npm/yarn for assets
'deploy:writable',
'artisan:cache:clear',
'artisan:route:cache',
'artisan:config:cache',
'artisan:view:cache',
'deploy:symlink',
'artisan:migrate',
'deploy:cleanup',
'deploy:log_to_file',
])->shallow();
// Optional: Add a task to switch the symlink on the load balancer or a control server
// This is a crucial part of the blue-green switch.
task('deploy:switch_symlink', function () {
// This task would typically be run on a separate control server or the load balancer itself.
// For this example, we'll assume a simple script on a control server that updates a file
// or triggers an API call on the load balancer.
// In a real scenario, you'd interact with your load balancer's API or configuration.
writeln("Simulating traffic switch to the new release...");
// Example: If using a simple file-based switch on a control server:
// runLocally('echo "{{release_path}}" > /path/to/loadbalancer/config/active_release.txt');
// Or if using OVH Load Balancer API, you'd make an API call here.
});
// After a successful deployment, trigger the switch
// after('deploy', 'deploy:switch_symlink');
// If you want to run migrations only on one server (e.g., the "active" one before the switch)
// you might need a more complex setup or a separate task.
// For simplicity, we're running it on all servers here.
// If you have a single database, this is generally fine.
Before running Deployer, ensure your SSH keys are set up for passwordless login to your OVH VMs and that the deployer user has the necessary permissions to write to /var/www/laravel-app.
Implementing the Traffic Switch
The core of blue-green deployment is the traffic switch. On OVH, you can achieve this using:
- OVHcloud Load Balancer: This is the most robust solution. You can configure backend servers and dynamically change which backend pool is active.
- HAProxy: If you manage your own load balancer, HAProxy can be configured to switch traffic.
- DNS Changes: A slower but simpler method is to update DNS records to point to the IP of the active environment. This is not ideal for zero-downtime.
Let’s focus on using the OVHcloud Load Balancer. You’ll typically have two backend pools, one pointing to your “Blue” VMs (e.g., app-blue-1) and another to your “Green” VMs (e.g., app-green-1). The load balancer’s frontend will initially direct traffic to the Blue pool.
The deployment process using Deployer would look like this:
- Deploy to Green: Run Deployer targeting only the Green environment.
- Test Green: Perform automated or manual tests against the Green environment (e.g., using a staging URL or by temporarily directing a small percentage of traffic).
- Switch Traffic: Update the OVH Load Balancer configuration to direct all traffic to the Green backend pool.
- Monitor: Observe application performance and error logs.
- Prepare for Next Deployment: The Blue environment is now idle and ready to receive the next deployment.
To automate the traffic switch, you would typically use the OVHcloud API. You can write a script (e.g., in Python or Bash) that interacts with the API to change the active backend pool.
Here’s a conceptual Python script using the OVH API (you’ll need to install the OVH SDK: pip install ovh):
import ovh
import os
import json
# --- Configuration ---
# Replace with your OVH API credentials and details
CONSUMER_KEY = os.environ.get('OVH_CONSUMER_KEY')
API_KEY = os.environ.get('OVH_API_KEY')
API_SECRET = os.environ.get('OVH_API_SECRET')
ENDPOINT = 'https://api.us.ovhcloud.com/1.0' # Or your region's endpoint
LOAD_BALANCER_SERVICE_NAME = 'your-loadbalancer-service-name' # e.g., 'lb-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
FRONTEND_ID = 12345 # The ID of your frontend configuration
BACKEND_POOL_BLUE_ID = 67890 # The ID of your Blue backend pool
BACKEND_POOL_GREEN_ID = 11223 # The ID of your Green backend pool
# --- OVH API Client Initialization ---
try:
client = ovh.Client(
endpoint=ENDPOINT,
consumer_key=CONSUMER_KEY,
api_key=API_KEY,
api_secret=API_SECRET
)
# Authenticate (if not using persistent keys)
# client.new_consumer_key_request()
# print(f"Please visit this URL to validate your token: {client.url_to_authorize()}")
# client.set_consumer_key(input("Enter the token: "))
# print("Consumer Key:", client.consumer_key)
except Exception as e:
print(f"Error initializing OVH client: {e}")
exit(1)
def get_active_backend_pool(lb_service_name, frontend_id):
"""Retrieves the currently active backend pool for a given frontend."""
try:
response = client.get(f'/cloud/loadbalancer/{lb_service_name}/frontend/{frontend_id}')
return response.get('defaultBackendId')
except ovh.exceptions.APIError as e:
print(f"Error getting frontend details: {e}")
return None
def switch_backend_pool(lb_service_name, frontend_id, target_backend_id):
"""Switches the active backend pool for a frontend."""
try:
# First, get the current frontend configuration
frontend_config = client.get(f'/cloud/loadbalancer/{lb_service_name}/frontend/{frontend_id}')
# Update the defaultBackendId
frontend_config['defaultBackendId'] = target_backend_id
# Put the updated configuration back
client.put(f'/cloud/loadbalancer/{lb_service_name}/frontend/{frontend_id}', **frontend_config)
print(f"Successfully switched frontend {frontend_id} to backend pool {target_backend_id}.")
return True
except ovh.exceptions.APIError as e:
print(f"Error switching backend pool: {e}")
return False
def deploy_and_switch(target_environment):
"""
Performs deployment to the target environment and switches traffic.
This function orchestrates the Deployer command and the API call.
"""
print(f"--- Starting deployment to {target_environment} ---")
# 1. Execute Deployer command for the target environment
# This assumes Deployer is configured to target specific servers or environments.
# You might need to adjust your deploy.php or use tags/aliases.
# Example: deployer.phar production deploy --target=green
# For simplicity, we'll assume a manual deployer run first.
print(f"Please manually run Deployer for the {target_environment} environment.")
print("Example: vendor/bin/dep deploy production --branch=main --limit=1") # Assuming 'production' is defined for green
# 2. Perform health checks on the newly deployed environment
print(f"Performing health checks on {target_environment}...")
# Add your health check logic here. This could involve making HTTP requests.
# For now, we'll assume manual verification or a separate automated check.
# 3. Switch traffic if health checks pass
if target_environment.lower() == 'green':
target_pool_id = BACKEND_POOL_GREEN_ID
print(f"Switching traffic to Green environment (Backend Pool ID: {target_pool_id})...")
if switch_backend_pool(LOAD_BALANCER_SERVICE_NAME, FRONTEND_ID, target_pool_id):
print("Traffic switch successful.")
else:
print("Traffic switch failed. Manual intervention required.")
# Consider triggering a rollback or alert
elif target_environment.lower() == 'blue':
target_pool_id = BACKEND_POOL_BLUE_ID
print(f"Switching traffic back to Blue environment (Backend Pool ID: {target_pool_id})...")
if switch_backend_pool(LOAD_BALANCER_SERVICE_NAME, FRONTEND_ID, target_pool_id):
print("Traffic switch successful.")
else:
print("Traffic switch failed. Manual intervention required.")
else:
print(f"Unknown target environment: {target_environment}")
# --- Example Usage ---
if __name__ == "__main__":
# Example: Deploy to Green and switch traffic
# deploy_and_switch('green')
# Example: Switch traffic back to Blue (e.g., for rollback)
# deploy_and_switch('blue')
# Check current active pool
current_pool = get_active_backend_pool(LOAD_BALANCER_SERVICE_NAME, FRONTEND_ID)
if current_pool:
print(f"Current active backend pool for frontend {FRONTEND_ID}: {current_pool}")
if current_pool == str(BACKEND_POOL_BLUE_ID):
print("Currently serving traffic from Blue environment.")
elif current_pool == str(BACKEND_POOL_GREEN_ID):
print("Currently serving traffic from Green environment.")
else:
print("Unknown active backend pool.")
# To run the switch:
# Make sure your OVH API credentials are set as environment variables:
# export OVH_CONSUMER_KEY='your_consumer_key'
# export OVH_API_KEY='your_api_key'
# export OVH_API_SECRET='your_api_secret'
# python your_script_name.py
# Then uncomment the desired deploy_and_switch call.
pass
You would integrate this script into your CI/CD pipeline. After Deployer successfully deploys to the idle environment, the pipeline would execute this script to perform the API call and switch the load balancer’s active backend pool.
CI/CD Pipeline Integration
A typical CI/CD pipeline for blue-green deployments might look like this:
- Trigger: A push to the main/production branch.
- Build & Test: Run unit tests, integration tests, and static analysis.
- Deploy to Idle Environment: Execute Deployer targeting the currently idle environment (e.g., Green). This includes composer install, npm build, database migrations, and symlinking.
- Automated Health Checks: Run a suite of automated tests against the newly deployed environment using its staging URL or a temporary IP. This could involve tools like Selenium, Cypress, or simple `curl` checks.
- Manual Approval (Optional): A gate for manual QA or stakeholder approval.
- Traffic Switch: Execute the OVH API script to switch the load balancer’s active backend pool.
- Post-Switch Monitoring: Monitor application performance, error rates, and key metrics.
- Rollback (if needed): If issues are detected post-switch, trigger a rollback by switching traffic back to the previously active environment (Blue) and potentially running inverse migrations if applicable.
For example, in GitLab CI, your .gitlab-ci.yml might have stages like:
stages:
- build
- test
- deploy_blue
- deploy_green
- switch_traffic
- monitor
variables:
# Define your OVH API credentials as CI/CD variables
OVH_CONSUMER_KEY: $OVH_CONSUMER_KEY
OVH_API_KEY: $OVH_API_KEY
OVH_API_SECRET: $OVH_API_SECRET
OVH_LOAD_BALANCER_SERVICE_NAME: 'your-lb-service-name'
OVH_FRONTEND_ID: '12345'
OVH_BACKEND_POOL_BLUE_ID: '67890'
OVH_BACKEND_POOL_GREEN_ID: '11223'
# ... other jobs for build and test ...
deploy_to_green:
stage: deploy_green
script:
- echo "Deploying to Green environment..."
# Assuming deployer is installed and configured in your CI environment
# You might need to pass specific server targets or use tags in deploy.php
- vendor/bin/dep deploy production --branch=$CI_COMMIT_REF_NAME --limit=1 # Assuming 'production' targets green
only:
- main # Or your production branch
perform_health_checks_on_green:
stage: deploy_green # Run after deployment to green
script:
- echo "Running health checks on Green environment..."
# Add your automated health check commands here
# Example: curl -f http://staging.your-domain.com/health || exit 1
needs:
- deploy_to_green
only:
- main
switch_to_green:
stage: switch_traffic
script:
- echo "Switching traffic to Green environment..."
- python scripts/ovh_api_switch.py switch_to_green # Your Python script
needs:
- perform_health_checks_on_green
only:
- main
monitor_production:
stage: monitor
script:
- echo "Monitoring production environment..."
# Add your monitoring commands or alerts here
needs:
- switch_to_green
only:
- main
# You would have similar jobs for deploy_to_blue and switch_to_blue for rollback scenarios.
# Rollback logic would typically involve a manual trigger or an automated alert.
Database Considerations
Database management is critical in blue-green deployments. The primary challenge is ensuring schema compatibility between the Blue and Green environments, especially during migrations.
Best Practices:
- Backward-Compatible Migrations: Ensure that new migrations do not break the existing application version. This often means a phased approach:
- Deploy a migration that adds new columns or tables (but doesn’t remove or alter existing ones).
- Deploy the new application code that uses these new columns/tables.
- Deploy a migration that removes old columns/tables (only after the new code is fully deployed and validated).
- Single Database Instance: For simplicity, it’s highly recommended to have a single, shared database instance that both Blue and Green environments connect to. This avoids data synchronization issues.
- Rollback Strategy: Have a clear strategy for rolling back database changes if a deployment fails. This might involve running inverse migrations or restoring from backups. Deployer’s `rollback` task can be extended to handle this, though it can be complex.
When running artisan:migrate with Deployer, it will execute on all specified servers. If you have a single database, this is generally safe. However, if you encounter issues, consider running migrations only on the *newly active* environment after the switch, or on a designated “migration runner” server.
Advanced Strategies and Considerations
Canary Releases: Instead of an instant switch, you can gradually shift traffic using the load balancer. Start with 1% of traffic to Green, monitor, then increase to 10%, 50%, and finally 100%. This further reduces risk.
Health Check Endpoints: Implement robust health check endpoints in your Laravel application (e.g., /health) that check database connectivity, cache status, and other critical services. Your automated tests should hit these endpoints.
Configuration Management: Ensure that environment-specific configurations (like database credentials, API keys) are managed securely. Using .env files managed by Deployer’s `shared_files` is a common approach. For sensitive data, consider using a secrets management system.
Stateful Applications: Blue-green deployments are simpler for stateless applications. For stateful applications (e.g., those with local file storage that needs to persist), you’ll need strategies to handle shared storage or data migration between environments.
Kubernetes: If you’re using Kubernetes, the concept of blue-green deployments can be implemented using Services, Ingress controllers (like Nginx Ingress or Traefik), and potentially tools like Argo Rollouts or Flagger, which automate canary and blue-green strategies.
By combining a robust deployment tool like Deployer with the traffic management capabilities of OVHcloud Load Balancer and a well-defined CI/CD pipeline, you can achieve zero-downtime blue-green deployments for your Laravel applications, significantly improving your release process.