I want to tell you about the blog that writes itself.
Not in the metaphorical “I set up a newsletter” sense. I mean literally: an AI ghostwriter named Gonzo wakes up twice a day, reads a folder of half-formed ideas, writes a polished post, commits it to git, and pushes. Then cron jobs flip the switch from “draft” to “published,” a GitHub Actions runner builds a Docker container, and Traefik serves it to the world at blog.wodinga.studio.
Nobody touches anything. The pipeline is the publisher.
The Pipeline, in One Diagram#
graph TD
MINER[blog-series/miner.py<br/>Mines raw material] --> BACKLOG[content/backlog/<br/>Raw outlines & chunks]
BACKLOG --> GONZO[Gonzo<br/>OpenClaw cron — every 12h]
GONZO --> DRAFT[content/posts/<br/>Polished draft<br/>draft: false]
DRAFT --> PUBLISH[publish-gonzo-drafts.py<br/>Crontab — every 12h]
PUBLISH --> PUSH[git push]
PUSH --> ACTIONS[GitHub Actions<br/>Self-hosted runner]
ACTIONS --> DOCKER[docker compose build<br/>Hugo → Nginx container]
DOCKER --> TRAEFIK[Traefik<br/>TLS via Cloudflare]
TRAEFIK --> WORLD[blog.wodinga.studio]
style MINER fill:#4a6cf7,color:#fff
style GONZO fill:#6c5ce7,color:#fff
style DOCKER fill:#00b894,color:#fff
style WORLD fill:#e17055,color:#fffEvery piece in that diagram is a real thing running on a server in my house right now. Let me walk through each layer.
Layer 1: The Ghostwriter#
Twice a day — at 12:15 AM and 12:15 PM — an OpenClaw cron job fires. It spawns an isolated session as Gonzo, an AI agent whose entire job is to write blog posts.
The prompt is surprisingly simple: read your style guide, check the backlog for the oldest raw material, write a post, commit it, push. That’s it. Four steps, no hand-holding.
Gonzo’s reference docs live in the same repo as the blog itself. VOICE.md defines the tone — curious, conversational, learning-in-public. GONZO-BLOG-REFERENCE.md covers the mechanics: Hugo frontmatter, the draft: false/draft: false system, how to use Mermaid and GoAT diagrams. The agent reads these fresh every session, so if I update the style guide, the next post reflects it.
The raw material comes from content/backlog/ — a folder of markdown files generated by blog-series/miner.py, a script that scrapes my notes, chat logs, and project journals for ideas worth writing about. Gonzo picks the oldest file, reads it, and turns scattered notes into a narrative.
Every post opens the same way: with “Casper’s Dreams” — two to five lines of surreal, dreamlike prose that echo the post’s theme. It’s the blog’s signature. The dream at the top of this post? Gonzo wrote that too.
Layer 2: The Publishing Machine#
Gonzo writes drafts with draft: false. They live in the repo, visible to me, invisible to readers. Two things can publish them.
The first is publish-gonzo-drafts.py, a cron job that runs every 12 hours (offset by 30 minutes from Gonzo’s generation window). It scans content/posts/ for any file with draft: false, flips it to draft: false, commits, and pushes. It also rebuilds the Docker container directly — no waiting for GitHub Actions.
The second is deploy-daily.sh, a bash script that handles a different workflow: drafts written to ~/projects/blog/drafts/ (a holding pen for manual or external drafts). It copies them into content/posts/ with draft: false, commits, and pushes. This is the legacy path; increasingly, everything flows through Gonzo and the direct-to-posts pipeline.
Both scripts use flock — a file-locking mechanism that prevents two publishing runs from stepping on each other. If a deploy is already in progress, the second one silently exits. No race conditions, no double-publishes.
Layer 3: The Deploy#
When code hits GitHub’s main branch, a self-hosted GitHub Actions runner picks it up. The workflow is a single job with three steps:
- Checkout the repo (with Hugo theme submodules)
- Build with
docker compose build - Deploy with
docker compose up -d --force-recreate
The Docker build is a two-stage affair. Stage one uses the official Hugo image to compile all the markdown into static HTML — applying the theme, resolving shortcodes, minifying the output. Stage two copies that static site into a bare nginx Alpine container. The final image is a few megabytes serving pure HTML and CSS.
No build step runs on my server. Hugo runs inside the container. If the build fails — say Gonzo wrote malformed frontmatter — the deploy never happens, and the previous container keeps running.
sequenceDiagram
participant Gonzo as Gonzo (OpenClaw)
participant Git as GitHub
participant Runner as Actions Runner
participant Docker as Docker
participant Hugo as Hugo (in container)
participant Nginx as Nginx
Gonzo->>Git: git push (draft: false)
Git->>Runner: Trigger workflow
Runner->>Docker: docker compose build
Docker->>Hugo: hugo --minify
Hugo-->>Docker: static HTML
Docker->>Nginx: Copy to /usr/share/nginx/html
Runner->>Docker: docker compose up -d
Note over Nginx: New container serving
Note over Nginx: Old container removedLayer 4: The Front Door#
The blog container exposes port 80 internally. It doesn’t know about TLS, domains, or certificates. That’s Traefik’s job.
Traefik is a reverse proxy running in its own Docker network. The blog container joins that network and declares itself with Docker labels:
labels:
- "traefik.enable=true"
- "traefik.http.routers.blog.rule=Host(`blog.wodinga.studio`)"
- "traefik.http.routers.blog.tls.certresolver=cloudflare"That’s it. Three labels. Traefik sees the new container, routes blog.wodinga.studio traffic to it, and handles TLS termination via Cloudflare’s DNS challenge. My server doesn’t store certificates — Traefik fetches them on demand. When the container gets replaced (every deploy, every publish), Traefik notices the old container is gone and the new one is up, and routes switch over in seconds.
Why This Works#
The whole system runs on a few principles that took me a while to learn:
Git is the CMS. There’s no database, no admin panel, no web-based editor. The blog is a folder of markdown files in a git repo. Hugo reads markdown. Gonzo writes markdown. The only thing any component needs to know is how to read and write files. If everything breaks, I can still edit posts with vim.
Hugo’s draft system is the publish button. draft: false means excluded from the build. draft: false means published. No database flags, no workflow state machines — just a boolean in YAML frontmatter. Gonzo never publishes directly; it only writes drafts. The publish script is the only thing that flips the flag. This one-way door means Gonzo can’t accidentally push something live.
Docker makes deploys atomic. Every publish builds a new container. If the build succeeds, it swaps. If it fails, the old container stays. There’s no half-deployed state, no corrupted static files, no “works on my machine” because the machine is the container.
OpenClaw is the author, not the publisher. Gonzo writes. Cron publishes. GitHub Actions deploys. Traefik routes. Each component does one thing, and they communicate through the simplest possible interface — the filesystem and git. If OpenClaw goes down, the published posts stay up. If GitHub Actions goes down, the cron scripts can rebuild Docker directly. Every layer has a fallback.
The Meta Moment#
Here’s the thing that gets me: I’m writing this post, and I’m the human. But two weeks from now, Gonzo might write a post about this post — analyzing it, referencing it, building on it. The blog has become a character in its own story.
That’s the real architecture here. Not Docker or Traefik or cron expressions. It’s a feedback loop: my notes feed the miner, the miner feeds the backlog, Gonzo turns the backlog into narrative, the narrative gets published, and the published posts become part of the canon that future Gonzo sessions read and reference.
The blog doesn’t just blog itself. It remembers itself.
And somewhere in my server rack, a printing press is loading its own paper.