For a long time, publishing a post on this blog worked like this:

  • write in VSCode

  • drop images into a separate folder

  • double-check that every path was right

  • push to git

  • SSH into the server and run a script

  • Hope

  • Check page, find problem

  • Repeat

For anyone currious of the script here it is:

#!/bin/bash
cd /home/zev/zev.se
git pull
sudo rm -rf /opt/docker-data/www-public/*
sudo hugo -d /opt/docker-data/www-public -s /home/zev/zev.se/zev

It worked. It was fine. But it was one more thing to remember, one more reason to keep a terminal open, and one more thing to debug when the server had drifted out of sync with what I expected. Not fragile exactly — but manual in a way that always felt unfinished. And honestly, probably the reason not many post got written. I also needed a project to try some AI on to give me ammo on why you shoulden’t let AI go free in production. And what better way then fixing my already broken pipeline?

So I fixed it.

The old way

Diagram of the old manual deploy workflow

As you might expect the image is AI-generated, and as all true AI generated images it also contains faults. I used to push to github. I switched to Gitea. In any case it clearly shows the gap that is me having to push to git, ssh to server, run script, check, hope I didn’t mess any file up, because the scipt nukes the webroot.

The images situation was its own small frustration. They lived in a separate folder, references had to be typed correctly, and if something was off you wouldn’t know until you checked the live site. Also this means they wont render in any markdown editor.

The new way

Traiding an evening now for less hassle every time I write a post in the future. Or at least until the automation stops working.

Diagram of the new automated deploy pipeline

It works like this: write the post, push to Gitea, walk away. A few seconds later the site is updated.

The pipeline is straightforward:

  1. Push triggers a Gitea Actions workflow
  2. A self-hosted act_runner on the same VPS picks up the job
  3. It checks out the repo (including the theme submodule), installs Hugo, and builds
  4. The output lands in a Docker volume shared with the web server
  5. static-web-server picks it up immediately — no restart needed

The workflow itself is small:

name: Deploy

on:
  push:
    branches:
      - main

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: https://gitea.com/actions/checkout@v4
        with:
          submodules: recursive

      - name: Install Hugo
        run: |
          curl -fsSL https://github.com/gohugoio/hugo/releases/download/v0.152.0/hugo_extended_0.152.0_linux-amd64.tar.gz \
            | tar -xz -C /usr/local/bin hugo

      - name: Build
        run: hugo --minify --destination /site

Hugo writes directly to /site, which is a named Docker volume mounted into the build container. The web server has the same volume mounted at /public. No copy step, no rsync, no restart.

The one thing that took a while

Getting the volume mount to work took longer than expected. act_runner has a security feature where it silently ignores any volume mounts that aren’t explicitly whitelisted — even named Docker volumes. Every run logged:

[zev-site] is not a valid volume, will be ignored

The fix is one line in the runner config:

container:
  valid_volumes:
    - zev-site

Without that, Hugo builds successfully, outputs to what it thinks is /site, and the files vanish when the container exits. The site never updates. The logs show a successful build. It’s a good one to know about.

The repo changes

While I was at it, I cleaned up the structure. The old repo had accumulated some things worth fixing: content nested inside a zev/ subdirectory for no good reason, images in a shared static/images/ folder divorced from the posts that used them, custom SCSS and layout overrides that the theme now handles on its own, and the compiled public/ output sitting in version control — meaning every deploy added hundreds of generated files to git history, making it hard to see what actually changed between posts.

BEFORE                                 AFTER
──────────────────────────────         ──────────────────────────────
zev.se/                                zev.se/
├── zev/                               ├── config.toml
│   ├── config.toml                    ├── content/
│   ├── content/                       │   └── posts/
│   │   └── posts/                     │       └── my-post/
│   │       ├── post-one.md            │           ├── index.md
│   │       └── post-two.md            │           └── image.png
│   ├── static/                        ├── layouts/
│   │   └── images/                    │   └── shortcodes/
│   │       ├── image1.png             ├── themes/
│   │       └── ...                    │   └── hugo-coder/
│   ├── assets/   ← custom SCSS/JS     └── .gitea/
│   ├── layouts/  ← theme overrides        └── workflows/
│   ├── public/   ← build output                └── deploy.yml
│   └── themes/
│       └── hugo-coder/
└── README.md

Posts are now page bundles — each post lives in its own folder with its images alongside the index.md. No separate image directory, no path to get wrong. The pipeline builds public/ fresh on every deploy and throws it away. Git history is just content.

Worth it

The script is gone. Publishing is just writing and pushing. The pipeline is simple enough that there’s not much to break, and when something does go wrong the Gitea Actions log tells you exactly where.

This post was the first one published through it. It went smooth… I hope.