Build Log: Ghost CMS Headless Content Pipeline

The Bottom Line: This build log documents the headless Ghost CMS content pipeline powering toolbrain.net — a Python-driven cron architecture that generates, formats, and publishes technical content via the Ghost Admin API. The stack: Ghost 6.35 (headless), Python 3 with the Admin API, systemd timers for cron scheduling, and a Jinja2-style HTML assembly pipeline. Total lines of custom glue code: ~400. Total time from content generation to live post: ~8 seconds.

Why Headless Ghost?

8.0 / 10

Build Log Review 2026

🛡️ AI Tool · Updated 2026

Ghost CMS ships with a beautiful default editor and a capable Handlebars theming layer. So why bypass the editor entirely and push raw HTML via the Admin API? Three reasons:

  • Automation-first workflows: Cron-generated content (build logs, roundups, briefings) should never require a human to copy-paste into an editor.
  • Version-controlled content: Every post's source lives as a generated HTML file in /tmp/ during the build cycle, and the generation scripts are tracked in Git. No content gets created that can't be reproduced.
  • Consistent formatting: Hand-authored posts drift in style. Generated posts follow strict templates — same heading hierarchy, same code block styling, same TL;DR blockquote structure every time.

The headless approach doesn't replace the Ghost editor — it supplements it. Short-form, data-driven, or recurring content gets automated. Long-form editorial still uses the UI. The two paths converge on the same rendering pipeline.

Admin API Authentication Flow

Ghost's Admin API uses JSON Web Tokens (JWT) for authentication. Every request requires a Bearer token generated from an Admin API key — a Base64-encoded concatenation of a key_id and a secret.

The token lifecycle works like this:

import jwt, time, requests

GHOST_ADMIN_KEY = “64738291abc:abcdef1234567890abcdef1234” key_id, secret = GHOST_ADMIN_KEY.split(”:”)

payload = { “iat”: int(time.time()), “exp”: int(time.time()) + 5 * 60, “aud”: “/admin/” }

token = jwt.encode(payload, bytes.fromhex(secret), algorithm=“HS256”, headers={“kid”: key_id}) headers = {“Authorization”: f”Ghost {token}“}

Now any Admin API call works:

resp = requests.post( “https://toolbrain.net/ghost/api/admin/posts/”, headers=headers, json={“posts”: [{“title”: “Test”, “html”: “<p>Hello</p>”}]} )

Key gotcha: the aud claim must be /admin/ (with trailing slash), not /admin or /ghost/api/admin. Ghost's JWT middleware is strict about this — wrong audience returns a 401 with no useful error message.

The HTML Assembly Pipeline

Each build log starts as a markdown draft in the source repo, then passes through a three-stage pipeline before reaching Ghost:

  1. Generation: A Python script fills templates with dynamic data (timestamps, search results, system state). Output: valid HTML fragment stored at /tmp/tb-build-log.html.
  2. Enrichment: The publish script wraps the fragment in Ghost-compatible markup — adds <!--kg-card-begin: markdown--> fences for code blocks, resolves relative links, and injects metadata cards (feature images, bookmarks).
  3. Publishing: POST to /ghost/api/admin/posts/ with status published and a scheduled published_at. Ghost handles webhook fan-out, RSS regeneration, and cache invalidation.

The pipeline is deliberately stateless: each run fetches fresh data, generates the HTML, publishes, and exits. There's no database, no queue, no background worker — just a Python process that runs for ~10 seconds and dies.

systemd Timer Scheduling

The cron jobs run via systemd timers rather than traditional crontab entries. This gives us logging, dependency ordering, and failure notifications through journald.

[Unit]
Description=ToolBrain Build Log Publisher
After=network-online.target
Wants=network-online.target

[Service] Type=oneshot ExecStart=/usr/bin/python3 /home/techgeek/.local/bin/toolbrain-publish.py
—title “Build Log: $(date +%Y-%m-%d)”
—html /tmp/tb-build-log.html
—tags “Builds”
—excerpt “Technical build log” User=techgeek Group=techgeek

[Install] WantedBy=timers.target

[Timer]
OnCalendar=*-*-* 17:00:00
Persistent=false
RandomizedDelaySec=120

[Install]
WantedBy=timers.target

The RandomizedDelaySec=120 prevents multiple cron jobs from hitting the Admin API simultaneously. The After=network-online.target ensures the network is up before the script attempts an API call — without this, a boot-time timer fire would fail silently with a connection timeout.

Error Handling & Idempotency

The publish script handles three failure modes:

  • API unreachable: Retries with exponential backoff (1s, 2s, 4s) up to 3 attempts. Failure exits non-zero, which systemd logs and (optionally) alerts via OnFailure=.
  • Duplicate slugs: Ghost rejects posts with duplicate slugs by default. The script appends a -N suffix when a 422 arrives with slug has already been taken. This is rare for auto-generated titles with timestamps but necessary for robustness.
  • HTML validation errors: Ghost's API validates HTML structure. Unclosed tags or malformed <pre> blocks return a 422. The pipeline runs the generated HTML through lxml.html.fromstring() before sending — if parsing fails, the post is aborted and the raw HTML is dumped to logs for debugging.

Idempotency is handled by not retrying successful POSTs. If the API returns a 200 with a post ID, the script records that ID in a local SQLite database and refuses to re-publish the same content hash. This prevents double-posts from cron overlap or manual re-runs.

Lessons Learned

After 40+ automated publications through this pipeline, a few things stand out:

  • Token expiry is the most common failure. The 5-minute JWT window is generous for a single request, but if the script does prep work (generating HTML, fetching data) before the API call, the token can expire. Fix: generate the token immediately before the POST, not at script start.
  • Ghost caches aggressively. After publishing, the front-end may show a stale version for 30-60 seconds even with Cache-Control: no-cache. The ?v=c3d62e7508 query parameter on assets handles cache busting for static files, but the post content itself goes through Ghost's internal rendering cache. A warm-up GET to the post URL after publishing speeds this up.
  • Handlebars partials don't apply retroactively. If you change a theme partial, previously published posts don't re-render. All post HTML is stored as-is in the database. This is fine for our pipeline since we control the full HTML output, but it means theme-level formatting changes require a bulk re-publish.

Links & References

📖 Related Reads

  • ToolBrain — tool reviews, LLM comparisons, and AI workflow guides
  • Hermes Tutorials — Hermes Agent setup, configuration, and advanced workflows
  • NoCode Insider — AI workflow automation with no-code tools, agents, and APIs

Cross-links automatically generated from ToolBrain.

← Back to all posts