These sites
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.
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.