GitLab CI/CD of a Nuxt.js frontend over SSH/rsync
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 preferringyarn
overnpm
, but basically the same):$ yarn install $ yarn generate
yarn generate
maps tonuxt generate
, as we have defined the following inpackage.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
- GitLab CI/CD: SSH keys when using the Docker executor
- GitLab: Generate an SSH key pair
- GitLab: Verifying the SSH host keys
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
.gitlab-ci.yml
Create 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.
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 combinedbuild
anddeploy
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:
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.