The Agentic Tekton

The Cost of Staying Invisible

A confidential internal hosting platform needed to keep several demo projects private and unlinkable to their owner, without a second AWS account or a zero-trust product license. The fix was removing the inbound network path entirely, and writing it once as a module instead of once per project.

I run a small internal platform whose only job is hosting things I am not ready to talk about. Demo projects, early experiments, the occasional client-facing prototype under an NDA, each one needing to stay private and, ideally, unlinkable back to me if someone went looking. The requirement was never exotic. It was cheap, small-scale, and permanent: a handful of low-traffic apps, reachable by exactly one or two named people, invisible to everyone else, without a second AWS account and without a per-seat zero-trust product license.

The actual problem

The obvious first answer is a VPN. The second obvious answer is a second AWS account per project, so a compromise in one can never see another. Both work. Both also cost more than the problem justifies: a VPN means a client to install and a tunnel to keep alive, and a second account per project means a second bill, a second set of IAM boundaries to maintain, and a lot of plumbing for something that, most of the time, sits at zero traffic and zero cost of being wrong.

What I actually needed was narrower: remove the thing that has to be defended, rather than defend it well. A security group with an inbound rule is a thing that can be misconfigured, scanned, or left open by accident. A security group with no inbound rule at all cannot be any of those things, because there is nothing there to get wrong.

The pattern

Each hosted project runs on ECS Fargate with a cloudflared sidecar in the same task. The security group has zero ingress rules, full stop. Nothing can reach the task directly. The only path in is the tunnel the sidecar opens itself, outbound, to Cloudflare’s edge. In front of that tunnel sits Cloudflare Access, checking a person’s identity against an allow-list before a request is ever proxied through to AWS at all. Two identity providers are wired in parallel, GitHub OAuth and Google OAuth, so either sign-in method gets an allow-listed person through without forcing everyone onto the same account type. GitHub Actions gets a short-lived STS role through GitHub’s own OIDC provider, scoped to one repository, so there is no long-lived AWS key anywhere in any project’s CI.

The part that actually made this durable, rather than a one-off script, was writing all of it as a single Terraform module and calling it once per project. Four real projects run on this platform today, each one a module block, not a forked copy of the pattern that slowly drifts from the original as each project accumulates its own patches. That count, and everything in this section, comes from my own private operational record, not something a reader can independently verify. Reuse was not a nice-to-have added after the fact: in my own experience, each later project stood up faster than the one before it, with less to rediscover each time, though I never timed it precisely enough to put a real number on that.

What broke

The pattern held. The process around it did not, once. This account is also from my own private record, described honestly rather than left vague. A stale local Terraform worktree, still pointed at the shared state backend, ran a plan against infrastructure that had already moved on from a different session. The apply that followed destroyed a live project’s running infrastructure. No data was lost, because the app itself held nothing that mattered outside its own container, but the project was down until it got rebuilt from the same module block that had built it the first time, which took minutes, not a redesign. The same failure against a project that actually held state would have meant real data loss, not a quick rebuild, and nothing about this fix would have prevented that.

The fix was not just a smarter locking mechanism or a more defensive Terraform wrapper, though the gap those would close is real and, honestly, still open: enforced state locking would fail closed against a stale plan automatically, with no memory required, and that hasn’t been added yet. What did change immediately, the same day, was cheaper and shipped faster: a rule, rebase against the real remote state before every plan, no exceptions, checked before the plan runs rather than trusted to memory. The module survived the incident because the module itself was never the problem. The workflow around who gets to run it, and against what state, was, and the honest state of that workflow today is a human discipline, not yet a technical guarantee.

The tradeoff, named honestly

Removing the inbound path is a single control, not a layered one. There is no NAT gateway, no private subnet tier, nothing standing behind the zero-ingress security group if Cloudflare Access itself is ever misconfigured or a tunnel token leaks. For four low-traffic, cost-constrained personal projects, that is a fair trade: a NAT gateway alone costs more per month than the rest of this stack combined, and there is no second team, no compliance regime, and no dual-control requirement pushing back on it. It would be the wrong call for a workload that actually needs defense-in-depth independent of any single vendor. The honest version of this pattern says so, rather than presenting “no inbound rule” as a universal security upgrade instead of the specific, scoped tradeoff it is.

The artefact

Because the platform this pattern runs on stays private by design, the reusable module itself now exists as its own thing: zero-inbound-network, a public, generic re-implementation of the same pattern, terraform validate-clean, not deployed anywhere. It is not the real platform’s Terraform, and it should not be treated as production-ready as checked in, it is a reference to read and adapt: a root module wiring a zero-NAT VPC and two Cloudflare Access identity providers, and an infra/terraform/modules/hosted-app/ unit that is the actual reusable part, the security group, the tunnel, the Access policy, the OIDC deploy role, all in one block. Reading it is the fastest way to see whether the tradeoff above fits your own situation before adopting any part of it. For a step-by-step look at the request path itself, Stealth Labs walks through it as an interactive diagram rather than prose.

What next

The real platform’s own next real gap is the one this pattern deliberately does not close: everything still shares one AWS account, so account-level blast-radius containment across projects is a matter of the module’s own IAM/security-group scoping, not a hard account boundary. Whether that gap ever needs closing depends entirely on what eventually gets hosted there, and is worth revisiting the day a project’s confidentiality requirement stops being “small and personal.”