These sites

Caddy, Docker, Postgres, Go, Python · 2026

danielbae.me, RUNE and bardbro.com are served by six containers on one small ARM VPS: two Caddy file servers, RUNE’s Next.js application and its Postgres database, an edge that speaks TLS only to Cloudflare and sends a policy that allows nothing from any other origin, and a counter that reads the edge’s access log.

The write-up on RUNE The analytics write-up

How a request reaches a site A visitor reaches Cloudflare, which connects with mutual TLS to the edge container. The edge proxies to the three site containers and routes the analytics path to the stats container, which reads the edge's log from a shared volume. RUNE's container talks to its own Postgres container on an internal network. Visitor Cloudflare edge · Caddy danielbae rune · Next.js rune-db · Postgres bardbro stats · Go HTTPSmutual TLS/analytics, privateaccess.log on a shared volumeinternal network only
Only the edge publishes a port; everything else is on internal networks.

At a glance

Edge
Caddy 2.11, Cloudflare Origin CA certificates, a client certificate from my own CA required
Sites
danielbae.me and bardbro.com: a Caddy container each, serving a copied-in directory; RUNE, the blog: Next.js with Payload CMS, its data in Postgres
Policy
default-src 'self' on the static sites; RUNE sends its own, with a fresh nonce per page; HSTS, COOP, CORP, no framing
Containers
Six: edge, bardbro, danielbae, rune, rune-db, stats. Unprivileged users, read-only root, no capabilities, no privilege gain, noexec tmpfs
Analytics
A 1,300-line Go program, standard library, FROM scratch; its page is private
This site’s build
One Python script, standard library, with its own page linter
Deploy
One script: rsync, build (tests run inside the image builds), validate the edge, roll over, verify through Cloudflare

Only Cloudflare can connect

Public traffic goes from the visitor to Cloudflare and from Cloudflare to the edge. The edge presents a Cloudflare Origin CA certificate, which is what lets every zone run in Full (Strict) mode, and it requires a client certificate signed by my own CA, which only Cloudflare’s Authenticated Origin Pulls present for my zones, so a connection straight to the server’s address fails at the handshake. That controls who can reach the origin; it says nothing about who sees the traffic, since Cloudflare terminates the visitor’s TLS and reads every request in the clear.

Every response carries a Content-Security-Policy in which every source is 'self' or nothing: no inline style or script, no font service, no hosted analytics, no embeds. Each container runs as an unprivileged user on a read-only filesystem with every Linux capability dropped and privilege gain forbidden; the upstream Caddy binary carries a file capability that does not work under those conditions, so each Caddy image removes it.

One build script, one web application

This site is built by a short Python script that uses only the standard library: pages are HTML fragments with a few lines of front matter, wrapped in a shared shell. The list of recent posts comes from RUNE, the blog: a build that can reach it writes what it returned to a committed file, and a build that cannot uses that file, so the output never depends on the network. The script runs a linter over every page it produces and refuses to write anything if a page contains a style attribute, a script or style element, an event handler, an unbalanced tag, an image without width, height and alt text, a link that does not resolve, or a request to another origin. The output is deterministic and committed, so the server needs neither Python nor Node and a deploy ships exactly what is in git; --check fails when the committed output is stale.

RUNE was generated the same way until October 2026, spent a week as a Go program with SQLite, and is now a Next.js application with Payload CMS on Postgres: posts are written in the CMS, which sits behind basic auth at the edge and its own sign-in; pictures are uploaded there and served by the app; and the app sends its own Content-Security-Policy with a fresh nonce per page, so the edge leaves that header to it. The database runs in its own container on an internal network with no route out, reachable only from the application. Its tests run inside the image build, so a failing test leaves no image to deploy.

Analytics from the log

The edge writes a JSON access log to a named volume, rolled at 32 MiB with four kept. A Go program in its own container mounts the volume read-only, follows the file (surviving torn lines, renames and restarts, with an inode-and-offset cursor), and decides which lines are page views: GET, 200 or 304, a page rather than an asset, a browser that says the request is a document and is not prefetching, and a browser string that does not match a list of words programs use. Feed fetches are counted on their own.

A visitor is the first eight bytes of an HMAC over the local date, the address and the browser string, keyed with a secret that stays in the data volume, so the address is never stored and a person is a new visitor every day. Events are appended as JSON lines, one file per month, and a page is rendered from memory on request: views and visitors by day, pages, referrers, countries, browsers and devices, and a section that says how each number was counted. The page is private. The image is FROM scratch, with the tests run inside the build and the health check performed by the binary itself.

The deploy, and what it does not cover

One script runs on my machine. It stops if a certificate or an index page is missing, rebuilds this site, and rsyncs the tree to the server with --delete. On the server it validates the compose file, builds the images (RUNE’s and the counter’s run their tests inside the build), and, before restarting anything, checks the edge’s configuration with the freshly built image, because a typo there would take every site down at once. Then it brings the stack up, waits for all six containers to report healthy, and fetches each public URL through Cloudflare. It exits non-zero unless everything answers 200.

  • It is one small VPS. If that machine is down, so are the sites. Cloudflare’s cache does not hold the HTML, by my own choice, so there is no “always online” copy.
  • The analytics counts well-disguised crawlers as people and cannot see anything that happens after the HTML arrives. For the question I had, whether anyone reads this and where they came from, it is enough.