I wanted to use Hugo to create this blog, store it on a local Gitea server, and use Drone to deploy it to an nginx container on a VPS node. Here’s how I did it:

Hugo

Hugo is a static site generator that lets you create a site by writing in simple markdown - It handles the rest. Having used other feature-heavy CMS/blogging platforms in the past for other projects, the simplicity of committing a markdown doc to a git repo was really appealing to me for this blog.

Infra

I’m running Gitea and Drone containers behind Traefik on my home server, and deploying the Hugo site to an nginx container behind Traefik on my VPS node. Everything is defined with docker-compose.

Files are pushed to the Gitea repo, Drone picks up those commits, builds the Hugo site, and copies the resulting files to the nginx container on the VPS via rsync

Prereqs

  • A Hugo site. This isn’t a tutorial on building a Hugo site - there are many great tutorials on that already. I’m assuming you already have a site that works on the built-in Hugo server and you want to deploy it.
  • A server to run all of this on. I’m running most of the services on my local server and deploying the Hugo site to a VPS node, but it can all be done on a single host if you want.
  • DNS and SSL for a domain/s that you want your Hugo site to live at. I’m using Cloudflare.

All of my infra is defined in Ansible playbooks, which is what you’ll see below. {{ variables }} should be replaced with whatever makes sense for your setup. For some context:

{{ domain_int }} = my internal LAN domain (example.com)

{{ domain_blog }} = burritovoid.net

{{ puid }}/{{ pgid }} = The user/group id of whichever user you want the service to be run as. (See Understanding PUID and PGID - LSIO)

Traefik (Home Server)

I just switched to using Traefik recently, and I’ve liked it so far. Once you get your head wrapped around it, it’s quick and easy to use. It’s configured to integrate with other docker containers via the docker socket. This config uses Cloudflare for DNS and to validate LetsEncrypt cert generation. Check the Traefik docs for more on that. Here’s what my docker-compose container definition looks like:

networks:
  proxy:
    external: false

services:
  traefik-internal:
    image: "traefik:v2.5"
    container_name: "traefik-internal"
    networks:
      - proxy
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - {{ appdata_path }}/traefik-internal:/etc/traefik
      - /var/run/docker.sock:/var/run/docker.sock:ro
    environment:
      - CF_API_EMAIL={{ cloudflare_api_email }}
      - CF_API_KEY={{ cloudflare_api_key }}
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.traefik-secure.entrypoints=websecure"
      - "traefik.http.routers.traefik-secure.rule=Host(`traefik.{{ domain_int }}`)"
      - "traefik.http.routers.traefik-secure.tls=true"
      - "traefik.http.routers.traefik-secure.tls.certresolver=cloudflare"
      - "traefik.http.routers.traefik-secure.tls.domains[0].main={{ domain_int }}"
      - "traefik.http.routers.traefik-secure.tls.domains[0].sans=*.{{ domain_int }}"
      - "traefik.http.routers.traefik-secure.service=api@internal"
    restart: unless-stopped

Gitea (Home Server)

The code needs to live somewhere. Setting up the Gitea server is pretty straightforward. (I opted for an external MariaDB instance, which isn’t necessary). This is the container definition for mine:

  gitea:
	container_name: gitea
	image: gitea/gitea:1.16.8
	depends_on:
	  - gitea_mariadb
	environment:
	  - USER_UID={{ puid }}
	  - USER_GID={{ pgid }}
	  - TZ={{ tz }}
	  - GITEA__database__DB_TYPE=mysql
	  - GITEA__database__HOST=gitea_mariadb:3306
	  - GITEA__database__NAME=gitea
	  - GITEA__database__USER=gitea
	  - GITEA__database__PASSWD=gitea
	  - GITEA__repository__DEFAULT_BRANCH=main
	  - GITEA__webhook__ALLOWED_HOST_LIST=drone.{{ domain_int }}
	  - ROOT_URL=https://git.{{ domain_int }}
	  - SSH_DOMAIN=git.{{ domain_int }}
	  - APP_NAME=git.{{ domain_int }}
	  - DISABLE_REGISTRATION=true
	  - REQUIRE_SIGNIN_VIEW=true
	restart: unless-stopped
	volumes:
	  - {{ appdata_path }}/gitea:/data
	ports:
	  - 222:22
	networks:
	  - proxy
	labels:
	  - traefik.enable=true
	  - traefik.http.routers.git.entrypoints=websecure
	  - traefik.http.routers.git.rule=Host(`git.{{ domain_int }}`)
	  - traefik.http.services.git.loadbalancer.server.port=3000
	  - traefik.http.routers.git.tls=true
	  - traefik.tcp.routers.git.rule=HostSNI(`*`)
	  - traefik.tcp.routers.git.entrypoints=ssh
	  - traefik.tcp.routers.git.service=git
	  - traefik.tcp.services.git.loadbalancer.server.port=222

  gitea_mariadb:
    image: linuxserver/mariadb:version-10.5.12-r0
    container_name: gitea_mariadb
    environment:
      - PUID={{ puid }}
      - PGID={{ pgid }}
      - TZ={{ tz }}
      - MYSQL_ROOT_PASSWORD=gitea
      - MYSQL_USER=gitea
      - MYSQL_PASSWORD=gitea
      - MYSQL_DATABASE=gitea
    volumes:
      - {{ appdata_path }}/gitea_mariadb:/config
    restart: unless-stopped
    networks:
      - proxy

Make sure the GITEA__webhook__ALLOWED_HOST_LIST variable is set to the domain that the Drone service will live at. This is what Gitea uses to allow the integration - it won’t work otherwise.

At this point, create a repo for your Hugo site and push it to the repo.

Drone CI (Home Server)

Drone is a tool that can automatically build and deploy software from a repository according to predefined rules. In this case, I have it configured to watch my Gitea repo for new commits. To actually build the site, Drone is going to spin up it’s own temporary docker container which requires a runner container to be defined.

Here’s the container definition for that:

	drone:
		image: drone/drone:latest
		container_name: drone
		depends_on:
		  - drone-runner-docker
		volumes:
		  - {{ appdata_path }}/drone:/data
		networks:
		  - proxy
		labels:
		  - traefik.enable=true
		  - traefik.http.routers.drone.entrypoints=websecure
		  - traefik.http.routers.drone.rule=Host(`drone.{{ domain_int }}`)
		  - traefik.http.routers.drone.tls=true
		environment:
		  - DRONE_GITEA_SERVER=https://git.{{ domain_int }}/
		  - DRONE_GIT_ALWAYS_AUTH=true
		  - DRONE_GITEA_CLIENT_ID={{ drone_gitea_client_id }}
		  - DRONE_GITEA_CLIENT_SECRET={{ drone_gitea_client_secret }}
		  - DRONE_SERVER_HOST=drone.{{ domain_int }}
		  - DRONE_SERVER_PROTO=https
		  - DRONE_RPC_SECRET={{ drone_rpc_secret }}
		  - DRONE_USER_CREATE=username:{{ drone_user }},admin:true
		restart: unless-stopped

  	drone-runner-docker:
		image: drone/drone-runner-docker:1
		container_name: drone-runner-docker
		networks:
		  - proxy
		volumes:
		  - /var/run/docker.sock:/var/run/docker.sock
		environment:
		  - DRONE_RPC_PROTO=https
		  - DRONE_RPC_HOST=drone.{{ domain_int }}
		  - DRONE_RPC_SECRET={{ drone_rpc_secret }}
		  - DRONE_RUNNER_CAPACITY=2
		  - DRONE_RUNNER_NAME=runner
		restart: unless-stopped

DRONE_GITEA_CLIENT_ID and DRONE_GITEA_CLIENT_SECRET need to be generated from the Gitea repository where Hugo will live. See the Drone docs for how to generate these.

DRONE_RPC_SECRET is (as far as I can tell) a string you define at container creation time. It can be anything as long as both containers have the same value.

Drone setup

At this point, log into your Drone service. It should automatically prompt you to authenticate using Gitea credentials. Once you’ve authenticated, select the Hugo repo and activate it.

Traefik (VPS)

I have Traefik configured on the VPS a little different from the home server - no dashboard exposure.

networks:
  proxy:
    external: false

services:
  traefik-external:
    image: "traefik:v2.5"
    container_name: "traefik-external"
    networks:
      - proxy
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - {{ appdata_path }}/traefik-external:/etc/traefik
      - /var/run/docker.sock:/var/run/docker.sock:ro
    environment:
      - CF_API_EMAIL={{ cloudflare_api_email }}
      - CF_API_KEY={{ cloudflare_api_key }}
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.traefik-secure.tls.certresolver=cloudflare"
      - "traefik.http.routers.traefik-secure.tls.domains[0].main={{ domain_blog }}"
      - "traefik.http.routers.traefik-secure.tls.domains[0].sans=*.{{ domain_blog }}"
    restart: unless-stopped

Nginx (VPS)

The Hugo site will be served by a simple nginx server:

  hugo:
    image: nginx
    container_name: hugo
    labels:
      - traefik.enable=true
      - traefik.http.routers.hugo.entrypoints=websecure
      - traefik.http.routers.hugo.rule=Host(`blog.{{ domain_blog }}`)
      - traefik.http.services.hugo.loadbalancer.server.port=80
      - traefik.http.routers.hugo.tls=true
    networks:
      - proxy
    volumes:
      - {{ appdata_path }}/hugo:/usr/share/nginx/html
    restart: unless-stopped

{{ appdata_path }}/hugo is where the files generated from Hugo will eventually land.

Rsync

I’m using rsync to move the generated site files to the server. For this you’ll need

  • rsync installed on the VPS
  • Keypair to ssh/scp/rsync to the VPS

Additionally, make sure the user you’ve configured the keypair for has write permissions to the {{ appdata_path }}/hugo directory on the VPS.

Drone configuration

Drone is configured by with a .drone.yml file at the root of your repository. This is loaded by Drone when new commits are pushed to the repository.

This is mine:

---
kind: pipeline
type: docker
name: build
steps:  
  - name: build
    image: plugins/hugo
    settings:
      validate: true
      url: https://blog.burritovoid.net
      theme: hello-friend
      output: /site/
    volumes:
      - name: site
        path: /site
  
  - name: deploy
    image: drillster/drone-rsync
    settings:
      hosts: [ "burritovoid.net" ]
      target: /opt/appdata/hugo
      source: /site/*
      recursive: true
      user: drone
      key:
        from_secret: ssh-key
    volumes:
      - name: site
        path: /site
      
volumes:
  - name: site
    host:
      path: /opt/appdata/hugo

I’ve defined 2 steps - build and deploy - which happen in different docker containers.

The build step uses the Hugo plugin container by cbrgm. Drone handles the repo cloning automatically and passes the resulting repository to this container, which will automatically build the Hugo site and output the result to the specified path (/site/).

I’m using a named volume to store the resulting output between build and deploy steps.

The deploy step is using the rsync plugin container by drillster. This is configured with source/dest and credential info for the rsync job.

A note about paths

I use /opt/appdata as my appdata path on both servers. The named volume at the bottom of the config specifies the appdata path on the home server (where Drone is running). The target defined in the deploy step specifies the path on the VPS server. These don’t have to be the same, they just are in my case.

Drone Secrets

Drone has a secrets management feature which lets you store sensitive info (like your private key) outside of the config. The Drone docs cover how to do this, but I’ll reiterate this because it’s important:

Make sure the “Allow Pull Request” is NOT selected, especially if your git repo is publicly exposed. Checking this box would allow the secret to be used during a pull request, and thus exposed to whoever submits the request.

I’ve named mine ssh-key and referenced it in the deploy step of the drone config.

Trusted Project

Because this build pipeline needs access to volumes mounted on the host, the project needs to be specified as trusted. This can be done in the settings tab of the project in Drone:

Drone config

Putting it all together

At this point, the next push to the Gitea repo should trigger a build and deploy the Hugo site. Nice.

Credit due

I just did most of this for the first time, with the help of these awesome resources: