Software 5 June 2026 7 min read One practice, one container By Old Forge Technologies 85 reads Contents In the last entry I promised to come back for the machinery. Here it is, with the sharp edges filed off for public consumption. Felgate is a practice portal for nutritional therapists, and it is multi-tenant: many practices, one platform. There are two defensible ways to build that. The common one is a single shared database with a `tenant_id` column on every table, and a fervent hope that you never forget a `WHERE tenant_id = ?` in a query. The other is to give each practice its own container, its own database and its own encryption key, so that two practices can never share a row in the first place. Felgate takes the second road. That road has a reputation: it is supposed to be too expensive to operate. A database per customer sounds like a sysadmin's second job. So the interesting part of Felgate is not the isolation itself, it is the machine that makes the isolation cost nothing to create. A practitioner pays, and roughly two minutes later they have a private, fully isolated portal at `their-practice.felgate.co.uk`, with no human in the loop. This is the shape of that, and the three places reality pushed back. ## The shape Every request passes through a couple of front doors before it reaches anyone's data. TLS is terminated once, with a single wildcard certificate for `*.felgate.co.uk` that renews itself, so there is never a per-subdomain certificate to chase. Behind that, routing is by hostname alone: the apex serves the marketing site, `app.` is the control plane, and each `<slug>.felgate.co.uk` lands on that practice's own container. The control plane is the part that does the real work here: it is the process that stamps out and wires up those containers when someone new arrives. The container itself is a "gold image": one Docker image, identical for every practice. Everything that makes a container *this* practice - its identity, its database, its encryption key, the features its plan unlocks - is handed in at start-up. There is exactly one image to build, and exactly one image to trust. ## What happens when someone pays A signup creates a pending tenant and a Stripe Checkout session, then hands the visitor to Stripe. When they pay, Stripe rings a webhook, and that webhook is where the whole show runs. In order: 1. **Secrets first.** The engine mints the practice's keys and a one-time owner-activation token. The token is made before anything is built, so the link in the welcome email and the link the new portal will accept are guaranteed to be the same string. 2. **A database of its own.** A fresh Postgres database and a dedicated role to own it, readable by nobody else. 3. **A container of its own.** The gold image, started with this practice's settings, on a private internal network. 4. **A route of its own.** A web-server entry for `<slug>.felgate.co.uk`, tested before it goes live, then a matching DNS record if one is needed. 5. **A health gate.** The pipeline waits, politely, for the new portal to answer a health check. Only when it does is the practice marked live. 6. **The welcome.** The owner gets the activation link, sets a password, and the portal is theirs. On the happy path it is gloriously boring, which is the goal. The work was all in the unhappy paths, and there were three good ones. ## Reality, part one: the webhook that times out Building a portal takes a couple of minutes. To anything making an HTTP call, two minutes is an ice age, and Stripe's webhook caller is no exception: it gives up waiting and tries again. So the one property this endpoint absolutely must have is that **ringing it twice does the work once.** My first attempt got this charmingly wrong. The handler would record the payment, then start the slow build. Stripe, still waiting, would retry, hit the half-finished state, throw an error, get a failure back, and dutifully retry *again*. A perfectly valid, signed payment had turned into a message that poisoned itself on a loop. The fix is to give the webhook a bouncer. The very first thing it does with any event is write that event's id into a table with a uniqueness rule, in the same breath as doing the real work, so the two either succeed together or fail together. If the same event knocks a second time, it trips the uniqueness rule, the attempt unwinds cleanly, and the webhook smiles and says "already handled, thanks". A crash halfway through leaves nothing half-built for the retry to trip on. There is a second bouncer behind the first: if a practice is already being built or already live, a duplicate payment event just files the paperwork and walks away. ## Reality, part two: Stripe tells you things out of order Payment events do not arrive in the order they happened. That sounds like a footnote until it bites. A cancellation is final: it starts a countdown that later tidies the practice away. But an older "still subscribed" message can turn up *after* the cancellation, like a letter lost in the post. Believe it, and you cancel the countdown, and the practice's infrastructure lives on forever, billed to nobody. The cure is to treat a cancellation as a one-way door: once a practice has walked through it, no late-arriving good news is allowed to drag it back. ## Reality, part three: owning a database is not enough This one only shows itself on a brand-new portal's first breath. On modern Postgres, *owning* a database does not, by itself, let you create tables in it - a subtlety that is completely invisible until a freshly minted account connects to its very own database and is politely refused permission to build a single thing. The fix is a single extra line at creation time, handing the new owner the keys to its own house. One line of SQL, found the hard way, sitting in the gap between "the database exists" and "the database is usable". ## Telling the truth about being live The worst thing an automatic pipeline can do is declare success when it has actually failed. If the routing step ever fails, the lazy option is to shrug, log a warning, and carry on - the container is up, after all. That way lies the cruelest outcome of all: a practitioner who has paid, received a cheerful welcome email, and clicks through to a dead page, while nothing on my side raises a hand. So the pipeline refuses to fake it. If any essential step fails, the whole thing stops, the practice is rolled back to pending, and I get paged. A practice marked live is one you can actually reach. There are no in-between states, because a half-built practice that has already paid is the single failure you cannot afford. ## Walls between practices The reason for all the per-practice everything is simple, and it is the part worth saying out loud to anyone trusting Felgate with client records. Each portal is sealed off: its own database, its own keys, its own little sandbox, reachable only through the front doors and never directly from the open internet. Sensitive information is encrypted at rest under a key that belongs to that one practice, so a problem is, at worst, one practice's problem and never everybody's. The portals never handle payment card details themselves; that conversation happens only between the customer and Stripe. The whole design has one north star: the blast radius of anything going wrong is a single practice. That is also the luxury of building it this way. There is no query in Felgate that can hand one practice's clients to another, because there is no shared table on which to forget a filter. A loud neighbour cannot starve the rest. A bad day is one practice's bad day, not the whole platform's. The "expensive" way to do multi-tenancy turns out to be perfectly affordable once the expensive part is done by a robot. That robot began life as a single line in the last post: *the first public version went up this week.* This is what was humming away underneath it. There is more still on the bench, and I will write it up when it has earned the words.