Skip to main content

CI/CD with local GitLab Runner to automatically deploy a Hugo blog

Rogelio Guerra Riverón
Author
Rogelio Guerra Riverón
Building my own web infrastructure from scratch. Here I document each step: servers, networks, containers and everything that comes along.

Introduction
#

Tired of manually deploying my Hugo blog every time I publish an article. I decided to set up a local CI/CD pipeline with GitLab Runner. The result: automatic, reliable, and without depending on external services.

Prerequisites
#

You need:

  • A server with Docker installed
  • A repository on GitLab (can be self-hosted or gitlab.com)
  • Hugo installed locally for testing
  • SSH access configured on your server

Installing GitLab Runner
#

First, install GitLab Runner on your server. I did it on Docker because I already had the daemon running.

docker pull gitlab/gitlab-runner:latest

docker run -d --name gitlab-runner \
  --restart always \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v /srv/gitlab-runner/config:/etc/gitlab-runner \
  gitlab/gitlab-runner:latest

This mounts the Docker socket so the runner can execute nested containers. Important for building images.

Registering the Runner
#

You need a token from your GitLab project. You can find it at: Configuración del proyecto → CI/CD → Runners

Then you run:

docker exec -it gitlab-runner gitlab-runner register \
  --url https://gitlab.com/ \
  --registration-token TU_TOKEN_AQUI \
  --executor docker \
  --docker-image alpine:latest \
  --docker-volumes /var/run/docker.sock:/var/run/docker.sock \
  --description "Runner Local Hugo"

Choose Docker as the executor. It’s the cleanest option for this case.

Configuring the pipeline
#

At the root of your repository, create .gitlab-ci.yml:

stages:
  - build
  - deploy

variables:
  DEPLOY_PATH: /home/deploy/blog-hugo/public

build:
  stage: build
  image: alpine:latest
  before_script:
    - apk add --no-cache hugo git
  script:
    - hugo --minify
    - echo "Build completado"
  artifacts:
    paths:
      - public/
    expire_in: 1 week
  only:
    - main

deploy:
  stage: deploy
  image: alpine:latest
  before_script:
    - apk add --no-cache openssh-client rsync
    - mkdir -p ~/.ssh
    - echo "$DEPLOY_KEY" | base64 -d > ~/.ssh/id_rsa
    - chmod 600 ~/.ssh/id_rsa
    - ssh-keyscan -H localhost >> ~/.ssh/known_hosts
  script:
    - rsync -avz --delete public/ deploy@localhost:${DEPLOY_PATH}
    - ssh deploy@localhost 'sudo systemctl restart nginx'
  only:
    - main
  when: on_success

Environment variables
#

In Configuración del proyecto → CI/CD → Variables, add:

  • DEPLOY_KEY: Your private SSH key in base64 (cat ~/.ssh/id_rsa | base64 -w0)
  • DEPLOY_PATH: Path where you want the files (I use /home/deploy/blog-hugo/public)

Deploy user
#

On your server, create a specific user:

sudo useradd -m -s /bin/bash deploy
sudo usermod -aG docker deploy
sudo mkdir -p /home/deploy/blog-hugo/public
sudo chown deploy:deploy /home/deploy/blog-hugo

Configure the runner’s public SSH key:

sudo -u deploy ssh-keygen -t ed25519 -N "" -f /home/deploy/.ssh/id_rsa
cat /home/deploy/.ssh/id_rsa.pub >> /home/deploy/.ssh/authorized_keys

Verify it works
#

Push to the main branch:

git add .
git commit -m "Test CI/CD"
git push origin main

In GitLab you see the pipeline in real time. If everything is fine, in seconds your blog will be deployed.

sudo -u deploy cat /home/deploy/blog-hugo/public/index.html

Final notes
#

  • The local runner never leaves your network. Full control.
  • Build times are fast because everything is on the local machine.
  • If you need to cache dependencies, configure persistent volumes in Docker.
  • I’ve put restrictions on the main branch to avoid accidental deployments.

After three months running without issues. It’s simple but effective.


Recommended Equipment#

Affiliate links. No extra cost for you.