Multi-Environment Deployment Based on Git Branches
Introduction⌗
In the development process, we often need to deploy multiple environments for testing. The traditional approach is to create different environments such as development, testing, staging, and production. However, this approach has limitations when multiple features are being developed simultaneously, as they may interfere with each other during testing.
To solve this problem, we can implement multi-environment deployment based on Git branches. This allows each feature branch to have its own independent environment, avoiding interference between different features during testing.
GitLab CI Modification⌗
First, we need to modify the GitLab CI configuration to listen for branch changes and trigger the deployment process. Here’s a sample .gitlab-ci.yml
configuration:
stages:
- deploy
deploy-review:
stage: deploy
image: lorisleiva/laravel-docker:latest
only:
- /^feature\/.*/
- /^hotfix\/.*/
script:
- composer global require deployer/deployer
- export PATH=$HOME/.composer/vendor/bin:$PATH
- dep deploy review --tag=$CI_COMMIT_REF_NAME --env=.env.review -vvv
environment:
name: review/$CI_COMMIT_REF_NAME
url: https://laravel.example.com
on_stop: stop-review
stop-review:
stage: deploy
image: lorisleiva/laravel-docker:latest
variables:
GIT_STRATEGY: none
script:
- composer global require deployer/deployer
- export PATH=$HOME/.composer/vendor/bin:$PATH
- dep review:stop --tag=$CI_COMMIT_REF_NAME -vvv
when: manual
environment:
name: review/$CI_COMMIT_REF_NAME
action: stop
only:
- /^feature\/.*/
- /^hotfix\/.*/
In this configuration, we’ve defined two jobs:
deploy-review
: Deploys the code when changes are pushed to feature or hotfix branchesstop-review
: Manually stops the review environment when it’s no longer needed
The environment
section defines the environment name and URL. The on_stop
parameter specifies the job to run when stopping the environment.
Deployer Configuration⌗
Next, we need to configure Deployer to handle the deployment process. Here’s a sample deploy.php
configuration:
<?php
namespace Deployer;
require 'recipe/laravel.php';
require 'recipe/rsync.php';
// Project name
set('application', 'laravel');
// Project repository
set('repository', '[email protected]:group/project.git');
// [Optional] Allocate tty for git clone. Default value is false.
set('git_tty', false);
// Shared files/dirs between deploys
set('shared_files', [
'.env',
]);
set('shared_dirs', [
'storage',
]);
// Writable dirs by web server
set('writable_dirs', [
'bootstrap/cache',
'storage',
'storage/app',
'storage/app/public',
'storage/framework',
'storage/framework/cache',
'storage/framework/sessions',
'storage/framework/views',
'storage/logs',
]);
// Allow Deployer to use sudo commands
set('allow_anonymous_stats', false);
// Set the default stage to staging
set('default_stage', 'staging');
// Set the project base root directory
set('project_base_root', '/usr/wwwroot/laravel');
// Set the deploy path based on the branch name
set('deploy_path', function () {
$tag = input()->getOption('tag');
if (empty($tag)) {
$tag = 'develop';
}
return '{{project_base_root}}/' . $tag;
});
// Set the GitLab domain for private package installation
set('gitlab_domain', function () {
if (input()->hasOption('gitlab_domain') && !empty(input()->getOption('gitlab_domain'))) {
return input()->getOption('gitlab_domain');
}
return 'gitlab.shenjumiaosuan.com';
});
// Install private composer package from private gitlab server
set('gitlab_personal_token', function () {
if (input()->hasOption('gitlab_personal_token') && !empty(input()->getOption('gitlab_personal_token'))) {
return input()->getOption('gitlab_personal_token');
}
return '';
});
set('bin/php', 'docker exec fpm bash -c "cd {{release_path}} && php"');
// Staging host configuration
host('staging')
->set('port', 22)
->set('hostname', 'xxx.xxx.xxx.xxx')
->set('labels', ['stage' => 'staging'])
->set('http_user', 'www-data')
->set('remote_user', 'root')
->set('keep_releases', 1)
->set('identity_file', '~/.ssh/id_rsa')
->set('forward_agent', true)
->set('ssh_multiplexing', true)
->set('update_code_strategy', 'clone')
->set('ssh_arguments', ['-o UserKnownHostsFile=/dev/null', '-o StrictHostKeyChecking=no'])
->set('composer_install', 'COMPOSER=composer.dev.json composer install --verbose --prefer-dist --no-progress --no-interaction --optimize-autoloader --no-suggest --no-dev');
// Production host configuration
host('production-01')
->set('port', 22)
->set('hostname', 'xxx.xxx.xxx.xxx')
->set('labels', ['stage' => 'production'])
->set('http_user', 'www-data')
->set('remote_user', 'root')
->set('keep_releases', 3)
->set('identity_file', '~/.ssh/id_rsa')
->set('forward_agent', true)
->set('ssh_multiplexing', true)
->set('git_ssh_command', 'ssh') // When the server openssh version less-than 7.4 this config is required
->set('update_code_strategy', 'clone')
->set('ssh_arguments', ['-o UserKnownHostsFile=/dev/null', '-o StrictHostKeyChecking=no'])
->set('composer_install', 'composer install --verbose --prefer-dist --no-progress --no-interaction --optimize-autoloader --no-suggest --no-dev');
host('production-02')
->set('port', 22)
->set('hostname', 'xxx.xxx.xxx.xxx')
->set('labels', ['stage' => 'production'])
->set('http_user', 'www-data')
->set('remote_user', 'root')
->set('keep_releases', 3)
->set('identity_file', '~/.ssh/id_rsa')
->set('forward_agent', true)
->set('ssh_multiplexing', true)
->set('git_ssh_command', 'ssh') // When the server openssh version less-than 7.4 this config is required
->set('update_code_strategy', 'clone')
->set('ssh_arguments', ['-o UserKnownHostsFile=/dev/null', '-o StrictHostKeyChecking=no'])
->set('composer_install', 'composer install --verbose --prefer-dist --no-progress --no-interaction --optimize-autoloader --no-suggest --no-dev');
desc('Composer auth');
task('composer:auth', function () {
run('docker exec fpm bash -c "cd {{release_path}} && composer config gitlab-token.{{gitlab_domain}} {{gitlab_personal_token}}"');
});
//option('tag', null, InputOption::VALUE_REQUIRED, 'Tag to deploy');
desc('Installing vendors');
task('deploy:vendors', function () {
run('docker exec fpm bash -c "cd {{release_path}} && {{composer_install}}"');
});
// Upload .env file to remote server
desc('Upload environment file');
task('env:upload', function () {
$path = input()->getOption('env');
if ($path) {
upload(input()->getOption('env'), '{{deploy_path}}/shared/.env');
}
});
desc('Stop review environment');
task('review:stop', function () {
run('rm -rf {{deploy_path}}');
});
before('deploy:vendors', 'composer:auth');
after('deploy:vendors', 'env:upload');
after('deploy:failed', 'deploy:unlock');
After executing the above deployment configuration, CI will create a directory with the same name as the branch in the $PROJECT_BASE_ROOT
directory on the remote server. For example, if the branch is feature/sso, the final deployment path will be $PROJECT_BASE_ROOT/feature/sso
.
At this point, the service deployment is complete. Next, we need to configure Nginx to access the corresponding project branch based on the X-Branch header.
Nginx Configuration⌗
server {
listen 80;
server_name laravel.example.com;
access_log off;
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
server_name laravel.example.com;
# Set default branch, when user doesn't include X-Branch header, default to develop branch
set $branch develop;
if ($http_x_branch) {
# If X-Branch header is detected, use its value as the branch
set $branch $http_x_branch;
}
# Use the project branch as a path parameter for root
set $base /usr/wwwroot/laravel/$branch/current;
root $base/public;
index index.php;
access_log off;
include components/ssl.conf;
include components/security.conf;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
# additional config
include components/general.conf;
# handle .php
location ~ \.php$ {
root $base/public;
fastcgi_pass fpm:9000;
include components/fastcgi.conf;
}
}
With this configuration, Nginx can route requests to the specified branch’s source code, and new branches can be added without modifying the Nginx configuration. The advantage of this approach is that it routes based on request headers, making the configuration simple without changing the current environment.
Another approach is to use Nginx Unit + PHP-CLI to implement PHP services, as Unit itself supports configuration hot updates through its API, providing more flexibility.
However, we faced two issues:
- Changing the existing PHP-FPM architecture to Unit
- Our project is a mix of frontend and backend, so access is quite dependent on domain names and ports (there’s a lot of hardcoded values in the code, so we didn’t want to change it easily)
Based on these considerations, we ultimately chose the Nginx + PHP-FPM architecture.
Making Requests⌗
To implement branch switching, you need to install the Header Editor plugin in your browser to modify request headers.
After completing these steps, you can implement multi-environment deployment and testing based on branches on the test server.
For pure API projects, we might consider using Unit or developing directly with Go, and using API Gateway based on service discovery to automatically route multi-branch test environments.
I hope this is helpful, Happy hacking…