One VPS, Zero Open Service Ports: Anatomy of a Tunnel That Serves Everything
The infrastructure serving our products is deliberately small: one VPS, everything in docker compose, and no service port open to the internet. Not 80, not 443. An external scan of the box finds SSH and nothing else. And SSH exists for exactly one job, which is not deployment.
All HTTP traffic enters through a Cloudflare Tunnel: a connector (cloudflared) runs on the box and opens the connection from the inside out. Cloudflare’s edge receives requests for the hostnames, each service’s public address, and delivers them through the tunnel to the right container. No service listens to the internet. All of them listen only on Docker’s internal network.
This post is the anatomy of that design. Not as an ad for simplicity, but as a set of decisions with known costs. Three traps that cost real incidents. And the trust boundaries that make a single box defensible.
The map: three classes of hostname (and the class with none)
On a box serving several products, the right question isn’t “which port does each service use”. It’s who can reach each one. The map has four classes:
- Genuinely public. The main product’s API, with its own authentication (API key) and an anonymous quota for the free tier. It’s the only class that accepts the world.
- Public, but behind Cloudflare Access. The admin interfaces: the automation orchestrator, the operations panel. They have hostnames, but the edge demands identity before the request ever touches the tunnel.
- Off the box. Static sites served by Cloudflare Pages and a status page served by a Worker. That is on purpose: a service’s status page cannot live inside the service (more on that shortly).
- No hostname at all. The internal services, the bot brain and the agent service, have no route in the tunnel. This is not a backlog item: it’s the design. The question “how do I reach it from outside?” is answered with “you don’t.”
The fourth class is the cheapest and the most underrated. A service with no public route doesn’t need perfect authentication, rate limiting, or a WAF (a web application firewall). The docker network is the boundary. The internal endpoints (/interno/*) are only reachable by what’s already inside the network. The security effort concentrates where there is actual exposure.
Access instead of rolling your own login
The admin interfaces have no login of ours. Authentication is Cloudflare Access: the edge demands corporate identity, and only then forwards. The important property of this arrangement deserves precise wording: removing Access doesn’t degrade the protection, it removes the protection. There is no second password layer waiting behind it. There is the explicit decision that identity at the edge is the authentication.
That’s only acceptable with two disciplines:
- The service validates the Access JWT, the signed token the edge attaches, rather than trusting the routing. The operations panel verifies the token’s signature (the team’s JWKS, RS256, audience and issuer) in ~40 lines on top of the crypto library that was already a dependency. And the user’s email comes from inside the signed JWT, never from the convenience header Access also sends. An unsigned header is decoration.
- Fail closed. Without the Access configuration variables, the panel answers 503 with instructions, never “temporarily without auth.” A panel that degrades to open is worse than a panel that doesn’t exist.
The three traps that cost incidents
1. The connector cannot live in an app’s compose project
cloudflared started life inside the compose project of one of the products, the first one that needed it. That worked until the day that product was retired. Taking the project down would take down the tunnel for the whole box, including the services that were still live.
The rule that stuck: the tunnel serves the box, so it lives in its own compose project, in its own directory, sharing a lifecycle with no app. Cross-cutting infrastructure packaged inside an app is a bomb with its timer set to that app’s sunset.
2. --remove-orphans on a multi-project box
On a box with several compose projects, docker compose down --remove-orphans in one project removes containers from other projects, the ones compose considers orphans from its own point of view. The flag that is hygiene in a single-repo setup is friendly fire on a shared box. The defense is dumb and it works: the flag is banned on the box, in writing, in the runbook.
3. The tunnel’s ingress is not in git
The configuration of which hostname points at which service lives in the Cloudflare dashboard (token mode), not in a versioned file. That has a known cost. When a service dies, someone has to remember to remove the hostname from the ingress, the tunnel’s list of routes. A hostname left pointing at an origin that no longer exists serves 502 to the world and to Googlebot, indefinitely. That failure mode produced an SEO incident with a post of its own. The operational rule that stuck: tear down the route before the container, never the other way around.
Deploys: two paths, two different guarantees
The same box receives deploys through two paths, and the difference between them is a lesson in itself:
- Path A: self-hosted runner on the box. Push to
main→ CI → the runner runs the redeploy script. It rebuilds, waits for the healthcheck (the service’s own health check), and rolls back automatically, back to the previous build, if the new one doesn’t come up healthy. Red CI blocks publication. - Path B: Cloudflare Pages. The build happens on Cloudflare, straight from git. Red CI blocks nothing: the quality gate is advisory, and rollback is manual.
Both paths are legitimate. The danger is not knowing which one you’re on. When the account’s GitHub Actions hosted-minutes quota ran out, path A stopped publishing, and was noticed the same day. Path B kept publishing with no gate at all, and nobody noticed for a week. The coupling between verification and publication, a defect in theory, works as a detector in practice.
The watcher doesn’t live on the box
A single box concentrates another risk: monitoring that lives on it dies with it. The design uses three independent loops, chosen by the question each one answers:
- From outside: an external uptime monitor on the public URLs, and the status page served by a Worker at the edge. That Worker probes, keeps history, and renders without touching the box. The previous version of the probe ran on GitHub Actions. When the quota died, the page froze and, worse, the probe was also the alarm. It couldn’t migrate to the self-hosted runner, because that runner is the box being watched. A probe living on the machine it monitors reports silence when the machine dies, and silence looks like “all good.”
- From inside: a cron guardian, running on the system’s task scheduler, checks disk, unhealthy containers, stopped containers that should be up, and a deep application healthcheck. Then it pings an external healthcheck with the result. If the box dies, the ping stops, and the absence is the alert (dead-man switch).
- From the process: application error tracking, configured for 5xx only. 401, 413, and 429 are the API working as documented. Alerting on them trains the operator to ignore the alarm.
The boundaries, named
What makes a single box defensible isn’t the box. It’s knowing exactly where the trust boundaries run:
- The docker network is a real boundary. What has no route in the tunnel is not reachable from outside, period. Internal services’ security starts there, not at perfect authentication.
- Identity at the edge for admin. Access in front, JWT validated inside, fail closed.
- No secret in git. Healthcheck URLs (which are credentials: whoever knows them can silence the alarm), tokens, and real
.envfiles live outside the repository; the repositories carry.env.examplewith the shape. - SSH exists for the backup to pull, and that’s it. No deploy goes over SSH: deploys are the runner plus a script with healthcheck and rollback. The backup machine pulls the dumps; the VPS has no credential to write anywhere outside itself.
The trade-off, said out loud
One box is a single failure domain. The day it dies, everything living on it dies. The design accepts that with open eyes, because the operating cost is one person’s. And the mitigation is not redundancy: it’s measured recoverability. Three backup layers: local dumps, provider snapshots, a copy pulled off-site. Plus a restore rehearsal with actual RTO and RPO numbers (how long until we are back, how much data we lose). And an incident table mapping symptom → probable cause. For example: “all hostnames went down at once” → the connector, not the apps; “the guardian has been silent for over an hour and a half” → the box.
For a product that needs five nines, this design is wrong. For a solo operation with real products in the air, the realistic alternative isn’t a cluster. It’s the same box with the boundaries badly drawn. The tunnel with no open port, Access instead of a homegrown login, internal services without hostnames, and the watcher on the outside cost, combined, a day of configuration. What they buy is an attack surface that fits in one sentence, and incidents whose causes can be found.
Infrastructure that scales without breaking the bank
Cloud bill out of control? I run my own on a single VPS with no open ports, automatic deploys and healthcheck-gated rollback. The whole design is published here.
Read the infrastructure posts →Related Posts
How Cloudflare Access Works: Admin Protected Without Writing a Login Page
10 min
A Self-Hosted GitHub Actions Runner with Docker: The Setup That Survives Billing
11 min
A Static Site on Cloudflare Pages: The Migration That Shut Down Containers
11 min