GitLab CI/CD of a Nuxt.js frontend over SSH/rsync

August 8th, 2021 by Philip Iezzi 8 min read
cover image

Continuous deployment is great and a must for every modern web app. Forget about the times when you had to constantly log into your production server over SSH to run some git pull based deployment and cumbersome and error-prone build tasks, or building your project locally and then deploying it with rsync to production, which is not that sexy either. It is all doable and scriptable, but we want to have the whole process automated without any manual work involved. I much prefer to use GitLab CI/CD over GitHub Actions - ok, mainly just because I am more into it and prefer to run a self-hosted GitLab instance. GitLab just gets the job done very well!

We want the whole process to be straightforward without any fancy extras. It should be a matter of 15mins to set it up on every new project and I don't like to introduce any extra dependencies. I am just talking about deployment of a static site / SPA, a Vue.js based frontend that is generated by Nuxt.js. So let's keep it simple here! I am going to present you the solution I am using to deploy this TechBlog.

Requirements

OK, don't do it in the most simple way, we still have some basic requirements aside an apple a day (which keeps the doctor away):

  • Building should be done by GitLab CI/CD in Docker executor
  • Generated static site is uploaded to production server via SSH/rsync
  • We want basic versioning with current symlink, keeping the last 5 releases. No fat dependencies like Capistrano, please!
  • Only deploy when pushed to the default (main) branch (or any other branch that fits your workflow, but don't deploy on every branch!)

Manual Deployment

Remember: Building the application, generate every route as a HTML file and statically export to dist/ directory on a Nuxt.js project goes like this (I am preferring yarn over npm, but basically the same):

$ yarn install
$ yarn generate

yarn generate maps to nuxt generate, as we have defined the following in package.json:

{
     "scripts": {
       "dev": "nuxt",
       "build": "nuxt build",
       "start": "nuxt start",
       "generate": "nuxt generate"
     },
     // ...
}

If a local build does not yet work for you (maybe producing some HTML/CSS minification errors...), fix it first locally and study Nuxt.js Commands and Deployment. In this example and on this TechBlog deployment, we are using target: 'static' in nuxt.config.js, that's why we build the site with nuxt generate instead of nuxt build.

On a manual deployment, you would then rsync the whole dist/ content to your production server, e.g.:

$ rsync -aHv --delete dist/ user@example.com:public_html/blog

On production server, you would then point the site root to public_html/blog, but with the automated CD solution below, you would point it to public_html/blog/current, as we're going to create a symlink to the current release.

GitLab CI/CD Setup

Set up CI/CD Variables

Building and deployment is done automatically via GitLab CI/CD, once you have .gitlab-ci.yml (see below) added to your project root. A deployment is always triggered when you push to main branch. But first, set up those CI/CD variables in your GitLab project under Settings > CI/CD > Variables:

  • DEPLOY_HOST (Variable): The production host we want to deploy to.
  • SSH_KNOWN_HOSTS (Variable): ~/.ssh/known_hosts lines on deployment container (Docker)
  • SSH_USER (Variable): The user to connect to the production host.
  • SSH_KEY (File): The deployment SSH private key (copy-paste the whole key starting with -----BEGIN OPENSSH PRIVATE KEY-----\n...).

To get the correct line(s) for SSH_KNOWN_HOSTS variable, run this from a trusted host:

$ ssh-keyscan DEPLOY_HOST

Initially, you create a SSH keypair as follows (ED25519 is the first-preference signature algorithm since OpenSSH 8.5 and has been supported since OpenSSH 6.5):

$ ssh-keygen -t ed25519 -f ~/.ssh/deploy_id_ed25519 -N "" -C "gitlab-ci-deployer"
$ cat ~/.ssh/deploy_id_ed25519.pub

Don't store the private key anywhere else than in $SSH_KEY! Copy the public key (deploy_id_ed25519.pub) to the deploy host's authorized_keys:

$ echo "ssh-ed25519 AAAAC3N... gitlab-ci-deployer" >> ~/.ssh/authorized_keys

Create .gitlab-ci.yml

Ready for the real show? We're using official node Docker Image. node:current is based on Debian Buster (as of Aug 2021), so we can also use apt-get inside the container to install our extras, which is actually just rsync, nothing more.

Create the following .gitlab-ci.yml in your project root. It will use the private SSH key which you have provided in SSH_KEY GitLab CI/CD Variables as a File (NOT as a Variable!), so you don't need to know about the actual location of the key, just use ssh -i ${SSH_KEY} in the deploy commands.

.gitlab-ci.yml
image: node:current

variables:
  # In case you did not set up $SSH_KNOWN_HOSTS variable:
  # SSH_OPTS: '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null'
  SSH_OPTS: ''
  BASE_DIR: public_html/blog
  KEEP: 5

default:
  before_script:
    - mkdir -p ~/.ssh && chmod 700 ~/.ssh
    - echo "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
    - chmod 644 ~/.ssh/known_hosts
    - chmod 600 ${SSH_KEY}
    - export COMMIT_TIME=$(git show -s --format=%ct $CI_COMMIT_SHA)

cache:
  paths:
    - node_modules

build:
  stage: build
  script:
    - cp .env.prod .env
    - yarn install
    - yarn generate
  artifacts:
    paths:
      - dist
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
    - if: $RUN_ALWAYS

deploy:
  stage: deploy
  script:
    - apt-get update -y && apt-get install -y rsync
    - ssh -i ${SSH_KEY} ${SSH_OPTS} ${SSH_USER}@${DEPLOY_HOST} "cd ${BASE_DIR} && test ! -h current || cp -r \$(ls -td */ | grep -v current | head -n1) deploy_tmp"
    - rsync -aHv --delete -e "ssh -i ${SSH_KEY} ${SSH_OPTS}" dist/ ${SSH_USER}@${DEPLOY_HOST}:${BASE_DIR}/deploy_tmp
    - ssh -i ${SSH_KEY} ${SSH_OPTS} ${SSH_USER}@${DEPLOY_HOST} "cd ${BASE_DIR} && mv deploy_tmp ${COMMIT_TIME} && rm -f current && ln -s ${COMMIT_TIME} current"
    - ssh -i ${SSH_KEY} ${SSH_OPTS} ${SSH_USER}@${DEPLOY_HOST} "cd ${BASE_DIR} && rm -rf \$(ls -dt */ | grep -v current | tail -n +$(($KEEP+1)))"
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
    - if: $RUN_ALWAYS

UPDATE 2021-08-18: Above .gitlab-ci.yml can be further optimized, so that we no longer need to provide $RUN_ALWAYS variable to force-trigger a pipeline run on a different branch than default (main / master). I have also combined build and deploy stages into a single one, for improved deployment speed, as we don't need to split this up for this simple project. Like this, dist artifacts are no longer needed:

.gitlab-ci.yml
image: node:current

variables:
  # In case you did not set up $SSH_KNOWN_HOSTS variable:
  # SSH_OPTS: '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null'
  SSH_OPTS: ''
  BASE_DIR: public_html/blog
  KEEP: 5

default:
  before_script:
    - mkdir -p ~/.ssh && chmod 700 ~/.ssh
    - echo "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
    - chmod 644 ~/.ssh/known_hosts
    - chmod 600 ${SSH_KEY}
    - export COMMIT_TIME=$(git show -s --format=%ct $CI_COMMIT_SHA)

cache:
  paths:
    - node_modules

# combined build and deploy step to speed it up
build_deploy:
  stage: deploy
  script:
    # build
    - cp .env.prod .env
    - yarn install
    - yarn generate
    # deploy
    - apt-get update -y && apt-get install -y rsync
    - ssh -i ${SSH_KEY} ${SSH_OPTS} ${SSH_USER}@${DEPLOY_HOST} "cd ${BASE_DIR} && test ! -h current || cp -r \$(ls -td */ | grep -v current | head -n1) deploy_tmp"
    - rsync -aHv --delete -e "ssh -i ${SSH_KEY} ${SSH_OPTS}" dist/ ${SSH_USER}@${DEPLOY_HOST}:${BASE_DIR}/deploy_tmp
    - ssh -i ${SSH_KEY} ${SSH_OPTS} ${SSH_USER}@${DEPLOY_HOST} "cd ${BASE_DIR} && mv deploy_tmp ${COMMIT_TIME} && rm -f current && ln -s ${COMMIT_TIME} current"
    - ssh -i ${SSH_KEY} ${SSH_OPTS} ${SSH_USER}@${DEPLOY_HOST} "cd ${BASE_DIR} && rm -rf \$(ls -dt */ | grep -v current | tail -n +$(($KEEP+1)))"
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
    - if: $CI_PIPELINE_SOURCE == "web"

From now on, once you push to the default (usually main or master) branch, continuous deployment is triggered.

If you explicitely want to skip CI/CD for a single push, use:

$ git push -o ci.skip

GitLab will respect it!

If you wish to run a manual pipeline on a different branch, just do this from Gitlab's Run pipeline form. The $CI_PIPELINE_SOURCE == "web" rule comes into play!

The deployment task cares about basic versioning, so the final result will be such a structure (the Unix timestamp was taken from the latest git commit), while the oldest releases are getting rotated away (see KEEP variable in .gitlab-ci.yml):

public_html/blog
├── 1629150195
├── 1629154265
├── 1629154699
├── 1629188761
├── 1629188770
└── current -> 1629188770

Honestly, the deploy commands don't look that sexy in our .gitlab-ci.yml. But we are perfectly happy with this, especially since we didn't introduce any new monster dependency like Capistrano or similar deployment tools.

Finally, point your production web server's virtual host to public_html/blog/current. No need to restart anything or flush any caches, as this is a static site / SPA only.

Done! Now, go back coding and no longer care about deployment.