Confidential by absence, not by defense.
Stealth Labs is the name for a small internal platform that hosts things not ready to be talked about yet: demo projects, early experiments, the occasional confidentiality-bound prototype. Its job is to keep each one private and hard to link back to its owner, for a handful of low-traffic apps, without a second AWS account or a per-seat zero-trust product license. What stays private is the specifics — which projects it hosts, and the platform's own infrastructure identifiers — not the fact that it exists; see the write-up for how that disclosure line gets drawn. What's below, and what's linked, is the pattern the platform runs on, generalized into a public repo built specifically so this page has something real to point at.
The problem
The obvious answers are a VPN or a second AWS account per project. Both work, and both cost more than a handful of low-traffic, low-stakes apps justify: a VPN means a client to install and a tunnel to keep alive, a second account means a second bill and a second IAM boundary to maintain. The narrower fix is to remove the thing that has to be defended rather than defend it well. A security group with an inbound rule is something 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 architecture
Each hosted app runs on ECS Fargate with a cloudflared sidecar in the
same task. The task's security group has zero ingress rules — the only path in is
the outbound-initiated Cloudflare Tunnel the sidecar opens itself, gated by
Cloudflare Access before any request reaches AWS at all. Step through the request
path below.
| Component | Resource | Role |
|---|---|---|
| Compute | ECS Fargate task | App container + cloudflared sidecar, one task, shared network namespace. |
| Network | VPC, public subnet, zero-ingress SG | No NAT, no inbound rule at all — nothing needs one, so none exists. |
| Tunnel | Cloudflare Tunnel | The only path a request can take from the internet into the task. |
| Auth edge | Cloudflare Access + two IdPs | GitHub and Google OAuth, gating the tunnel hostname before AWS is ever touched. |
| CI identity | GitHub Actions OIDC role | Short-lived STS credentials scoped to one repo — no long-lived AWS keys. |
| Reuse unit | infra/terraform/modules/hosted-app/ | A second app is one more module block, not a second copy of the pattern. |
The tradeoff, named honestly
Removing the inbound path is a single control, not a layered one. There's 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 a small number of low-traffic, cost-constrained personal or demo projects, that's a fair trade — a NAT gateway costs roughly $32/month minimum before data processing charges, more than a single app's Fargate task (about $5-15/month), and there's no compliance regime pushing back on it. It would be the wrong call for a workload that needs defense-in-depth independent of any single vendor; the repo's own well-architected notes say so explicitly, including the residual risk this pattern does not close (unrestricted egress) and what it deliberately doesn't solve (account-level multi-tenancy isolation, high availability). The same notes are just as direct about two operational risks worth reading before adapting this repo: Terraform state holds the tunnel and OAuth secrets in plaintext, so the backend needs to be encrypted and access-controlled, not just locked; and each app's CI trust follows its GitHub repo's current name, not an immutable ID, so a renamed or transferred repo needs that trust re-checked.
Why this is worth doing
The value isn't the individual security group, it's writing the whole pattern once
as a reusable Terraform module and reusing it per project instead of hand-building
a stack each time. A second hosted app becomes one more module block
in main.tf, not a fork that slowly drifts from the original as each
project accumulates its own patches. And because each app's desired_count
is its own on/off switch, an idle app costs almost nothing — the task definition,
ECR repo, DNS record, and Access policy all still exist at zero traffic, and
setting it back to 1 brings it live behind Access in about a minute
with no infrastructure to stand up.
Read the pattern, or read the repo
The full narrative, including what actually broke once and how it got fixed, is in
The Cost of Staying Invisible.
The Terraform itself — terraform validate-clean, not deployed
anywhere, nothing outside the repo referenced by any module source — is public.