Moving From Zamrazac to Hugo

- 9 mins read

Moving over

One of my goals for my sabbatical since last November has been upgrading my blogging infra. This is now done.

I went from an old (read: Ubuntu 19 or so?) VPS running Nginx hosting static files generated by my Zamrazac static site generator to a NixOS 24.11 VPS running Nginx hosting static files generated by Hugo with a slightly-modified Poison theme (as well as self-hosted analytics via Plausible).

Why move?

My original goal with Zamrazac was “archival quality” blog posts in a language I enjoy. This turned the design space into:

  • Must be implemented in Elixir
  • Must create minimal HTML files (e.g., no network traffic required to render)
  • Must embed images
  • Must keep reasonable size

I managed to accomplish all of those things during my first week or two at Recurse many years ago. I’m particularly proud of the image processing–dithering, compressing, inlining via data URLs, and finally rendering a nice sepia tone using only CSS filters.

Unfortunately, years later, one particular issue kept coming back. I really, really wanted better code rendering.

My old solution used <pre> blocks and was basically servicable, but it unfortunately lacked syntax highlighting and line numbers.

A brief rant about syntax highlighting

Based on what I learned reading up on it, the basic issue with syntax highlighting is that there are several ways to do it and all of them I dislike.

Starting from the display and working backwards, you have a bunch of inline block elements/spans that need to be colored. You start with something simple like this…

    var myVar;

…and you expect it to eventually turn into a DOM tree like…

<pre>
    <span class="keyword-decl-var">var</span> <span class="identifier-variable">myVar</span><span class="keyword-end-of-statement">;</span>
</pre>

…or like…

<pre>
    <span style="color: #400;">var</span> <span style="color: #bbb;">myVar</span><span style="#fff">;</span>
</pre>

…or similar. Most any library–highlight.js, prism, or even CLI tools like highlight–will end up doing this basic transformation, and the wiggle room is whether it’s done in the client browser, server dynamically during a request, or a preprocessing step.

I didn’t want to have external dependencies or large files embedding libs, I didn’t want to be re-rendering this every time (Zamrazac being a static site generator and all), and so I figured it’d be a preprocessing step.

So, in Elixir, the standard ansewr for this is makeup. This is perfectly cromulent, excepting that at the time it only really supported Elixir/Erlang/C/HTML/Diff/JSON and that cluster (since then, via makeup-syntect it’s gotten better) so it wasn’t what I needed.

My next port of call was some kind of tool I could shell out to, highlight a blob, and patch back in its results: this is what introduced me to highlight above. Unforuntately, once that got figured out, I had a bear-and-a-half of a time trying to get the generated HTML spliced back in, because of the way I was trying to use Floki and Earmark. I think it may have gotten better, but at the time there wasn’t really any good way I could find to say “okay, remove everything below this child node, replace it with this other content, and wrap the whole thing in this other thing”.

So, I flipped the table and decided I’d just give up on it for a while, and sulk. Life provided many other areas of excitement, and time passed quickly.

What changed?

I’ve been doing some fun projects like vibe-coding, Nix-adjacent stuff, learning about ML, and so forth and wanted to share that progress–unfortunately, this means that I’d need to share code.

Making things even more annoying, I haven’t been able to do even basic blogging because my tape-and-bailing-wire setup on my old Linux Mint installation fell apart once I’d migrated to NixOS. The old situation was some shell scripts calling into an Elixir project using a system-wide Elixir/Erlang install, and in the new world that’s just not how I have anything set up.

Between these things, wanting to write and Nix breakage and Zamrazac’s age, I figured it was time to bite the bullet and see what was out there.

The move

Hugo does a large superset of what Zamrazac did, by and large, and is written in Go and reasonably fast. Perhaps most importantly, it’s already packaged in Nixpkgs and so I don’t have to do anything particularly clever to use it. This will be easy!

The only difference between theory and practice…

The move plan was straightforward enough:

  1. Install Hugo
  2. Grab a template
  3. Copy over all the blog posts and change their front matter (the metadata at the head of all their markdown files) to match what Hugo expects
  4. Update my local blogging scripts
  5. Copy the output of running Hugo to the VPS

…is that only in theory is there no difference.

The plan started out well.

Install Hugo

Laughably easy, testing via nix-shell -p hugo and later by just adding hugo to my configuration.nix. Sweet.

Grab a template

Poison seemed neat, and just needed to be downloaded to my new blog root and configured with their default suggested config modulo some tweaking around paths and whatnot. Awesome.

Copy over all the blog posts and change their front matter

Tedious, since I was too chicken to write a script to do it for me (and Claude was proving a bit unreliable). So, okay, copy the files and build the folder structure and do it manually. Not delightful, but a good chance to throw Do the Right Thing on the second monitor while I worked.

Update my local blogging scripts

Easy enough, mostly just removing a bunch of old assumptions and then slapfighting over directory name generation. Acceptable.

Copy the output of running Hugo to the VPS

And here, dear reader, is where the front fell off.

The front falls off

A bit of background: my VPS had been provisioned back in 2018, so it is a bit long in the tooth. I don’t have the uptime for it prior to this adventure, but I’m pretty sure it was months or possibly a year or two; once upon a time, this sort of thing was considered a badge of a job well done.

The box itself was running Ubuntu 19 with the usual hardening steps applied and nothing interesting running but SSH and Nginx, serving files out of a static directory and occasionally phoning home to handle SSL stuff via Let’s Encrypt. It did its job, humble as that was, and that was it.

Fast-forward to this week, and I decide that maaaaybe I should at least update the system. I run the usual incantation of sudo apt-get update and wouldn’t you know but all the sources have EOL’ed on me. There will be no updating this box.

I have nothing but free time on my hands, and so I decide to myself “Why not zoidbergNixOS?” I’ve moved nearly all my other boxen over a while ago, and this is one of the holdouts.

I first tried lustrating, and was unable to get that to work after a couple of hours. So, I instead decided to do a fresh install.

My hosting provider TornadoVPS is a bit spartan in amenities, but does provide a netboot installer so the sufficiently masochistic can do this. Because Reasons, the access provided to your box is a management console via SSH that internally hooks to the serial port (I believe) of the VPS VM. To do what I needed to do, I’d need to:

  1. Open up the management console
  2. Load the latest-provided NixOS image (24.05, not great not terrible)
  3. Restart the VM and attach
  4. Install NixOS after zorching the disk
  5. Setup Nginx

All of that was pretty straightforward, except that I couldn’t get the damned installer to actually…install. I attempted to follow the instructions but I ended up repeatedly screwing up the bootloader and possibly partitioning–note that the GUI installer is fine, but I had to do this via the terminal because of the aforementioned serial port access.

Anyways, at this point I’d kvetched sufficiently that one of my friends reminded me we not only already had a documented process for this but also that same runbook contained some handy default settings and configurations. I cracked that open and after a little bit of time had a functioning NixOS VM! Huzzah!

Except…well, I couldn’t log in. I’d dutifully copied over and kept my password during installation, but alas it wouldn’t work. The standard Linux terminal login screen kept failing after I put in my username and then password.

I assumed I’d made a mistake and redid the entire process–thrice!–and eventually got fed-up enough to try pasting in the password to the liveboot environment. Aha, the answer was obvious: the password contained a bunch of additiaonl garbage. There was something wrong in the character bucket brigade between my scratchpad, my clipboard, my temrinal, SSH, their serial console, and the VM.

Manually keying the password in and lo and behold everything was fine.

Wrapping up the move

The files got copied over, I fought with permissions a bit to make sure Nginx could get at the files and I could upload and update them easily, and I bodged together a decent deploy script with rsync instead of my old, derpy scp. Great success.

I discovered that it all worked, and then set about configuring and setting up Plausible, which was again very easy on NixOS.

I also discovered that my images were all broken (surprise) and so with a bit of Claude learned to make a Hugo shortcode that would reproduce most of the old Zamrazac functionality:

{{/* layouts/shortcodes/smart-image.html */}}
{{ $src := .Get "src" }}
{{ $alt := .Get "alt" | default "" }}
{{ $width := .Get "width" | default "800" }}
{{ $quality := .Get "quality" | default "80" }}
{{ $class := .Get "class" | default "" }}

{{ $isRemote := or (hasPrefix $src "http://") (hasPrefix $src "https://") }}
{{ $image := "" }}

{{ if $isRemote }}
    {{ $image = resources.GetRemote $src }}
{{ else }}
    {{ $image = $.Page.Resources.Get $src }}
{{ end }}

{{ if $image }}
    {{ $opts := dict "method" "Atkinson" "colors" (slice "#F5E8C8" "#D0B27A" "#A67F46" "#664A2B") }}
    {{ $processed := $image.Resize (printf "%sx q%s" $width $quality) | images.Filter (images.Grayscale) | images.Filter
    (images.Dither $opts)
    }}

    <figure class="{{ $class }}">
        {{ if $isRemote }}
        <a target="_blank" href="{{$src}}">
            {{else}}
            <a target="_blank" href="{{$image.RelPermalink}}">
                {{ end }}
                <img src="{{ $processed.RelPermalink }}" width="{{ $width }}" alt="{{ $alt }}" loading="lazy">
                {{ with .Get "caption" }}<figcaption>{{ . }}</figcaption>{{ end }}
            </a>
    </figure>
{{ else }}
    {{ if $isRemote }}
        <!-- Fallback for remote images that can't be processed -->
        <figure class="{{ $class }}">
            <img src="{{ $src }}" width="{{ $width }}" alt="{{ $alt }}" loading="lazy">
            {{ with .Get "caption" }}<figcaption>{{ . }}</figcaption>{{ end }}
        </figure>
    {{ else }}
        <div class="error">Image not found: {{ $src }}</div>
    {{ end }}
{{ end }}

It’s not perfect, but it works!

On to blogging!

At last, my arm is complete again.

My next steps will be tidying up and copying over my ML notes to date, and probably dusting off the old cobwebs by writing about the things I learned as engineering management and leadership over the last four years. I think I’ll also be playing around with the shortcodes more to make little callout boxes and personas like I’ve seen in other blogs that I liked–good artists copy, great artists steal, etc.

So long for now!