Setting up Decap CMS, and the four things that broke
Adding a graphical editor to the Blog meant no new database and no format migration, since Decap just commits to the same markdown files. Getting GitHub, Caddy and Docker to actually agree with each other took four separate fixes.
For a year this Blog has meant opening a terminal, writing frontmatter by hand, and running a build script. That is fine for someone who already lives in a terminal, but it is not graphical, and the request was simple: an editor with fields and a publish button, without giving up the architecture underneath it.
The deciding factor was storage. Decap CMS treats a post as a markdown file with frontmatter, committed straight to the repo it already lives in. Nothing about the data model changes. Ghost would have meant a new database, a new content model, and rebuilding the three-layer project and topic structure this Blog already has. Decap needed none of that: the eleven fields already in every post's frontmatter became eleven form fields in a config file, and the posts themselves needed zero edits to match it.
What Decap does need is somewhere to log in. GitHub's OAuth token endpoint has no CORS support, so a browser cannot exchange a login code for a token by itself. That part has to run server side, which meant one new small service: a stateless Express app with three routes, one to start the GitHub login, one to finish it, and one more to receive a webhook on every publish and redeploy the site automatically. No database, no Docker socket, just one bind mount to the files the deploy script already touches.
On paper that is a small service behind a reverse proxy. Getting there took four separate failures, each with its own log line.
Caddy refused the config outright. The editor needed its own path gated by a second login on top of GitHub's, so /admin/* got its own handle block. Caddy's error was blunt: handle_errors is not an ordered HTTP handler and cannot sit inside a handle block. The fix was moving it back out to sit alongside the two handle blocks instead of nested inside one of them, the same shape every other gated route in this Caddyfile already uses.
The editor loaded, then refused to run. First real page load, and the browser console said EvalError: call to Function() blocked by CSP. Decap's bundle calls Function() as part of its own config and widget machinery, which needs script-src 'unsafe-eval'. Rather than loosen the whole site's policy for a page only one person ever opens, each handle block now sets its own Content-Security-Policy: relaxed inside /admin/*, untouched everywhere else.
Login with GitHub returned GitHub's own 404 page. Not a redirect mismatch, which GitHub reports differently, but a plain "this is not the page you are looking for." A curl against the login endpoint showed why: the client ID in the redirect URL was wrapped in literal angle brackets, left over from a placeholder in the setup instructions that got filled in rather than replaced. GitHub does not recognise a client ID that is not actually a client ID.
The webhook fired, but nothing updated. GitHub confirmed delivery, the container logs told the real story: a Git error about dubious ownership. The bind-mounted repo is owned by the account that runs the VPS, the container runs as root by default, and modern Git refuses to operate on a repository it does not own unless told that ownership is expected. One line in the Dockerfile, baked in at build time rather than patched into a running container by hand, and every Git command this service runs trusts the mount again.
None of these were architecture problems. Each one was a boundary two systems did not agree on: a directive's place in a config file, a policy one path needed and the rest did not, a placeholder treated as literal text, a filesystem owned by one user and read by another. Once each boundary was named, the fix was small.
What's live now: this post. Written in a form, not a terminal, committed by a click, and published without anyone running a deploy command by hand.