Post 1 made the promise: exactly one machine faces the internet, and it’s the one we care about least. This post builds that machine. In this lab it’s called url, after Futurama’s robot cop. Officer URL works the door, checks IDs, and owns nothing worth stealing.
url runs three containers: Traefik, an SSO outpost (post 8), and Crowdsec. No databases, no user data, no credentials beyond the scoped DNS token from post 2. Anyone who’s run public servers for long has a short list of boxes that got popped on their watch; the goal here isn’t pretending you’ll never be on that list again, it’s arranging that the internet-facing mistake budget lands on a machine whose complete recovery is “rebuild from the repo, ten minutes.”
Routes live in a file, and the file is the point
Internal proxies route by container label because their containers are local. The edge proxies to other machines, so its routes live in the file provider. That file quietly becomes one of the most useful artifacts in the repo: the complete, reviewable inventory of what the internet can reach.
# traefik/dynamic/public-routes.yml (the shape)
http:
routers:
requests-public:
rule: Host(`requests.example.tv`)
service: requests-backend
middlewares: [secure-headers, crowdsec]
tls: {certResolver: dns}
vault-public:
rule: Host(`vault.example.dev`)
service: vault-backend
middlewares: [secure-headers, crowdsec]
tls: {certResolver: dns}
services:
requests-backend:
loadBalancer:
servers:
- url: http://10.99.0.20:5055 # bender, over the overlay
vault-backend:
loadBalancer:
servers:
- url: http://10.99.0.10:8090 # hermes
“What’s exposed right now?” used to be archaeology across three servers. Now it’s one file with a git history. Every public service appears here AND keeps its internal-label route on its home box (post 3’s rule): the edge route is how the world arrives, the internal route is how you reach it when the WAN is down and you’re debugging why.
Auth at the door
Services with real logins of their own (the vault, the request portal) route straight through. Everything else gets the SSO forward-auth middleware at the edge, so strangers are challenged on the VPS before a packet crosses into either site. One middleware reference on the router, same as the internal pattern. It converts “I accidentally exposed an admin panel” from a disclosure into a login page.
The TLS error whose message will not help you
Sometimes the edge proxies to a backend Traefik instead of straight to an app, useful when a host should own its own routing for a service. HTTPS to HTTPS. The first time you wire it:
500 Internal Server Error
... remote error: tls: unrecognized name
Here’s what’s happening. The edge dials the backend by IP
(https://10.99.0.10), so the TLS handshake carries no useful SNI.
The backend proxy picks certificates BY SNI; given nothing, it
refuses the handshake or serves a default cert that doesn’t match.
The router rule never even runs. This dies during the handshake,
which is why staring at routing config gets you nowhere.
The fix: tell the dialer what name to present.
serversTransports:
hermes-tls:
serverName: vault.hermes.zt.example.dev
services:
vault-backend:
loadBalancer:
serversTransport: hermes-tls
servers:
- url: https://10.99.0.10
And the companion discipline: when a backend route skips auth because
“only the edge can reach it,” make that true with config, not vibes.
An ipAllowList middleware on the backend router, pinned to url’s
overlay IP. Topology is a fact about today; an allow-list is a
guarantee about tomorrow.
Crowdsec: the bouncer
Within hours of your DNS records existing, the scanners arrive:
/wp-login.php, /.env, /.git/config (rude), exploit probes for
appliances you’ve never owned. Background radiation. Mostly harmless,
entirely tireless.
Crowdsec tails the edge’s access logs, matches them against community-maintained attack scenarios, and issues decisions; a bouncer plugin in Traefik enforces them, refusing decisioned IPs before they reach any router. Setup is the agent, the bouncer, and an API key between them, about half an hour. The community blocklist means known-bad IPs get bounced on their first request to your server, paid for by their behavior on someone else’s.
It runs on url only. Internal proxies see traffic that already passed the edge or originated inside the overlay; a bouncer there is moving parts with nothing to bounce.
Check it’s working after a day:
docker exec crowdsec cscli decisions list | head
If that list isn’t filling with strangers probing for WordPress, your DNS records haven’t propagated yet.
Not everything is HTTP
The edge also forwards raw TCP. In this lab that’s mail, via Traefik’s TCP routers with SNI passthrough:
tcp:
routers:
imaps:
rule: HostSNI(`mail.example.dev`)
entryPoints: [imaps]
service: imaps-backend
tls: {passthrough: true} # the mail server does its own TLS
services:
imaps-backend:
loadBalancer:
servers:
- address: "10.99.0.10:993"
passthrough: true is the important line: the edge reads the SNI to
route and forwards the still-encrypted stream. The mail server
terminates its own TLS, and url never holds mail plaintext or mail
keys. Post 14 leans on this hard.
Milestone: the internet now reaches exactly one machine, every exposed service is listed in one reviewable file, and the scanners are somebody else’s log line.
Next: one login for everything.