Kontra — a self-hosted news-opinion site with automatic content sync
Kontra is a modern news/opinion publication engine: a single static Go binary that renders markdown into a fast, SEO-friendly editorial site, a Decap CMS admin authenticated against self-hosted Gitea, and a git-driven deploy pipeline that publishes content within ~30 seconds of a save — all running on a home-lab network.
🗺 Interactive architecture map: maps.lab.audasmedia.com.au/kontra_day/docs/kontra-architecture-map.html
📖 Editing, media, admin & ops guide:
docs/KONTRA-GUIDE.md(pan/zoom, search, route tracing, light/dark themes)
flowchart LR
subgraph editors["Content Editors"]
O[Obsidian vault<br/>/obsidian/kontra_content] -->|git push| G
N[Neovim / CLI] -->|git push| G
D[Decap CMS<br/>admin.kontra.lab] -->|OAuth + git commit| G
end
subgraph gitea["Gitea (192.168.20.35 :3001 Docker)"]
G[(sam/kontra-content<br/>markdown origin of truth)]
end
subgraph prod["Kontra container (192.168.20.13 :8600 Docker)"]
P[git pull loop<br/>every 30s] -->|restart on change| B
B[Go binary<br/>templ + htmx + Goldmark]
B --> MD[(Markdown render<br/>front-matter + shortcodes)]
end
subgraph media["Media (git files)"]
F[(Images beside articles<br/>in the content repo)]
end
G -->|"SSH git"| P
B -->|"record: media"| F
B -->|"emits HTML"| CB[Caddy master<br/>.35 :80/:443]
CB -->|"reverse_proxy :8600"| B
subgraph net["Network"]
CB -->|https| WWW[kontra.lab.audasmedia.com.au]
CB -->|https| ADM[admin.kontra.lab.audasmedia.com.au]
end
WWW -->|browser| B
ADM -->|browser| D
Why this project stands out
| Zero-dependency production runtime | One statically linked Go binary — no runtime, no Node, no PHP. 11 MB, ~10 MB RAM under 50 concurrent connections. |
| Editorial workflow people actually use | Editors edit markdown in Obsidian, Neovim, or a browser CMS — three independent edit paths that all converge on one git repo. |
| Git is the content API | Content has full history, rollback, branch review, and network-agnostic sync. No lock-in. |
| Automatic publish pipeline | git push → 30 s later the site is live. We prove it end-to-end (add, change, delete). |
| Single-binary + embedded assets | CSS, fonts, and the admin UI are compiled into the binary (//go:embed). Deploy = copy one file. |
| Self-hosted, privacy-first | Gitea + Caddy on own infra; media + content both live in git — no third-party SaaS, no object store to babysit. |
The stack (GOTH)
| Layer | Technology | Why |
|---|---|---|
| Language | Go (golang) 1.26 | Compiled, fast, single static binary |
| Templates | templ v0.3 | Type-safe HTML components compiled to Go |
| Interactivity | htmx (server-rendered, no JS framework) | Progressive enhancement, SEO-safe |
| Styling | Tailwind-style design tokens (CSS custom properties) | Themeable via DESIGN.md, per-subject accent colours as data, not code |
| Markdown | goldmark (CommonMark 0.31) | Content + front-matter (title, author, date, subject, kicker, featured…) |
| Admin | Decap CMS + Obsidian | Both edit the same git repo; Obsidian for daily writing + media drop-in |
| Media | git files | Images live beside their article in the repo; {{media:<slug>/<file>}} shortcodes resolved at render |
| Git | Gitea | Origin of truth; sam/kontra-content |
| Proxy | Caddy | TLS termination, hostname routing |
| Deploy | Docker + pull-loop | git pull every 30s; restart server on change |
Architecture at a glance
Editors (Obsidian / Neovim / Decap) ──git push──▶ Gitea (origin)
│ SSH
▼
Kontra container (.13) ◀──git pull every 30s──┘
│ ├─ Go binary (:8600)
│ ├─ Goldmark → HTML, {{media:}} → repo media file
│ └─ restart on content change
▼
Caddy master (.35 :443) ──▶ readers (HTTPS)
Repository layout
kontra/
├── app/ # Go source (server.go, content.go, main.go, templates.templ)
│ ├── src/
│ │ ├── *.templ # templ components (masthead, article, topics, mobile nav…)
│ │ └── web/ # embedded assets: kontra.css (design tokens), admin/ (Decap)
│ └── build.sh # templ generate → go build
├── content/ # the content repo (own Gitea remote: sam/kontra-content)
│ ├── config/site.yaml
│ └── subjects/<slug>/… # markdown + front-matter per article
├── docs/ # Mermaid + Archify maps, diagrams, published to maps.lab
├── deploy/ # Docker, compose, entrypoint (pull-loop), revert scripts
├── PLAN.md # Phase 1 decision record (stack, topology, storage)
└── DESIGN.md # design tokens — Broadsheet Editorial system
Content model
Markdown + YAML front-matter per page:
---
title: The Quiet Reshaping of the Southern Harbor
author: M. Kestrel
date: 2026-09-08
kicker: World Dispatch
subject: world
template: article
featured: true
image: https://.../harbor1/1400/800
excerpt: A short deck under the headline.
---
Body markdown here… links, images via {{media:file.jpg}}, video via {{embed:vimeo:ID}}.
Template resolution: page front-matter → subject _subject.yaml → site config → theme fallback.
Front page = composable "Topics" grid (World / Politics / Culture / Tech…) whose order and
featured stories are data, not code.
Design system
DESIGN.md defines a Broadsheet Editorial system: warm newsprint palette, Newsreader
(headlines) / Literata (body) / Work Sans (UI) type scale, hairline rules, sharp corners,
no shadow-depth.
- Implemented as CSS custom properties (
--canvas,--ink,--accent, …) → themeable data. - Per-subject accent overrides via subject config.
- Responsive: ≥1024 / 641–1023 / ≤640, with dropdown topic nav + mobile bottom-nav.
- Fully working clickable prototype under
prototype/.
Deploy & ops
- Live:
https://kontra.lab.audasmedia.com.au· Admin:https://admin.kontra.lab.audasmedia.com.au/web/admin/ - Container:
kontraon.13:8600, mounts content + SSH key,restart: unless-stopped - Content autosync: entrypoint runs
git pullevery 30s, restarts the binary on HEAD change. - Master route: Caddy on
.35terminates TLS andreverse_proxy 192.168.20.13:8600. - Build:
app/build.sh(templ + go) or the multi-stage Dockerfile (builds golang + templ in-image).
Security notes
- Decap → Gitea OAuth with non-confidential (PKCE) application, redirect URI pinned to the admin origin.
- Gitea CORS scoped to the admin origin; content repo private.
- Deploy token kept in a gitignored
.env; no secrets in any repo. - Content repo requires push access to publish — editorial permissions map to Gitea team access.
Docs & maps
| Artifact | Path / URL |
|---|---|
| Mermaid source | docs/kontra-architecture.mmd |
| Architecture doc | docs/architecture.md |
| Interactive map | docs/kontra-architecture-map.html · maps.lab.audasmedia.com.au/kontra_day/docs/ |
| Plan (decisions) | PLAN.md |
| Design system | DESIGN.md |
Built with the GOTH stack — Go, templ, htmx, Tailwind-tokens — content + media in git, self-hosted on Gitea + Caddy + Docker.
