This site is written using a simple offline stack: Hugo as a static site generator and Ansible as a deployment mechanism taking care of syncing things to my VPS.

I used Claude to carry out some of the initial scaffolding and busywork to get things up and running quickly, with a clear brief on what I wanted the workflow to look like.


For writing articles, I exclusively use Neovim running with my personal combination of plugins and scripts. It’s my day-to-day editor for most things because of its extensibility and speed. All articles are written in Markdown as you might expect these days.

To preview and refine articles locally prior to publishing, I use a Docker Compose setup which allows me to run a Hugo instance, mounted on my local copy of the blog repository root.

The docker-compose.yml looks like this:

services:
  # Dev: hugo's built-in server with hot reload. Edit content/, layouts/, static/ —
  # the browser refreshes automatically. No Caddy involved in dev.
  blog:
    image: hugomods/hugo:latest
    container_name: blog-hugo-dev
    # Run as the host user/group so any cache or generated-resource files
    # Hugo writes to the bind-mounted repo are owned by the host user, not
    # root. Without this, prod deploy fails when Hugo's --cleanDestinationDir
    # can't remove root-owned leftovers in public/.
    user: "${UID:-1000}:${GID:-1000}"
    working_dir: /src
    command:
      hugo server --bind 0.0.0.0 --port 80 --baseURL http://localhost:18080/ --appendPort=false
      --disableFastRender
    ports:
      - "18080:80"
    volumes:
      - .:/src
    restart: unless-stopped

Running the blog locally is just this:

docker compose up

Once I’m happy with a new post — or a tweak to the site structure — I publish by running an Ansible playbook that builds the site locally and rsyncs the result to my VPS.

ansible-playbook deploy.yml

The deploy.yml used for publication looks like this:

- name: Deploy blog
  hosts: blog
  gather_facts: false

  collections:
    - ansible.posix

  vars:
    repo_root: "{{ playbook_dir | dirname }}"

  tasks:
    - name: Build Hugo site locally (Docker one-shot)
      ansible.builtin.command:
        cmd: >-
          docker run --rm -v {{ repo_root }}:/src -w /src --user {{ lookup('pipe', 'id -u') }}:{{
          lookup('pipe', 'id -g') }} hugomods/hugo:latest hugo --minify --cleanDestinationDir
      delegate_to: localhost
      run_once: true
      changed_when: true
      tags: [build]

    - name: Sync rendered site to host
      ansible.posix.synchronize:
        src: "{{ repo_root }}/public/"
        dest: "{{ web_root }}/"
        delete: true
        recursive: true
        rsync_opts:
          - "--omit-dir-times"
      become: true
      tags: [sync]

    - name: Re-apply SELinux file contexts on web root
      ansible.builtin.command:
        cmd: "restorecon -R {{ web_root }}"
      become: true
      register: restorecon_result
      changed_when: restorecon_result.stdout | length > 0
      tags: [sync]

In addition to the deploy.yml playbook, I also have one for provisioning all the pre-requisites on a given VPS, with the assumption that the VPS is running a Fedora/DNF based distribution.

This means moving between VPS providers is trivial — stand up a fresh host, point Ansible at it, done.

All authentication is carried out via SSH where the keys live on one of my personal Yubikeys.

To keep the deployment as simple as possible, and to avoid any arsing around with certificate issuance, I use the most excellent Caddy for serving up the pages, which makes obtaining and renewing HTTPS certificates easy.

All the Caddy configuration is done using a Jinja template processed by Ansible. (The template builds a Caddyfile for deployment).


I’m not a graphic designer, so things are kept very simple. I love typewriter and monospaced fonts, and so Courier Prime is used as the primary site font.

Light and dark theming is handled in the CSS:

:root {
  --paper: #f1efe2; /* aged typing paper */
  --ink: #1a1612; /* ribbon black */
  --ink-soft: #6a635a;
  --rule: #d4ccb6;
  --accent: #b8351a; /* ribbon red */
  --code-bg: #ebe2cc;

  --measure: 38rem;
  --type: "Courier Prime", "American Typewriter", "Courier New", Courier, ui-monospace, monospace;
  --code-type: "Lekton", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
}

/* Dark theme is applied when the inline <head> script sets data-theme="dark"
   on <html> — based on either the stored choice or the OS preference. The
   media query below catches the no-JS fallback: if data-theme isn't set at
   all, an OS dark preference still gives the visitor dark mode. */
:root[data-theme="dark"] {
  --paper: #14110d;
  --ink: #e6dec9;
  --ink-soft: #968d78;
  --rule: #2c2620;
  --accent: #ec7a55;
  --code-bg: #2a2218;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --paper: #14110d;
    --ink: #e6dec9;
    --ink-soft: #968d78;
    --rule: #2c2620;
    --accent: #ec7a55;
    --code-bg: #2a2218;
  }
}

There’s also some really noddy JavaScript allowing users to switch between ‘sweet’ and ‘sour’ themes and stash a preference into localStorage:

<script>
  (function () {
    var s = localStorage.getItem("theme");
    var d = matchMedia("(prefers-color-scheme: dark)").matches;
    document.documentElement.setAttribute("data-theme", s || (d ? "dark" : "light"));
  })();
</script>

Code blocks are rendered in a different font — Lekton — which I use on a daily basis as my favourite terminal/coding font.

I’m not sure of the provenance of this font, but it’s been kicking around as a NerdFont for quite some time. If you haven’t tried it in your terminal then I’d recommend it to both you and your retinas.