
Platform engineering
Cycle Kirklees platform
A production platform for a volunteer cycling campaign group, built and operated on a £0 infrastructure budget.
cyclekirklees.org.uk- Monthly infra cost
- £0
- Independent services
- 4
- Deploy pipelines
- 4
- Metric and log retention
- 30d
- Alert rules to Telegram
- 10
The problem
Cycle Kirklees is a volunteer-run cycling campaign group in West Yorkshire. I'm one of the volunteers.
When I joined, the group was running an ageing WordPress site on managed hosting it couldn't really justify on a volunteer budget. Newsletters went out by hand from a personal Gmail account. New members were onboarded by whoever on the committee had time to chase WhatsApp invites that evening. The organisation's communications depended on individual people remembering to do things, and nobody could tell whether any of it was working.
The committee wanted a site it could update without me, signups and newsletters that didn't hinge on someone's free time, and email on the organisation's own domain.
The binding constraint was that there was no operating budget at all. That ruled out most of the obvious answers and made the interesting question not "which service should we buy" but "which parts of this problem actually benefit from being someone else's responsibility".
Architecture
Three public services, each with its own deployment lifecycle, behind a single ingress layer on a self-hosted Proxmox/OPNsense DMZ.
How it works
The public site (cyclekirklees) is an Eleventy, Tailwind and Webpack static build. Committee members edit it through a vendored Sveltia CMS authenticated against GitLab OAuth, which means the editorial workflow is git-based without anyone needing to know that. A committee member opens the CMS, edits a page and publishes. That creates a commit, GitLab CI builds the site, and the artefact is deployed. There is no SSH session and no manual file copying anywhere in that path.
The signup gateway (cyclekirklees-gateway) is an Express/TypeScript service handling everything stateful: member signup, newsletter double opt-in and contact form submissions. Each flow is validated with Zod and backed by a SQLite state machine, so a half-finished signup or an unconfirmed subscription is a known state rather than a guess. Consent is captured and timestamped at the point of submission.
Domain email is self-hosted on Postfix and Dovecot, so cyclekirklees.org.uk addresses, inbound routing and webmail no longer depend on a personal inbox or a paid mailbox tier. Outbound delivery goes through an authenticated relay rather than the mail server.
Prometheus, Grafana, Loki and Alertmanager sit over all of it, deployed through their own pipeline so a monitoring change can ship without touching the signup flow. Thirty days of metrics and logs, ten alert rules, routed to Telegram because that's where the committee already is.
Engineering decisions
The gateway is separate from the site. Static hosting solves most of the problem and none of the interesting part. Keeping the dynamic behaviour in its own service means the public site stays a static artefact that can be rebuilt and redeployed with no state to preserve, while the small amount of stateful logic has its own lifecycle, its own pipeline and its own failure domain. Changing how signups work doesn't mean rebuilding the publishing system.
State is backed up off-site. The gateway's SQLite volume is the only thing on the platform that can't be rebuilt from Git. It goes to Azure Blob Storage nightly as a tarball, retaining seven weekly, four monthly and one yearly copy, and the restore path has been tested rather than assumed. The database is small enough that this lands under a penny a month, which makes it the only line item the platform has at all.
Inbound mail is self-hosted, outbound is delegated. Running a mail server is tractable. Establishing deliverability reputation for a new domain from a residential-adjacent IP is not, and failure there is silent: mail disappears into spam folders and nobody finds out for a month. The operational boundary sits where the risk changes character, not where the software boundary happens to be.
No orchestrator. I run K3s in my own lab and chose not to use it here. The workload is a handful of services with one operator and modest traffic. A cluster would add substantial operational surface without providing capability this platform needs. Docker, Proxmox, Traefik and GitLab CI give me isolation, repeatable deployment and independent release paths, which is the actual requirement.
Public endpoints are hostile input. Every submission endpoint sits behind Cloudflare Turnstile, rate limiting at five requests per IP per hour and a 24-hour cooldown, with a CORS allowlist and helmet limiting what the service will talk to at all. The newsletter uses genuine double opt-in, so nobody joins a mailing list through a typo or a bot. Containers run non-root. None of this is exotic, and that's the point: proportionate controls at the boundaries where they matter.
Development happens in staging. Early on I previewed work through an ngrok tunnel. It worked, but it made the normal development path a temporary one. That's now a permanent staging URL behind the same ingress as production, which changed the model from expose-and-hope to deploy, verify, promote.
The boundaries
The site doesn't know how mail is delivered. The mail stack doesn't know how content is published. The gateway doesn't know how the site is built. Each service has a defined responsibility, its own pipeline and its own deployment path, so a change in one can't block a change in another.
That separation is the platform. Everything else is implementation.
Operating targets
- £0 marginal infrastructure cost, on existing Proxmox/OPNsense hardware and genuinely free tiers (Turnstile, Resend, Telegram, Umami)
- 30-day metric and log retention, 10 alert rules routed to Telegram
- Nightly off-site backup of gateway state to Azure Blob Storage, seven weekly, four monthly and one yearly copy retained, restore tested
- Independent deploy pipelines for site, gateway, mail and observability, via GitLab CI with a DIND runner and systemd auto-start
What happens if I stop volunteering
The obvious weakness in a £0 self-hosted platform is that it runs on hardware I own. There isn't one answer to that, there are three, and the difference between them is the point.
The site survives untouched. Content, templates and build configuration are all in Git, and the output is a static artefact. It redeploys to GitHub Pages, Netlify, Vercel or any free static host without migration, without data and without cost. That's the component the organisation actually depends on day to day, and it is genuinely free to keep running.
The gateway is portable but not free. It's a container with a SQLite volume, so it runs anywhere, but the free tiers that would host it either sleep or throttle. A successor with no budget would more realistically swap it for a hosted form provider and a mailing list service. Signups and newsletters keep working. What's lost is the state machine, the consent record and the abuse controls, which is a real downgrade rather than a like-for-like move.
Domain email is the genuine discontinuity. It's the one component with no credible free hosted equivalent, so continuity there means either a small monthly bill or going back to personal accounts.
Nothing in the platform lives only on my hardware, and nothing is held together by undocumented state. What varies is what each component costs to move. Self-hosting is a deployment choice rather than an architecture, and the useful version of that claim isn't that the choice is reversible, it's knowing the price of reversing it for each service.
Outcome
- Replaced a WordPress site, hand-sent newsletters and WhatsApp-invite onboarding with one platform: CMS-driven static site, automated signup gateway with double opt-in, and self-hosted domain email
- Gave committee members a git-based editorial workflow with no per-seat licensing and no third-party platform lock-in
- Made signup volume, abuse attempts and service health observable without a paid monitoring tier
- Removed the recurring hosting bill entirely
What I owned
I designed, built and operate the whole thing: the Eleventy site and content architecture, the CMS integration, the gateway service and its persistence and validation layers, the mail stack, containerisation, Traefik ingress, network segmentation on the Proxmox/OPNsense DMZ, all four CI/CD pipelines, staging, and the observability and alerting stack.
What I'd change next
The remaining architectural dependency is the physical host. If the platform needed stronger continuity guarantees, the next investment would be infrastructure redundancy rather than more application complexity.
The gateway's SQLite state machine is the right choice at this scale and will stay the right choice for a long time, but it's the component where growth in membership volume would force a decision rather than an optimisation.
Comments load automatically via GitHub (Utterances), which may set cookies. See the privacy policy.