blog mechanics
a loose explanation of how this blog site works
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.
Replies to bewilder.