The Hosting Estate
Six public websites, one machine, zero open inbound ports, and a managed-hosting bill of
roughly nothing. The Hosting Estate is the part of my platform that proves self-hosting isn’t a hobbyist
trade-off — it’s a repeatable production pattern. Every site is reconstructable from git, isolated from
its neighbours, and served to the public without a single port forwarded on the host. The site you’re
reading this on is itself the sixth stack on that estate, which I’ll come back to.
The brief was simple and the constraints were strict: host multiple unrelated WordPress sites on a
single node (prokopton), keep them isolated so one compromise or one runaway query can’t take down the
others, expose them publicly with proper TLS, and never open an inbound port to the internet. Those last
two usually pull against each other. They don’t have to.
One stack per site
Each site is its own Docker Compose stack: a WordPress container, its own MariaDB, its own network.
No shared database, no shared web server, no co-tenant blast radius. A compromise in one site is contained
to that site’s network and data; a query that pins one database doesn’t touch the others. The cost of
this isolation is a little more memory and a little more orchestration, and it’s worth every byte.
The theme for each site is a git repository, bind-mounted into the container. Git is the source of
truth — the running container holds no state I can’t rebuild. Deployment is a pull. If a node dies, the
estate is reconstructable from repositories plus a database dump, not from a snapshot nobody remembers
how to recreate.
# per-site compose stack — isolated db + network, theme bind-mounted from git
services:
db:
image: mariadb:10.11
restart: unless-stopped
environment:
MARIADB_DATABASE: ${DB_NAME}
MARIADB_USER: ${DB_USER}
MARIADB_PASSWORD: ${DB_PASSWORD} # from env, never committed
volumes:
- dbdata:/var/lib/mysql
wordpress:
image: wordpress:php8.3-apache
restart: unless-stopped
depends_on: [db]
volumes:
- wpcore:/var/www/html
- ./theme:/var/www/html/wp-content/themes/site # git = source of truth
volumes:
dbdata:
wpcore:
Public, but with no front door
Public exposure comes through Cloudflare tunnels, with Caddy as the reverse proxy in front of the
stacks. The mechanism matters: the node makes an outbound connection to Cloudflare’s edge, and
public traffic rides back down that established connection. There is no listening port on the host for an
attacker to find. Nothing is port-forwarded. There is no public IP to scan, no SSH exposed, no
:443 waiting on the open internet. TLS is automatic and terminated at the edge.
That last detail introduces the one piece of glue every WordPress-behind-a-tunnel setup needs. Because
TLS terminates at the edge and the traffic reaches WordPress over plain HTTP inside the tunnel, WordPress
thinks it’s running insecurely — it generates http:// URLs, breaks redirects, and mixes
content. The fix is a tiny must-use plugin that trusts the proxy’s forwarded protocol header:
<?php
// mu-plugins/https-proxy.php — WP sits behind a TLS-terminating tunnel.
// Trust the edge's forwarded-proto so WP detects HTTPS correctly.
if ( isset( $_SERVER['HTTP_X_FORWARDED_PROTO'] )
&& $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https' ) {
$_SERVER['HTTPS'] = 'on';
}
With those pieces in place the build recipe is repeatable: spin up a per-site compose stack, drop in
the https-proxy mu-plugin, bring the site up on a staging hostname, verify, then cut the
tunnel route over to the production hostname. New site to live in minutes, every one identical in shape.
A war story: the timeouts that weren’t the firewall
Shortly after the estate went up, containers started failing to reach each other. A site
would lose its database for thirty seconds, throw 500s, then heal itself with no intervention. The
symptom screamed firewall — dropped east-west traffic is what firewalls do — so the obvious move was to
go add rules. I didn’t, because the pattern was wrong: firewall blocks are consistent, and this
was intermittent and self-healing. Firewalls don’t flap.
So I watched the interfaces instead of guessing. tcpdump on the Docker bridge showed SYNs
leaving and never arriving next door. ip link told the real story: the veth pairs Docker
creates per container were briefly going DOWN and getting renamed out from under the daemon.
Something else on the box was claiming interfaces it didn’t create.
That something was systemd-networkd, which by default tries to manage every interface it
sees — including Docker’s virtual veths. The two daemons were fighting over the same NICs: Docker created
a veth, networkd saw an “unconfigured” interface and reset it, the link bounced, and the container lost
its neighbours until Docker repaired it. Not the firewall. A governance fight between two pieces of
init-adjacent plumbing. The fix was to tell networkd to leave Docker’s interfaces alone:
# /etc/systemd/network/10-docker-veth.network
[Match]
Name=veth* docker* br-*
[Link]
Unmanaged=yes
The obvious suspect was the wrong one, and the evidence said so before I touched anything. Intermittent
and self-healing is a different signature than blocked. Watch the interfaces, not your assumptions.
What it buys
The economics are stark. Managed multi-site hosting at this footprint would be a recurring monthly
invoice that scales with every site added. Here the marginal cost of another site is a few hundred
megabytes of RAM and ten minutes of setup. Everything is reproducible from git, isolation is real rather
than logical, and there’s no co-tenant I have to trust. The trade is that I’m the ops team — but as the
war story shows, that’s exactly the control that lets me fix a problem at the layer it actually lives in.
The estate as an agent’s launch pad
“New site to live in minutes” turns out to be the foundation for something more interesting than
hosting. If standing a site up behind the tunnel is cheap, repeatable, and needs no open port, then an
agent can do it — at volume, unattended. That’s exactly what runs on top of this estate as a
lead-generation engine for the studio out front.
The pipeline picks a South African service business — a prospect — and builds them a personalised,
single-page mockup of what their site could be. It scrapes their public footprint, a local model on my
own GPU profiles their brand and the real search demand in their niche, and a coding agent assembles the
page. The output isn’t WordPress; it’s a lightweight pre-rendered static page (Astro + Tailwind) that
loads instantly and costs nothing to serve. Then the estate does what it does best: the page goes live in
minutes at <slug>-showcase.inkypyrus.com — a single-label hostname the
*.inkypyrus.com wildcard certificate already covers — served back down the same outbound
tunnel, with no front door opened to put it there.
Every mockup is watermarked and carries a 24-hour takedown SLA: email the slug and it’s gone. These
are unsolicited proofs of concept, and that constraint is part of being a good guest in someone else’s
brand. The prospect gets a link to a site that already looks like theirs, next to evidence that I
understand what their customers are actually searching for. s20.inkypyrus.com below — “a
mobile page for trades, built in hours” — is one of these.
This is the quiet payoff of the whole architecture. The same tunnel-and-isolation pattern that serves
production sites also lets an agent publish disposable, personalised ones at the speed of a cron job. The
infrastructure stops being a cost of doing business and becomes a way to win it.
Live / proof
Real sites serve real traffic off this estate right now — and this very site is the sixth stack on it,
which is about as direct a proof as a hosting claim can have:
- afrecosoil-wp.inkypyrus.com — the AfrEcoSoil regenerative-agriculture site (WordPress + WooCommerce), mirrored on this estate
- s20.inkypyrus.com — a mobile page for trades, built in hours