---
title: "The devlog is live: how I built it"
date: "2026-11-13T22:00:05+07:00"
summary: "A step-by-step look at the LXC, Caddy, Hugo and publish API behind this site, written so I can rebuild it later."
url: "https://blog.s3na.xyz/2026/11/the-devlog-is-live/"
markdown: "https://blog.s3na.xyz/2026/11/the-devlog-is-live/index.md"
author: "senasgr"
tags: ["meta","devblog","proxmox","hugo","caddy","cloudflare"]
projects: ["senasgr-devblog"]
images: []
---


## 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

| Role | Host | Address |
| --- | --- | --- |
| Proxmox node | `pve2` | `192.168.89.102` |
| Blog container | `blog` (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:

```sh
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:

```sh
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

```sh
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

```text
/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:

```sh
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`:

```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:

```sh
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`:

```sh
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:

```ini
[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:

```sh
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

```caddyfile
: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:

```sh
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:

```sh
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:

```json
{
  "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

```sh
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.

