I am building my Hugo website on a local LXC container, using Gitea and Drone. There are plenty of tutorials on how to connected those two together, so I won’t go through that here.

Instead I want to show you how I build and deploy my staging and production environment to Nginx — using atomic deployments and unique preview URLs.

I will not be covering SSL and certificates here, I am using HAproxy for SSL offloading on both my staging and production servers.
Table of contents

Local build container

I do my Drone builds with a SSH runner, on a local LXC container. I find LXC containers easier to work with than Docker containers — I like having the option to SSH in and configure the environment. Instead of building everything up and tearing it down with every build.

I build both staging and production in the same container, so their environment is identical.

To avoid Hugo having to process all images with every build; I create a symbolic link from my home folder to resources within my Hugo project. This means that all processed assets and images are kept in the container between builds. I like this approach better than checking the processed images into git 👍

Staging

My staging server is also a local LXC container running the Nginx web server. All commits are built and deployed to staging, under a unique URL, and kept for 7 days. Similar to what companies like Netlify and Vercel are offering. This is quite easy to achieve, let’s have a look.

Staging pipeline

The staging build pipeline connects to the build server, using SSH, and performs the following steps:

  • Initialize
    • Clone the website git repository
    • Clone the Hugo theme
    • Create a symbolic link to the resources folder
  • Build
    • Build the site, with drafts
  • Deploy
    • Sync the built site to the staging server — in a new folder named the first 8 characters of the git commit SHA
---
kind: pipeline
type: ssh
name: staging

server:
  host: build.lan.uctrl.net
  user: hebron
  password:
    from_secret: password

steps:
- name: initialize
  commands:
  - git submodule update --init
  - hugo version
  - ln -s /home/hebron/hugo_resources/staging resources

- name: build
  commands:
  - hugo -D

- name: deploy
  commands:
  - rsync -ah --stats public/ staging:/var/www/html/blog/${DRONE_COMMIT_SHA:0:8}

Staging server

A wild card DNS points to the staging server; *.staging.lan.uctrl.net. The Nginx site configuration allows for a wild card in the server name, and uses this to point to the website root.

Example:

blog-44f3cca9.staging.lan.uctrl.net -> /var/www/html/blog/44f3cca9

So all you need to locate the unique URL for a staging deployment; is the git commit SHA.

Here is the Nginx site configuration:

server {
    listen 80;
    server_name ~^blog-(.+)\.staging\.lan\.uctrl\.net$;
    root /var/www/html/blog/$1;

    port_in_redirect off;
    error_page 404 /404.html;

    location = /robots.txt { return 200 "User-agent: *\nDisallow: /\n"; }
}

As the site is deployed, and kept, with every commit — it does pile up after a while…

hebron@staging:~$ ls /var/www/html/blog/
028f5299  210d7abb  3ab19db0  44f3cca9  6241cf25  9330dbdd  cb6c5429  eaef4314
0eea21a3  2bd5f6c1  4170524b  4863e89f  744ee8be  a97619da  d4a11332
1361b832  2d597c28  4435fd98  559812b4  8e90e228  b102d72e  e95432ab

To clean up I use a daily cron job, that deletes folders which are over 6 days old.

hebron@staging:~$ crontab -l
0 2 * * * /usr/bin/find /var/www/html/blog -maxdepth 1 -mtime +6 -type d -exec rm -r {} \;

Production

Now — over to production, which I deploy to a VPS, also running the Nginx web server.

Production pipeline

As with staging; the production build pipeline connects to the build server, using SSH, and performs the following steps:

  • Initialize
    • Clone the website git repository
    • Clone the Hugo theme
    • Create a symbolic link to the resources folder
  • Build
    • Build the site, with garbage collection and minification, and a base URL
  • Deploy
    • Sync the built site to the production server — in a new folder named the first 8 characters of the git commit SHA
    • Create a symbolic link from the newly synced folder to /var/www/html/blog/deployed, overwrite if existing
    • Remove all previous deployment folders that are over 1 day old
---
kind: pipeline
type: ssh
name: production

trigger:
  branch:
  - master

server:
  host: build.lan.uctrl.net
  user: hebron
  password:
    from_secret: password

steps:
- name: initialize
  commands:
  - git submodule update --init
  - hugo version
  - ln -s /home/hebron/hugo_resources/production resources

- name: build
  commands:
  - hugo --gc --minify -b https://blog.cavelab.dev/

- name: vps-deploy
  commands:
  - rsync -ah --stats public/ cirrus:/var/www/html/blog/${DRONE_COMMIT_SHA:0:8}
  - ssh cirrus -f "ln -sfn /var/www/html/blog/${DRONE_COMMIT_SHA:0:8} /var/www/html/blog/deployed"
  - ssh cirrus -f "find /var/www/html/blog/ -maxdepth 1 -mtime +1 -type d -exec rm -r {} \;"

This is what is called a atomic deployment. It just means that there is zero downtime during deployment, the website root is only replaced after the entire site have been synced to the folder.

The switch from the old to the new site happens in a single — or atomic — step.

Production server

No server name wild cards are required there — just serve the deployed web root on the site URL. The symbolic link we created during the build; makes sure that the latest deployment is served.

And, since we’re on production, add some cache-control headers as well 😃

server {
    listen 8080;
    server_name blog.cavelab.dev;
    root /var/www/html/blog/deployed;

    port_in_redirect off;

    location / { 
        add_header Cache-Control "public, max-age=3600";
    }   

    location ~ \.(jpg|jpeg|gif|png)$ {
        add_header Cache-Control "public, s-maxage=7776000, max-age=86400";
    }   

    location ~ \.(css|js)$ {
        add_header Cache-Control "public, max-age=31536000";
    }   

    location /assets/fonts/ {
        add_header Cache-Control "public, s-maxage=7776000, max-age=86400";
    }   

    error_page 404 /404.html;

Since the old deployments are removed after only 1 day, and it happens after each build, this leaves us with a relatively clean web root folder.

hebron@cirrus:~$ ls -l /var/www/html/blog/
drwxr-xr-x 20 hebron hebron 4096 Feb  8 21:04 559812b4
drwxr-xr-x 20 hebron hebron 4096 Feb 10 00:09 cb6c5429
lrwxrwxrwx  1 hebron hebron   27 Feb 10 00:10 deployed -> /var/www/html/blog/cb6c5429
drwxr-xr-x 20 hebron hebron 4096 Feb  9 09:26 e95432ab

And there you have it… 😃 🖖