Lucas Fiege

How to deploy a Laravel app with Envoy inside from a GitLab CI pipeline

Photo by tian Kuan on Unsplash

Photo by tian Kuan on Unsplash

In a previous post, I told you about creating a Gitlab CI pipeline for Laravel including Laravel Dusks tests (I still have to translate it). Now it’s time to do a deploy of our app using the power of Laravel Envoy with a GitLab CI.

I will not show now how to configure Laravel Envoy for doing deploys, this topic is for another post.

TL;DR

  1. Create an ssh-key for a deployer user without a password.
  2. Copy the public key to your server.
  3. Copy the private key and paste it into the Gitlab CI Variables as SSH_PRIVATE_KEY.
  4. Configure the Envoy Deploy Variables in your GitLab project.
  5. Use your existing Laravel project with some tests.
  6. Setup the deploy configuration you need and initialize a deploy manually from your computer
  7. Setup the CI configuration in your project.

1. Create a deployer ssh key

Previous requirement: Have a VPS configured for your Laravel app and a user for doing deploy tasks.

Create a new SSH key for your deployer user on your computer, you can do this by running

ssh-keygen -t rsa -b 4096 -C deployer@example.com

This key must not have a password, you can give it the name you want, for this example I gave the following name id_gitlab.pub

2. Copy the public key to your server

Now, you must add the generated public key to your server, to achieve this you can use this command

ssh-copy-id -i ~/.ssh/id_gitlab.pub deployer@serverip

Now you must set the private key in your GitLab CI settings:

3. Copy the private key into the Gitlab CI Variables

You can copy your private key with:

pbcopy < ~/.ssh/id_gitlab

or use this command:

xclip --selection clipboard < ~/.ssh/id_gitlab

now, go to your GitLab project or Group and create a new Variable (Settings > CI/CD > Variables), name it as SSH_PRIVATE_KEY and paste the copied value.

SSH private key variable for a GitLab project or Group

4. Configure the Envoy Deploy Variables in your GitLab project.

Inside your GitLab project, setup the following Envoy Deploy env variables: DEPLOY_BASE_DIR, DEPLOY_REPO, DEPLOY_SERVER, DEPLOY_USER

CI Variables

the content of each variable could be something like this:

DEPLOY_USER=deployer
DEPLOY_SERVER=11.22.33.44
DEPLOY_BASE_DIR=/path/to/the/project
DEPLOY_REPO=git@gitlab.com:myuser/myproject.git

5. Use your existing Laravel project with some tests, and 6. Setup the deploy configuration you need and initialize a deploy manually from your computer

I covered this in this post, but for example, you can use this configuration:

@setup
    require __DIR__.'/vendor/autoload.php';

    $dotenv = Dotenv\Dotenv::createImmutable(__DIR__);

    try {
        $dotenv->load();
        $dotenv->required(['DEPLOY_USER', 'DEPLOY_SERVER', 'DEPLOY_BASE_DIR', 'DEPLOY_REPO'])->notEmpty();
    } catch ( Exception $e )  {
        echo $e->getMessage();
    }

    $user = env('DEPLOY_USER');
    $repo = env('DEPLOY_REPO');

    if (!isset($baseDir)) {
        $baseDir = env('DEPLOY_BASE_DIR');
    }

    if (!isset($branch)) {
        $branch = 'master';
    }

    $releaseDir = $baseDir . '/releases';
    $currentDir = $baseDir . '/current';
    $release = date('YmdHis');
    $currentReleaseDir = $releaseDir . '/' . $release;

    function logMessage($message) {
        return "echo '\033[32m" .$message. "\033[0m';\n";
    }
@endsetup

@servers(['prod' => env('DEPLOY_USER').'@'.env('DEPLOY_SERVER')])

@task('rollback', ['on' => 'prod', 'confirm' => true])
    {{ logMessage("Rolling back...") }}
    cd {{ $releaseDir }}
    ln -nfs {{ $releaseDir }}/$(find . -maxdepth 1 -name "20*" | sort  | tail -n 2 | head -n1) {{ $baseDir }}/current
    {{ logMessage("Rolled back!") }}

    {{ logMessage("Rebuilding cache") }}
    php {{ $currentDir }}/artisan route:cache

    php {{ $currentDir }}/artisan config:cache

    php {{ $currentDir }}/artisan view:cache
    {{ logMessage("Rebuilding cache completed") }}

    echo "Rolled back to $(find . -maxdepth 1 -name "20*" | sort  | tail -n 2 | head -n1)"
@endtask

@task('init', ['on' => 'prod', 'confirm' => true])
if [ ! -d {{ $baseDir }}/current ]; then
    cd {{ $baseDir }}

    git clone {{ $repo }} --branch={{ $branch }} --depth=1 -q {{ $release }}
    {{ logMessage("Repository cloned") }}

    mv {{ $release }}/storage {{ $baseDir }}/storage
    ln -nfs {{ $baseDir }}/storage {{ $release }}/storage
    ln -nfs {{ $baseDir }}/storage/public {{ $release }}/public/storage
    {{ logMessage("Storage directory set up") }}

    cp {{ $release }}/.env.example {{ $baseDir }}/.env
    ln -nfs {{ $baseDir }}/.env {{ $release }}/.env
    {{ logMessage("Environment file set up") }}

    sudo chown -R {{ $user }}:www-data {{ $baseDir }}/storage
    sudo chmod -R ug+rwx {{ $baseDir }}/storage

    rm -rf {{ $release }}
    {{ logMessage("Deployment path initialised. Run 'envoy run deploy' now.") }}
else
    {{ logMessage("Deployment path already initialised (current symlink exists)!") }}
fi
@endtask

@story('deploy', ['on' => 'prod'])
    git
    composer
    npm_install
    npm_run_prod
    update_symlinks
    migrate_release
    set_permissions
    reload_services
    cache
    clean_old_releases
@endstory

@task('git')
    {{ logMessage("Cloning repository") }}

    git clone {{ $repo }} --branch={{ $branch }} --depth=1 -q {{ $currentReleaseDir }}
@endtask

@task('composer')
    {{ logMessage("Running composer") }}

    cd {{ $currentReleaseDir }}

    composer install --no-interaction --quiet --no-dev --prefer-dist --optimize-autoloader
@endtask

@task('npm_install')
    {{ logMessage("NPM install") }}

    cd {{ $currentReleaseDir }}

    npm install --silent --no-progress > /dev/null
@endtask

@task('npm_run_prod')
    {{ logMessage("NPM run prod") }}

    cd {{ $currentReleaseDir }}

    npm run prod --silent --no-progress > /dev/null

    {{ logMessage("Deleting node_modules folder") }}
    rm -rf node_modules
@endtask

@task('update_symlinks')
    {{ logMessage("Updating symlinks") }}

    # Remove the storage directory and replace with persistent data
    {{ logMessage("Linking storage directory") }}
    rm -rf {{ $currentReleaseDir }}/storage;
    cd {{ $currentReleaseDir }};
    ln -nfs {{ $baseDir }}/storage {{ $currentReleaseDir }}/storage;
    ln -nfs {{ $baseDir }}/storage/app/public {{ $currentReleaseDir }}/public/storage

    # Remove the public uploads directory and replace with persistent data
#    {{ logMessage("Linking uploads directory") }}
#    rm -rf {{ $currentReleaseDir }}/public/uploads
#    cd {{ $currentReleaseDir }}/public
#    ln -nfs {{ $baseDir }}/uploads {{ $currentReleaseDir }}/uploads;

    # Import the environment config
    {{ logMessage("Linking .env file") }}
    cd {{ $currentReleaseDir }};
    ln -nfs {{ $baseDir }}/.env .env;

    # Symlink the latest release to the current directory
    {{ logMessage("Linking current release") }}
    ln -nfs {{ $currentReleaseDir }} {{ $currentDir }};
@endtask

@task('set_permissions')
    # Set dir permissions
    {{ logMessage("Set permissions") }}

    sudo chown -R {{ $user }}:www-data {{ $baseDir }}
    sudo chmod -R ug+rwx {{ $baseDir }}/storage
    cd {{ $baseDir }}
    sudo chown -R {{ $user }}:www-data current
    sudo chmod -R ug+rwx current/storage current/bootstrap/cache
    sudo chown -R {{ $user }}:www-data {{ $currentReleaseDir }}
@endtask

@task('cache')
    {{ logMessage("Building cache") }}

    php {{ $currentDir }}/artisan route:cache

    php {{ $currentDir }}/artisan config:cache

    php {{ $currentDir }}/artisan view:cache
@endtask

@task('clean_old_releases')
    # Delete all but the 5 most recent releases
    {{ logMessage("Cleaning old releases") }}
    cd {{ $releaseDir }}
    ls -dt {{ $releaseDir }}/* | tail -n +6 | xargs -d "\n" rm -rf;
@endtask

@task('migrate_release', ['on' => 'prod', 'confirm' => false])
    {{ logMessage("Running migrations") }}

    php {{ $currentReleaseDir }}/artisan migrate --force
@endtask

@task('migrate', ['on' => 'prod', 'confirm' => true])
    {{ logMessage("Running migrations") }}

    php {{ $currentDir }}/artisan migrate --force
@endtask

@task('migrate_rollback', ['on' => 'prod', 'confirm' => true])
    {{ logMessage("Rolling back migrations") }}

    php {{ $currentDir }}/artisan migrate:rollback --force
@endtask

@task('migrate_status', ['on' => 'prod'])
    php {{ $currentDir }}/artisan migrate:status
@endtask

@task('reload_services', ['on' => 'prod'])
    # Reload Services
    {{ logMessage("Restarting service supervisor") }}
    sudo supervisorctl restart all

    {{ logMessage("Reloading php") }}
    sudo systemctl reload php7.3-fpm
@endtask


@finished
    echo "Envoy deployment script finished.\r\n";
@endfinished

7. Setup the CI configuration in your project.

Having a Laravel app with some tests, use the following .gitlab-ci.yml configuration:

stages:
  - build
  - test
  - deploy

image: lsfiege/laravel-php:7.3

.init_ssh: &init_ssh | eval $(ssh-agent -s)
  echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - > /dev/null
  mkdir -p ~/.ssh
  chmod 700 ~/.ssh
  [[ -f /.dockerenv ]] && echo -e "Host *\n\tStrictHostKeyChecking 
  no\n\n" > ~/.ssh/config

variables:
  MYSQL_ROOT_PASSWORD: root
  MYSQL_USER: homestead
  MYSQL_PASSWORD: secret
  MYSQL_DATABASE: homestead
  DB_HOST: mariadb
  DB_CONNECTION: mysql

composer:
  stage: build
  script:
    - cp .env.gitlab .env
    - cp phpunit.xml.gitlab phpunit.xml
    - composer install --no-interaction --quiet --no-scripts --prefer-dist - 
    - optimize-autoloader
    - php artisan dusk:chrome-driver
  cache:
    key: ${CI_COMMIT_REF_SLUG}-composer
    paths:
      - vendor
  artifacts:
    expire_in: 7 days
    paths:
      - vendor/
      - .env
      - phpunit.xml

phpunit:
  stage: test
  dependencies:
    - composer
  services:
    - mariadb:10.3
  script:
    - configure-laravel
    - start-nginx-ci-project
    - ./vendor/bin/phpunit -v --stderr --colors --stop-on-failure --testdox
  artifacts:
    paths:
      - ./storage/logs
      - ./tests/Browser/screenshots
      - ./tests/Browser/console
    expire_in: 7 days
    when: always

dusk:
  stage: test
  dependencies:
    - composer
  services:
    - mariadb:10.3
  script:
    - configure-laravel
    - start-nginx-ci-project
    - nohup ./vendor/laravel/dusk/bin/chromedriver-linux 2>&1 &
    - php artisan dusk --colors --stop-on-failure --testdox
  artifacts:
  paths:
    - ./storage/logs
    - ./tests/Browser/screenshots
    - ./tests/Browser/console
  expire_in: 7 days
  when: always

staging:
  stage: deploy
  script:
    - *init_ssh
    - envoy run deploy
  environment:
    name: staging
    url: https://dev.yourdomain.com
  only:
    - master

production:
  stage: deploy
  script:
    - *init_ssh
    - envoy run deploy
  environment:
    name: production
    url: https://yourdomain.com
 when: manual
  only:
    - master

You can customize this file according to your needs, now you can push your changes to GitLab and see the magic:

Pipeline

Now you can click on the play button to deploy your app, when it finishes you can see the deploy status

Pipeline finished

Also, you can see your environments info in GitLab > Operations

Gitabl Operations

If you liked this post I invite you to support inviting me a coffee

Invitame un café en cafecito.app


almost 4 years ago

Lucas Fiege