About | Blog

Continuous Deployment on a Private Server with Gitlab CI

July 17, 2020

Long time no see!

Let's see how I configured Gitlab CI to be able to deploy this blog on my private server. As already explained, I use deployer for deployments. Under the hood, it performs a simple rsync thanks to the upload task.

The basics

My CI workflow is quite simple. A test stage allows me to merge the PR, and a deploy stage is here to release a new version of this blog. The deploy stage is launched only on master, when a pull request is merged. It looks basically like this:

stages:
  - test
  - deploy

test:
  stage: test
  script:
  - //...

deploy:
  stage: deploy
  only:
    refs:
      - master
  script:
  - // ...

Install deployer on Gitlab CI

This is quite straightforward:

+.install_deployer: &install_deployer
+  - apt-get install -yqq rsync
+  - curl -LO https://deployer.org/deployer.phar
+  - chmod +x deployer.phar

deploy:
  stage: deploy
  only:
    refs:
      - master
+  before_script:
+  - *install_deployer
  script:
  - // ...   

Access the server via SSH

This is the trickiest part. To be able to rsync the files from Gitlab CI to my server, the CI needs to be able to access this server.

For that, I use a dedicated SSH key. Its role is only to deploy on this server, and it has no passphrase. The SSH private key will be added to Gitlab CI's SSH agent.

+.ssh_to_deploy: &ssh_to_deploy
+  - 'which ssh-agent || ( apt-get update -y && apt-get install openssh-client git -y )'
+  - eval $(ssh-agent -s)
+  - echo "$DEPLOY_SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
+  - mkdir -p ~/.ssh
+  - chmod 700 ~/.ssh
+  - ssh-keyscan -p $DEPLOY_PORT $DEPLOY_HOSTNAME >> ~/.ssh/known_hosts
+  - chmod 644 ~/.ssh/known_hosts

deploy:
  stage: deploy
  only:
    refs:
      - master
  before_script:
  - *install_deployer
+  - *ssh_to_deploy
  script:
  - // ... 

The env vars $DEPLOY_SSH_PRIVATE_KEY, $DEPLOY_PORT and $DEPLOY_HOSTNAME must be added to Gitlab CI (section Settings >> CI/CD >> Variables of your project). Whenever they can, those variables must be masked, so that they don't appear in the jobs' logs.

As we want to deploy only on master, all those variables can be protected. To protect the master branch, go to the section Settings >> Repository >> Protected Branches of your project. For your first tests, maybe you'll want to deploy from your pull request, to see if everything goes well. In that case, do not protect those variables right now, do it once your deploy workflow is in place.

Use deployer to release

At the root of my repository, I put a .deployer.yml.dist file that looks like this:

jjanvier.com:
  hostname: _HOSTNAME_
  stage: _STAGE_
  deploy_path: _PATH_
  port: _PORT_
  user: _USER_

I use this file as a template for the CI where I'll replace all the _FOO_ patterns by the associated env var that I defined in my Gitlab project. Once done I can launch the deployment via php deployer.phar deploy production.

deploy:
  stage: deploy
  only:
    refs:
      - master
  before_script:
  - *install_deployer
  - *ssh_to_deploy
+  script:
+  - cp .deployer.yml.dist .deployer.yml
+  - sed -i "s~_HOSTNAME_~$DEPLOY_HOSTNAME~g" .deployer.yml
+  - sed -i "s~_STAGE_~$DEPLOY_STAGE~g" .deployer.yml
+  - sed -i "s~_PATH_~$DEPLOY_PATH~g" .deployer.yml
+  - sed -i "s~_PORT_~$DEPLOY_PORT~g" .deployer.yml
+  - sed -i "s~_USER_~$DEPLOY_USER~g" .deployer.yml
+  - php deployer.phar deploy production

The env vars $DEPLOY_STAGE,$DEPLOY_PATH and $DEPLOY_USER are added, masked and protected to Gitlab CI exactly like explained previously for $DEPLOY_SSH_PRIVATE_KEY, $DEPLOY_PORT and $DEPLOY_HOSTNAME.

Install PHP dependencies

The last missing part of my deployment pipeline is to install PHP dependencies. On purpose, I have no JS or CSS to build.

+.install_composer: &install_composer
+  - apt-get update -yqq
+  - apt-get install -yqq git libmcrypt-dev libpq-dev libcurl4-gnutls-dev libicu-dev libvpx-dev libjpeg-dev libpng-dev libxpm-dev zlib1g-dev libfreetype6-dev libxml2-dev libexpat1-dev libbz2-dev libgmp3-dev libldap2-dev unixodbc-dev libsqlite3-dev libaspell-dev libsnmp-dev libpcre3-dev libtidy-dev libzip-dev
+  - docker-php-ext-install zip
+  - curl -sS https://getcomposer.org/installer | php

deploy:
  stage: deploy
  only:
    refs:
      - master
  before_script:
+  - *install_composer
+  - php composer.phar install --no-ansi --no-dev --no-interaction --no-progress --no-scripts --optimize-autoloader
  - *install_deployer
  - *ssh_to_deploy
  script:
  - cp .deployer.yml.dist .deployer.yml
  - sed -i "s~_HOSTNAME_~$DEPLOY_HOSTNAME~g" .deployer.yml
  - sed -i "s~_STAGE_~$DEPLOY_STAGE~g" .deployer.yml
  - sed -i "s~_PATH_~$DEPLOY_PATH~g" .deployer.yml
  - sed -i "s~_PORT_~$DEPLOY_PORT~g" .deployer.yml
  - sed -i "s~_USER_~$DEPLOY_USER~g" .deployer.yml
  - php deployer.phar deploy production

To wrap up

My final .gitlab-ci.yml looks like this:

image: php:7.2

.install_composer: &install_composer
  - apt-get update -yqq
  - apt-get install -yqq git libmcrypt-dev libpq-dev libcurl4-gnutls-dev libicu-dev libvpx-dev libjpeg-dev libpng-dev libxpm-dev zlib1g-dev libfreetype6-dev libxml2-dev libexpat1-dev libbz2-dev libgmp3-dev libldap2-dev unixodbc-dev libsqlite3-dev libaspell-dev libsnmp-dev libpcre3-dev libtidy-dev libzip-dev
  - docker-php-ext-install zip
  - curl -sS https://getcomposer.org/installer | php

.install_deployer: &install_deployer
  - apt-get install -yqq rsync
  - curl -LO https://deployer.org/deployer.phar
  - chmod +x deployer.phar

.ssh_to_deploy: &ssh_to_deploy
  - 'which ssh-agent || ( apt-get update -y && apt-get install openssh-client git -y )'
  - eval $(ssh-agent -s)
  - echo "$DEPLOY_SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
  - mkdir -p ~/.ssh
  - chmod 700 ~/.ssh
  - ssh-keyscan -p $DEPLOY_PORT $DEPLOY_HOSTNAME >> ~/.ssh/known_hosts
  - chmod 644 ~/.ssh/known_hosts

stages:
  - test
  - deploy

test:
  stage: test
  before_script:
  - *install_composer
  - php composer.phar install --no-ansi --no-interaction --no-progress --no-scripts
  script:
  - ...

deploy:
  stage: deploy
  only:
    refs:
      - master
  before_script:
  - *install_composer
  - php composer.phar install --no-ansi --no-dev --no-interaction --no-progress --no-scripts --optimize-autoloader
  - *install_deployer
  - *ssh_to_deploy
  script:
  - cp .deployer.yml.dist .deployer.yml
  - sed -i "s~_HOSTNAME_~$DEPLOY_HOSTNAME~g" .deployer.yml
  - sed -i "s~_STAGE_~$DEPLOY_STAGE~g" .deployer.yml
  - sed -i "s~_PATH_~$DEPLOY_PATH~g" .deployer.yml
  - sed -i "s~_PORT_~$DEPLOY_PORT~g" .deployer.yml
  - sed -i "s~_USER_~$DEPLOY_USER~g" .deployer.yml
  - php deployer.phar deploy production

It works like a charm. Everytime a pull request is performed, this blog is automatically updated. Maybe this will help me to write more 😛