Introduction
Every project card on my site carries a button that says Request access. It doesn’t link to a running demo, because at the moment you click it the demo does not exist. There is no server waiting, no container idling, no URL that resolves to anything. What exists is a promise: give me a few minutes and there will be one — with its own subdomain, its own certificate, its own isolated environment — and twenty minutes later there will be nothing again.
The previous post was about what runs inside one of those environments: a RAG chatbot over this blog’s posts. This one is about the machinery that brings a demo into existence on request, routes traffic to it, and then deletes it — the same machinery that will serve the next demo, which I’m already building.
There’s also a rule I set for myself, and it’s the reason this post is dated now and not three months ago: I wasn’t going to write about this infrastructure until a real demo, requested by a real person through the real flow, had provisioned, served, and destroyed itself. Infrastructure that has never carried a real payload isn’t infrastructure — it’s a diagram. The diagram was finished in June. The first successful provision took considerably longer.
The problem was never “I want demos on my site”
Say “I want live demos” out loud and the solution seems obvious: deploy them, leave them up, link them from the project cards. Which, for a portfolio of N projects, means N services running permanently so that — statistically — nobody is looking at them.
It isn’t only the bill; it’s that the shape is wrong. A demo is something a person looks at for a few minutes and then leaves. And it compounds: every demo you add is one more permanently running thing to patch, monitor, and pay for — so the rational response to that design is to stop adding demos, exactly backwards from what I wanted.
So the actual goal was never demos. It was this: resources should exist while a human is looking at them, and not one minute longer. The demos are the visible consequence; the rule is the project. With one corollary that ended up shaping every decision: if nothing may stay parked, then teardown has to be a guarantee, not an intention. Anything I could forget to clean up, I will eventually forget to clean up — on a Tuesday, three months from now, while thinking about something else.
The constraints defined the design
Same as the last project, I wrote the rules before the first line of anything:
- Nothing parked. No per-demo compute alive between sessions. Not “scaled down” — gone.
- Teardown is deletion, not cleanup. The whole demo lives inside a disposable container that I destroy in one call. I don’t tidy up; I throw away the box.
- Whatever must persist has to cost ~$0 idle. A small permanent core is acceptable; a permanent bill is not.
- Bounded sessions, several independent ways. A hard cap, an idle timeout, and a kill switch — because any single limit is one bug away from being no limit.
Everything else fell out of those:
| Piece | Choice |
|---|---|
| Per-session environment | An ephemeral resource group (rg-demo-<slot>), created and deleted per demo |
| Orchestrator | GitHub Actions with OIDC (provision / teardown / reap workflows) |
| Front door | A persistent gateway container: TLS, session auth, slot → environment routing |
| Addressing | A pool of one-level subdomains, demo01…demo10.alexisalulema.com |
| State | Table Storage: one row per slot |
| Punctuality | A Cloudflare cron Worker, once a minute (the second attempt — more on this below) |
Two halves: the switchboard and the disposable part
The whole design is a split between what has to survive and what must not.
The permanent half is deliberately tiny: a gateway (a reverse proxy that terminates TLS,
validates the session, and routes a slot to its environment), the shared container environment that
hosts it, and a table holding the pool — ten rows, demo01 to demo10, each one either free or
occupied. The gateway scales to zero, so when nobody is connected the permanent half is a handful of
table rows and a sleeping container.
The disposable half is everything else. Each session gets its own resource group containing the entire demo, and teardown is a single delete of that group. That’s the part I actually cared about: I don’t clean up resources, I delete the container they live in. There’s no orphaned disk, no lingering app definition, no configuration drift, and — crucially — no partial-cleanup path where something survives because a step failed halfway through.
Persistent (≈$0 idle)
┌───────────────────────────────────────────────────┐
│ Gateway (TLS · session JWT · slot→env routing) │
│ Pool state · reaper cron (1/min) │
└───────────────────────────────────────────────────┘
│ routes demoNN → environment
┌───────────────────┼───────────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│rg-demo- │ │rg-demo- │ │ demo03 │ Ephemeral
│ demo01 │ │ demo02 │ │ free │ (created and deleted
│ (RAG) │ │ (other) │ │ │ per session)
└─────────┘ └─────────┘ └─────────┘
Scale-to-zero would have parked the definition: the app still exists, its configuration still exists, and it is still a thing I own and have to think about. Deleting the resource group is categorical in a way a scaling policy never is.
Two details worth recording. Demos that hold no per-user state can be marked shareable, so a second
visitor attaches to the environment that’s already running instead of waiting for a provision;
stateful ones like the chatbot always get their own. And each demo answers at
demoNN.alexisalulema.com — one level of subdomain, which turned out to be architectural rather
than cosmetic: a wildcard certificate covers demo01 for free, while a tidier
demo01.demos.alexisalulema.com would have required a paid certificate tier, forever.
When a field changes meaning, every reader is a bug
Adding those shared environments meant changing what a slot’s status value means, and moving the
expiry to a different place. Clean change, two files, one afternoon.
The gateway — a different service, in a different container, written in a different language — was still routing only when it saw the old value, and still reading an expiry field that had moved. Every demo, without exception, would have returned 410 Gone. I caught it before it shipped, but only because something made me go re-read the gateway.
Changing the meaning of a state field is a breaking API change to everyone who reads it — and “everyone” includes readers that don’t live in the code you’re editing, in languages your compiler isn’t looking at. It’s the strongest argument for renaming a field whose semantics changed: a rename turns a silent behavior change into a loud failure in every consumer at once.
Why a human approves (and the bug I never saw coming)
An endpoint on the public internet that provisions cloud resources on request is a bill waiting to be written by a stranger. So there’s a person in the loop: a request creates a ticket and sends me an email with an Approve button, and only that click starts a provision.
Which produced the single funniest bug of the project. Email scanners click your links. Gmail —
and every corporate mail-security product — prefetches URLs inside messages to check them for
malware. My approval link was a plain GET. So demos were being provisioned, faithfully and
automatically, by the security scanner whose job was to verify that the email about provisioning
demos was safe to open.
The fix is now permanent policy in this codebase: the link’s GET renders a confirmation page that
does nothing at all, and the real approval is the POST behind that page’s button. “Never put a
state-changing action behind a GET” is a rule I already knew in the abstract sense. Now I know it
in the check-the-resource-group-list sense, which turns out to be a different kind of knowing.
The reaper, or: best-effort is not a guarantee
The first design for teardown was the obvious one: a scheduled workflow, every five minutes, sweeping for expired demos. Free, simple, already had the credentials it needed. I shipped it and moved on.
Then a demo was still alive at 25 minutes, against a 20-minute cap.
The scheduler was the problem. Scheduled workflows on GitHub Actions are throttled — a */5
cron that I had read as “every five minutes” was, in the run history, firing every one to two
hours. This is documented behavior: schedules are best-effort. And “best-effort” in a docs page
means “not your reaper” in production.
What made it genuinely dangerous is the shape of the failure. Access was being cut perfectly: the session token expires at twenty minutes, the gateway stops routing, and the visitor experiences a demo that ended exactly on schedule. Nothing looked broken from any angle a user or a health check could see — while the resource group kept running, with a replica floor above zero, until a throttled cron eventually got around to noticing. The user-visible layer was flawless and the actual consumption was roughly six times what I’d designed for. That’s the worst class of infrastructure bug there is: the one whose only symptom is money.
The redesign replaced the scheduler with a Cloudflare cron Worker that fires once a minute and
does exactly one thing: POST /api/demo/reap. The backend sweeps what it was always going to sweep
— expired sessions, empty environments, idle environments — and dispatches a teardown per slot.
Teardown latency went from “somewhere between five minutes and two hours” to about a minute, and the
Worker costs nothing.
A timer you don’t control is not a timer, it’s a hint. If being late has a cost, punctuality has to be somebody’s product guarantee, not a best-effort convenience bundled with something else you were already using.
That redesign also brought the idle timeout, which has one subtlety I like. Only the gateway can see
demo traffic — everything goes through it, already authenticated — so it stamps a lastActivity
timestamp. But only client-initiated activity counts. A request, a socket opening, a message
from the client. Data the server pushes out does not. Get that backwards and a streaming demo
keeps itself alive forever on behalf of a visitor who closed the tab an hour ago, which is the
precise failure mode the idle timeout exists to prevent.
The bug that destroyed demos before anyone opened them
Releasing a slot reset its fields — but not lastActivity. So a new demo claiming that recycled
slot inherited a timestamp from the previous tenant, potentially hours old. The reaper did its
arithmetic, correctly concluded that this demo had been idle far past the limit, and deleted it —
in under two minutes, before the visitor had ever opened it. From the outside: an approval email
arrives, you click the link, and it tells you the demo isn’t active. The resource group is already
gone.
Every individual piece was right. The reaper’s math, the stamping, the release — each correct about everything it explicitly handled. The bug lived in the space between them: state that was correct when it was written and stale by the time it was read.
When you promote something to persistent state, the write is the easy part — audit the whole lifecycle. Claim, release, reuse. A recycled slot carries the previous tenant’s belongings forward unless you deliberately empty it.
And it was found by a human noticing that resource groups were vanishing suspiciously fast, not by a test. The suite was green throughout — as it was, in the previous post, while the chatbot greeted people in the wrong language. I’m starting to think that’s less of a coincidence and more of a pattern.
Declarative infrastructure does not ignore what you don’t mention
One afternoon I redeployed the shared template to add a couple of environment variables to the gateway. Minutes later, every demo subdomain was failing with a 525 SSL handshake error at the edge.
The custom-domain bindings — the hostnames attached to the gateway with their certificate — are created with CLI commands, outside the template, so the template doesn’t list them. And a declarative template does not read “not mentioned” as leave that alone; it reads it as “this property should be empty.” The redeploy dutifully erased every hostname binding. I had built a comfortable mental model where the template owned some properties and my CLI commands owned others and we all got along. The template did not share this model.
When a resource is co-owned by a declarative template and imperative commands, the declarative one wins every single deploy. Either bring the property into the template, or accept a documented ritual around every deploy — mine is that environment-variable changes go through a targeted update that doesn’t touch ingress, and any real redeploy is followed by re-binding the hostnames. What does not work is expecting the two of them to politely divide the property space between them.
”It starts, it doesn’t finish, it dies”
For weeks the infrastructure had been provisioning my two throwaway test payloads without complaint. Then I plugged in the RAG chatbot: the first demo with three containers in one pod, a resource split between them, and a startup probe.
It failed instantly. Every time. And because the provisioning workflow deletes the resource group when a deployment fails — which is correct, and is the whole “nothing parked” rule doing its job — the evidence deleted itself along with it. My note from that day reads: it starts, it doesn’t finish, it dies.
Two rejections, in a chain, each one uncovered only after fixing the previous:
| Rejection | Reality |
|---|---|
Startup probe failureThreshold: 40 | The platform caps it at 30 |
| Invalid total resources | I’d assumed 4 vCPU / 8 GiB was available; the real ceiling for this environment type is 2 vCPU / 4 GiB |
What made them diagnosable at all was a differential: the two simple demos provision fine, this one doesn’t — what does it have that they don’t? A probe. Then, after that, a multi-container resource split. Keeping a boring known-good payload sitting next to the interesting one turned out to be worth more than any amount of log reading.
And the detail that generalizes: the template compiler validates none of this. Building the template checks it against a schema, and the schema is perfectly happy with a threshold of 40 and with 4 vCPU. Those are runtime limits, enforced by the platform’s preflight validation when you deploy. “It compiles” and “it deploys” are different claims, and only one of them means anything.
That second row is my favorite link between these two posts. The 2 vCPU / 4 GiB ceiling is the exact constraint the previous post spends its entire performance section fighting: the half-billion parameter model, the top-3 retrieval, the 400-token chunks, the whole prompt-as-budget discipline. Every one of those decisions traces back to an error message I got here, in the infrastructure. The platform didn’t merely host the demo. It designed it.
What I deliberately didn’t do
- No cluster. A cluster is a parked resource with a management story attached, which is the precise thing this project exists to avoid.
- No authentication inside each demo. The gateway owns it, once. A new demo gets session auth, TLS, and expiry enforcement by existing — it doesn’t implement anything.
- No pinned image digests. Each provision pulls the current image, which is how publishing a blog post refreshes the chatbot’s corpus without anything in this system being aware it happened.
- No shared state between a demo and the platform. A demo never learns that it’s ephemeral, and
can be killed mid-request without ceremony. That’s what makes teardown a
deleteinstead of a negotiation.
Conclusion
At rest, all of this costs effectively nothing: the gateway sleeps at zero replicas, the pool is ten rows in a table, the reaper Worker is free. When someone presses the button, it costs one pod for as long as a human is actually watching it.
But the number I like most is the marginal one. Adding the next demo costs zero permanent resources. A new project registers itself in a few places, brings a template describing its own containers, and inherits the pool, the gateway, the session auth, the idle detection, and the reaper without implementing any of them. The cost of the next demo isn’t “one more service running forever” — it’s a few minutes of compute on the day somebody actually wants to see it. That’s the difference between a portfolio that discourages me from adding to it and one that doesn’t.
I said at the start that I wouldn’t publish this until a real demo had provisioned for a real person. It has — and what that proved wasn’t that the diagram was right. The diagram had been right since June; it’s the easy part, a couple of boxes and an arrow that says “delete the resource group.” What the first real provision proved was everything the diagram doesn’t say. Every problem in this post lived in a gap between two things that were each individually correct: a state model and a reader in another container, a cron expression and its scheduler’s actual promises, a template’s ownership and a CLI’s, a released slot and a claimed one.
They’re all the same failure, wearing different clothes: I verified the pieces and assumed the seams. Which is, I suspect, mostly what distributed systems are — seams, with pieces attached.
The next demo will be cheaper than this one was, because the expensive part is already paid for. That’s the real return on all of it: not that the chatbot has somewhere to run, but that whatever I build next already does, and it will cost nothing at all until the moment someone is curious enough to press a button.
Which you can do, right now, over at alexisalulema.com/projects. Somewhere a resource group is being created with your session’s name on it, and in twenty minutes there won’t be a trace that it ever existed.