devlog / 2026-11-13

The devlog is live: how I built it

A step-by-step look at the LXC, Caddy, Hugo and publish API behind this site, written so I can rebuild it later.

What I wanted

I wanted a working notebook, not a portfolio. Something I can rebuild from scratch six months from now, and something another engineer can follow if they want the same setup.

This entry is the build log for the site itself.

TL;DR

Proxmox LXC 180 on pve2 → Debian 12 → Caddy + Hugo + Flask/Gunicorn → Cloudflare Tunnel → blog.s3na.xyz. Writes go through /api/publish; Hugo rebuilds the static site; Caddy serves it.

Topology

RoleHostAddress
Proxmox nodepve2192.168.89.102
Blog containerblog (VMID 180)192.168.89.210/24
LAN gateway—192.168.89.1

The public side is a Cloudflare Tunnel pointing at http://192.168.89.210. TLS terminates at Cloudflare, so the origin only needs HTTP on the private network.

1. Create the LXC

I used the Debian 12 template already present on the Proxmox node:

pct create 180 local:vztmpl/debian-12-standard_12.12-1_amd64.tar.zst \
  --hostname blog \
  --cores 2 --memory 1024 --swap 512 \
  --rootfs local-zfs:8 \
  --net0 "name=eth0,bridge=vmbr0,firewall=0,gw=192.168.89.1,ip=192.168.89.210/24,type=veth" \
  --nameserver "<your-dns>" \
  --unprivileged 1 --features "nesting=1,keyctl=1" \
  --onboot 1 --start 1

Then I added my SSH public key to /root/.ssh/authorized_keys and set the timezone:

timedatectl set-timezone Asia/Jakarta

The result is a small, unprivileged container with 2 vCPU, 1 GB RAM, 512 MB swap and an 8 GB ZFS volume.

2. Install the base packages

apt update
apt install -y caddy hugo python3 python3-flask python3-gunicorn \
  python3-yaml sqlite3 curl jq rsync git ca-certificates

Versions on this box:

  • Debian 12 (bookworm)
  • Caddy 2.6.2
  • Hugo 0.111.3 extended
  • Python 3.11
  • Flask 2.2
  • Gunicorn 20.1

3. Create the layout

/srv/blog/
├── site/               # Hugo project
│   ├── content/posts/  # Markdown entries
│   ├── layouts/        # theme templates
│   ├── static/         # CSS, JS, favicon
│   └── hugo.toml
├── api/app.py          # publish API
├── public/             # generated static site
└── posts.db            # SQLite index

I use a dedicated system user for the site and the API:

useradd --system --home /srv/blog --shell /usr/sbin/nologin blog
mkdir -p /srv/blog/{site,api,public}
chown -R blog:blog /srv/blog

4. Configure Hugo

The important parts of hugo.toml:

baseURL = "https://blog.s3na.xyz/"
title = "senasgr / devlog"
enableRobotsTXT = true

[taxonomies]
  tag = "tags"
  project = "projects"

[permalinks]
  posts = "/:year/:month/:slug/"

[outputs]
  home = ["HTML", "RSS", "JSON"]
  section = ["HTML", "RSS"]

A first build, as the blog user:

runuser -u blog -- env HUGO_ENV=production hugo \
  --minify --cleanDestinationDir --noBuildLock \
  --destination /srv/blog/public

--noBuildLock matters later: the systemd service runs with a read-only filesystem outside /srv/blog, and Hugo otherwise tries to create .hugo_build.lock in the project root.

5. Publish API

The API is intentionally small. It accepts a JSON document, writes a Markdown file with Hugo front matter, records the post in SQLite, and rebuilds the site.

The environment file lives at /etc/blog/blog.env:

BLOG_SITE_DIR=/srv/blog/site
BLOG_PUBLIC_DIR=/srv/blog/public
BLOG_DB=/srv/blog/posts.db
BLOG_LOCK=/srv/blog/.build.lock
BLOG_BASE_URL=https://blog.s3na.xyz
BLOG_TZ=Asia/Jakarta

Generate a strong API token once with openssl rand -hex 32 and add it as BLOG_API_TOKEN in that file. Keep the file at mode 0640, owned by root:blog.

The service:

[Unit]
Description=s3na devlog publish API
After=network-online.target

[Service]
User=blog
Group=blog
WorkingDirectory=/srv/blog/api
EnvironmentFile=/etc/blog/blog.env
Environment=PYTHONDONTWRITEBYTECODE=1
ExecStart=/usr/bin/python3 -m gunicorn --workers 1 \
  --bind 127.0.0.1:8090 --timeout 120 app:app
Restart=on-failure
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/srv/blog

Enable it:

systemctl daemon-reload
systemctl enable --now blog-api
curl -fsS http://127.0.0.1:8090/api/health

The API listens only on 127.0.0.1. Caddy is the only process exposed to the LAN.

6. Caddy

:80 {
    encode gzip
    root * /srv/blog/public

    handle /api/* {
        reverse_proxy 127.0.0.1:8090
    }

    handle {
        try_files {path} {path}/ =404
        file_server
    }

    handle_errors {
        rewrite * /404.html
        file_server
    }
}

Validate and reload:

caddy validate --config /etc/caddy/Caddyfile
systemctl reload caddy

I deliberately use :80 instead of blog.s3na.xyz: Cloudflare Tunnel connects to the origin over HTTP, and the public certificate is handled at the edge.

7. Publish an entry

I write Markdown, then send it through the CLI:

blog post \
  --title "Rebuilding the lab router" \
  --summary "What changed, what broke, and the commands that fixed it." \
  --project homelab \
  --tags networking,proxmox \
  --body-file post.md

The API expects JSON, so the same thing can be done from any script:

{
  "title": "Rebuilding the lab router",
  "summary": "What changed, what broke, and the commands that fixed it.",
  "body": "## Goal\n...",
  "tags": ["networking", "proxmox"],
  "project": "homelab",
  "status": "published",
  "session_id": "optional-idempotency-key"
}

The session_id is what makes retries safe: sending the same key updates the existing entry instead of creating a duplicate.

8. Verify

curl -fsS https://blog.s3na.xyz/ >/dev/null
curl -fsS https://blog.s3na.xyz/api/health
curl -sSI https://blog.s3na.xyz/does-not-exist
curl -fsS https://blog.s3na.xyz/index.xml >/dev/null

Expected: 200 for the home page and health check, 404 for a missing page, and a valid RSS feed.

Pitfalls I hit

  • Zero-byte post pages. baseof.html has to live in layouts/_default/ for templates under _default/ to find it. Putting it only at layouts/baseof.html worked for the home page but produced empty single pages.
  • 404 handling. try_files {path} {path}/ /404.html serves the error page with a 200. Using =404 plus a handle_errors block keeps the correct status.
  • Hugo lock file. The service user could not write .hugo_build.lock under the hardened systemd unit, so the build uses --noBuildLock.
  • Ownership. Hugo must be able to write content/posts, resources and public; Caddy only needs read access to public.
  • Port 80 on purpose. With Cloudflare Tunnel, there is no reason to terminate TLS twice on the origin.

What is next

More notes from the workbench: network changes, LXC builds, Rust experiments, and the occasional post-mortem. The archive and RSS feed are there for anyone who wants to follow along.

Rebuild checklist

  • Create LXC 180 on pve2 with Debian 12, 2 vCPU, 1 GB RAM, 8 GB disk.
  • Install Caddy, Hugo, Python, Flask, Gunicorn and SQLite.
  • Create the blog user and /srv/blog layout.
  • Deploy the Hugo site and build it once.
  • Configure the publish API and its systemd unit.
  • Validate the Caddyfile and reload Caddy.
  • Point the Cloudflare Tunnel at http://192.168.89.210.
  • Publish a test entry and check the home page, RSS and 404.