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:

  1. deploy-review: Deploys the code when changes are pushed to feature or hotfix branches
  2. stop-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)

Debugging

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.

Header Editor

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…