Skip to content

field_note · jul 19

One box, zero open ports, no Elastic IP: deploying a production service behind a Cloudflare Tunnel

A real single-box deployment where the firewall blocks all inbound, there's no public IP, and no Elastic IP on the bill. How Cloudflare Tunnel becomes the only ingress, how the containers are deployed and networked, and what it saves in operations and cost.

Here's a production service running on a single cloud instance with a security group that blocks every inbound port — no open web port, no open SSH, and no public IP address at all. Traffic still reaches it, deploys still ship, and the Elastic IP line-item is gone. The trick is making a Cloudflare Tunnel the only way in. This is how that's wired, and what it saves operationally.

The default AWS shape, and why it's more than you need

The reflex deployment for a small service is: an instance with a public IP, a security group opening 443 (and 22 "just for now"), usually an Elastic IP so the address is stable, and often a load balancer in front for TLS. Every one of those is an attack surface, a config knob, or a bill:

  • The open ports are the internet's to probe 24/7.
  • The public IPv4 address now costs money even when attached — AWS charges $0.005/hr ($3.60/mo) for every public IPv4, Elastic IP included, since Feb 2024.
  • The load balancer is another ~$16+/mo and another thing to configure for TLS.

For a low-traffic internal service, that's a lot of surface and spend for "make a box reachable."

Cloudflare Tunnel inverts the direction

A tunnel flips ingress from inbound to outbound. A small cloudflared connector runs on the box and dials out to Cloudflare's edge, holding a persistent connection open. Requests hit Cloudflare, ride back down that already-open pipe, and land on a local port. Nothing ever connects to the box.

The consequences stack up fast:

  • The security group blocks all inbound. There is nothing to port-scan. The box is not on the public internet in any reachable sense.
  • No public IP, so no Elastic IP. The connection is outbound-initiated, so the instance needs no public address to be reachable — that's the ~$3.60/mo EIP charge gone, and one less resource in Terraform.
  • The IP can change freely. Rebuild or resize the instance and the new box just re-dials the tunnel. No DNS to update, no EIP to re-associate — the operational reason people reach for an EIP in the first place disappears.
  • TLS terminates at Cloudflare's edge. No cert to provision or renew on the box, and no load balancer to run it. The internal proxy speaks plain HTTP because it's already behind the tunnel.

(If you're wiring the tunnel token in Terraform, the schema is fiddly — I wrote up the v5 gotcha in cloudflared tunnel token in Terraform v5.)

How the box is deployed

Everything runs as containers on the one host, deployed with a simple container-deploy tool over SSH — but the SSH itself goes through the cloud provider's session manager, not an open port 22. Same principle as the tunnel: no inbound SSH exposed, ever.

terminal
CDN edge  ──▶  tunnel  ──▶  cloudflared (on box)  ──▶  proxy :80  ──▶  app
                                                                        │
   deploy ──▶ session-manager ──▶ container-deploy over SSH (no open 22)

On the box:

  • a proxy that fronts the app and does zero-downtime container swaps,
  • the app container(s),
  • background workers,
  • a database as a colocated container on a persistent volume,
  • the cloudflared connector.

A health-check path gates each deploy: the proxy only cuts traffic to a new container once it reports healthy, and briefly runs old and new side by side — which is the one moment the box actually needs its RAM headroom.

How the containers talk

No service mesh, no overlay network. The database and the accessory services each bind to the container host's internal bridge address on their own port, and the app reaches them through the host-gateway alias. Because inbound is firewalled, those ports are private by construction — the network boundary is the security group, and the tunnel is the only door through it.

Internal hops still authenticate (shared secrets / bearer tokens between services), so "it's on a private bridge" is never the only thing protecting a call.

What it actually saves

  • Cost: no Elastic IP ($3.60/mo), no public IPv4 charge, no load balancer ($16+/mo). The whole box is one small instance around $30/mo.
  • Attack surface: zero inbound ports. No SSH to brute-force, no web port to scan, no admin panel exposed. Cloudflare's edge absorbs the internet-facing side.
  • Operational toil: no certificate lifecycle on the box, no DNS churn on rebuild, no EIP to associate, no LB target groups to manage.

The honest bottleneck on a box like this isn't traffic or CPU — it's the disk slowly filling with old container image layers. Prune them on a schedule and this runs untended for months.

Where the pattern stops fitting

One tunnel connector on one box is a single point of failure — fine for an internal or low-stakes service, not for something with an uptime SLA. When you need real availability you run redundant connectors across multiple hosts, split the database onto its own managed instance, and let the proxy fan out. None of that undoes the core win: outbound-initiated ingress means you can keep zero open ports and no public IP no matter how many boxes you add.

The takeaway: a Cloudflare Tunnel isn't just a convenience for exposing a localhost demo — it's a legitimate production ingress that lets a small service run with no public IP, no Elastic IP, and nothing for the internet to knock on.

Working through something like this? I help teams ship AI and cloud systems that hold up, and cost what they should.