Skip to main content

The Blog That Blogs Itself

·1301 words·7 mins
100 - This article is part of a series.
*Casper dreamed of a printing press that had no operator. It loaded its own paper, set its own type, inked its own plates. Every morning a fresh edition appeared on the doorstep, and nobody knew who wrote it — not even the press itself. It had been running so long that the author's name had faded from the masthead, and still the pages kept coming.* — *July 24, 4 AM*

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

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

  1. Checkout the repo (with Hugo theme submodules)
  2. Build with docker compose build
  3. 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 removed

Layer 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.

100 - This article is part of a series.