{"slug": "nineteen-subdomains-one-allowed-ip", "title": "Nineteen Subdomains, One Allowed IP", "summary": "A developer built a self-hosted infrastructure using a wildcard DNS record and Nginx Proxy Manager to publish nineteen services, but the ease of publishing created a security problem: all services are reachable from the internet, and port 80 must remain open for certificate renewals. The developer describes a solution using a shared Docker network to restrict access to a private network, but notes the risk of silent failures if port 80 is closed.", "body_md": "One DNS record covers the whole box: `*.example.com`\n\n→ `203.0.113.10`\n\n. I set it once, and publishing a service has not involved DNS since. I pick a name, and the name already resolves.\n\nWhat's left is a form in Nginx Proxy Manager (NPM) with four fields that matter: the domain, the container name, the container port, and a checkbox that requests a certificate. That is the whole act of publishing. Fill it in, save, and the hostname is serving HTTPS from a container that was never exposed to anything.\n\nOnce publishing is a form, it is mechanisable. I stopped filling it in by hand a while ago — I describe the service, Claude Code takes it end to end (compose file, network, proxy host, certificate), and hands me back the URL.\n\nThat is how I ended up with nineteen proxy hosts. Nineteen public hostnames, all of them off-the-shelf self-hosted software, each with its own idea of what authentication means: some have real user accounts, some a single shared password, some a setup wizard that never asked, some nothing at all.\n\nThe ease is the problem. Nothing in that form makes you decide who is allowed to reach the thing you just published.\n\nWhat I actually wanted wasn't to keep any of it off the internet — that stopped being an option the moment the wildcard resolved. I wanted all nineteen of them reachable from exactly one place: a private network, built entirely out of public infrastructure, where being on the internet and being reachable are two different facts.\n\nThe wildcard is a DNS record, not a certificate. Those two get conflated constantly, and the difference decides what the firewall looks like permanently.\n\nThe record is real work removed. `*.example.com`\n\npoints at the VPS, so every hostname I will ever invent already resolves; there is no propagation wait between deciding on a name and using it.\n\nThe certificates are not wildcard. NPM issues them per hostname over HTTP-01, so for each of those nineteen names Let's Encrypt makes an inbound request to `http://name.example.com/.well-known/acme-challenge/...`\n\nand expects nginx to answer on port 80. Nineteen certificates, nineteen challenges — and since they last about ninety days, the renewal timer re-runs the same challenge against every one of them, forever.\n\nSo port 80 can never close. Not \"should probably stay open\" — cannot close, on either of the two firewall layers this host has. And the failure mode is silent: closing 80 breaks nothing you can observe. Sites keep serving on 443 with the certificates they already have, renewals fail into a log nobody reads, and the outage lands ninety days later as every hostname going untrusted at once.\n\nDNS-01 would buy a genuine wildcard certificate and let 80 close, at the price of giving the proxy write credentials to my DNS zone. I didn't.\n\nThe network gets created once, by hand — `docker network create proxy`\n\n— and every compose file after that declares it as something it did not make:\n\n```\nservices:\n  trilium:\n    networks: [proxy]      # this is what attaches the container\nnetworks:\n  proxy:\n    external: true         # this only says where the network comes from\n```\n\nThose two blocks look like one setting written twice. They aren't. Leave out the one under the service and the file is still valid, the network still resolves, and nothing is on it.\n\nWith both in place, the proxy host form takes a container name: forward hostname `trilium`\n\n, forward port `8080`\n\n. Docker's embedded DNS resolves the name across the shared network, and `8080`\n\nis reachable inside that network and nowhere else. None of the containers nginx proxies to publishes a port to the host at all.\n\nThat is the part that is easy to half-do. Joining the shared network does not reclaim a published host port. They are two separate acts, and doing only the first adds a road without removing one.\n\nnginx serves its own admin panel the same way, by proxying `127.0.0.1:81`\n\n, so 81 is never published either — the entrance shouldn't have a second door. You will be told this can't work — that inside the container `127.0.0.1`\n\nis the container, so it loops and 502s. The premise is right; the conclusion isn't. One nginx process serves every virtual host, and handing a request from the 443 listener to the 81 listener is a hop, not recursion.\n\n```\ndocker exec npm curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:81/\n# 200\n```\n\nReal loops come from forwarding a host to its own hostname, or 443 to 443.\n\nOne more container sits on that network: a self-hosted proxy gateway, on the same VPS as everything else.\n\nOnly one thing about it matters here, and it's where its traffic comes out. Point a device at the gateway and that device's requests leave the VPS through the VPS's public interface and come back to `:443`\n\non the same box. To nginx there is nothing exotic about them — ordinary inbound HTTPS off the public internet — and the client address it writes to the log is `203.0.113.10`\n\n. The VPS's own.\n\nI'd seen that address in that column before, and I'd been wrong about it. I was chasing intermittent connection drops at the time, and the failing requests logged a client IP identical to the server's public address. It looked like a routing fault: something on the box talking to itself, traffic eating its own tail. I filed it as a loop to untangle later.\n\nIt wasn't a fault. It was the gateway, doing what a gateway does. The log had been telling me the truth the whole time, in a shape I didn't recognise.\n\nThe loop I tried to route around is what the whole design stands on.\n\nEvery hostname that gets gated gets the same block, written by NPM into that proxy host's config file:\n\n```\nlocation / {\n  auth_basic            \"Authorization required\";\n  auth_basic_user_file  /data/access/2;\n\n  allow 172.19.0.2/32;      # the gateway container, on the shared docker network\n  allow 203.0.113.10;       # the VPS's own public address\n  deny all;\n\n  satisfy all;\n}\n```\n\nThe two `allow`\n\nlines are an OR. A request passes the address check by matching either one; everything else falls to `deny all`\n\nand gets a 403 before nginx opens a connection to the upstream at all.\n\nThey both have to be there because the same request can arrive from either side of the box. A container on the shared network connects to nginx across that network, and the client address nginx records is that container's Docker address. Anything not on that network comes in through the host's public interface, and nginx records the VPS's public address — including the gateway's, which left the box and came back. Two paths in, two addresses in the log, one rule that has to cover both. Neither line is a spare.\n\n`satisfy all`\n\nis not part of that. It's a different axis: it makes the address check *and* basic auth both mandatory. (`satisfy any`\n\n— the one people pick by accident — would make either alone sufficient.) So an allowed address buys you a password prompt, not the service.\n\nThat's the design, stated in one line: nineteen public hostnames, all of them resolving, all of them presenting a valid certificate to anyone who connects — and none of that decides who gets in. The two `allow`\n\nlines and the password after them do. A public DNS record and a public certificate get a stranger exactly as far as a login prompt they can't clear. That's what the wildcard actually bought me: not privacy for the DNS, not secrecy for the addresses, but a private network assembled entirely out of public infrastructure — an intranet where every door is a public IP with one line of nginx behind it.\n\nOne path escapes all of it. The rule is scoped to `location /`\n\n, and NPM includes a separate location that opts out on purpose:\n\n```\nlocation ^~ /.well-known/acme-challenge/ {\n    auth_basic off;\n    auth_request off;\n    allow all;\n    # ...\n}\n```\n\nLet's Encrypt publishes no IP ranges for its validation infrastructure, so there is nothing to allow-list — the challenge path stays open to the entire internet or the renewals stop. Same fact as port 80, wearing different clothes: a deliberate, documented hole that is the correct call.\n\nSeventeen of the nineteen proxy hosts carry that block. The two that don't are the gateway's own admin panel and the endpoint that hands out client configs.\n\nThey can't carry it. The rule assumes the gateway is already carrying you — that's what puts an allowed address in the log. Reaching the panel means getting in before that's true, and setting up a new device means fetching its config from a device that isn't configured yet. You can't put the key inside the lock.\n\nSo those two sit on their own authentication and nothing else: a login form on the public internet, with whatever password I gave it and whatever rate-limiting the software ships with. Every other hostname on the box is behind two independent checks. These two are behind one, and I'd rather name that than pretend the wildcard covers it.\n\nPutting everything behind one nginx broke a service that had run untouched for a year. Not the gateway, and not either of the ungated two — this one carries the full block now; it didn't then, and this is why. It issues each user a connection profile with a server address baked into it.\n\nIt failed in stages: one person, then two, then everyone who asked.\n\nEverything the box could tell me was green: container up, credentials matching, nothing banned, a clean log. Curling the service's hostname from the server returned 200.\n\nSo I formed a theory, and it was a good one: a firewall rule had been deleted. I'd spent that afternoon in the cloud console editing security-list rules. Healthy inside, dead outside is the shape of a packet filter dropping traffic before it lands.\n\nInstead of reasoning further, I asked someone who was failing to send me what their client actually said.\n\n`connect: connection refused`\n\n, against `198.51.100.20`\n\n. Not my server. That killed the theory twice over — a filter that drops packets gives you a timeout, not a refusal, and the client wasn't trying to reach my server at all. A reverse lookup landed on my own ISP's egress: my home connection, that morning.\n\nnginx was doing its job: it sets `X-Forwarded-For`\n\nto the real client address. The service downstream read that header as *its own* public address — the one it writes into every config. So the address it advertised was whoever had asked last. My laptop had pulled a config from home that morning; after that, the service told everyone to connect to my house. The field that pins the advertised address was right there in the settings, empty.\n\nThe neighbouring mistake is easy: the port it listens on inside its container and the address it advertises outside sit next to each other in one form, and the first instinct is to change the wrong one. That code path had been unreachable since install — clients connected straight to the box, no header to misread.\n\nAnd it hid for days because of where I stood to test. My own client goes out through the gateway, so every config I fetched for myself came back carrying `203.0.113.10`\n\n— exactly what it was supposed to carry. It read correct every time. The address that makes the access rule work is the address that hid this bug: one fact doing both jobs, read from the wrong side twice.\n\nThe certificate lives on nginx and nowhere else. TLS terminates there; from nginx to the container the request travels as plaintext HTTP across the Docker network. That is why the Scheme field on every proxy host reads `http`\n\n, and why setting it to `https`\n\nreturns a 502 immediately instead of hanging — nothing on the backend port is listening with TLS.\n\nFine in itself, since that network is published to nothing. But it decides where the access check has to live. Past nginx the client is gone: the container sees a connection from a Docker address on its own network, and everything else it knows about the caller arrives as a header. nginx is the last point that still has the client's address from the socket rather than from a string. The rule goes there, or it goes nowhere.\n\nTwo allowed addresses is not zero-trust. Anything that can source traffic from either address is inside. Everyone else who uses the gateway arrives as `203.0.113.10`\n\n, same as me; so does every process on the box. The rule doesn't identify a caller — it says the request came in by an expected road.\n\nBehind it is basic auth, which is a password. One shared string, no second factor, nothing rate-limiting guesses. Two checks beat one because they fail independently, not because either is strong.\n\nThe services still trust nginx's headers without question — the same unconditional trust that spent days advertising my house. The rule doesn't fix that. It shrinks the audience for it.\n\nPort 80 stays open permanently, and the challenge path under it stays open to everyone.\n\nIf the gateway dies, so does my access to seventeen hostnames. SSH is the way back — which is why the rule is nginx answering 403 rather than a firewall dropping packets. A 403 lives in a config file on the host; the door I need during an outage can't be the one I just shut.\n\nNineteen hostnames gated by one line, and what holds it up is an accident of topology: traffic that leaves this box and comes back is indistinguishable from traffic that was never here. I get to allow-list a single address because the machine vouches for me to itself.\n\nThe cost is that I debug from the position the rule rewards. Whatever I check, I check while already holding the credential, so it checks out. It took someone else's error message to show me a view I couldn't produce.\n\nThe cheapest access control on this box and the bug that hid longest on it are the same string in the same column of the same log. I'd take the trade again. I'd just stop testing from inside the allow-list.", "url": "https://wpnews.pro/news/nineteen-subdomains-one-allowed-ip", "canonical_source": "https://dev.to/jeromefromhk/nineteen-subdomains-one-allowed-ip-3fk5", "published_at": "2026-08-03 23:09:11+00:00", "updated_at": "2026-08-03 23:39:56.581579+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents"], "entities": ["Nginx Proxy Manager", "Claude Code", "Let's Encrypt", "Docker"], "alternates": {"html": "https://wpnews.pro/news/nineteen-subdomains-one-allowed-ip", "markdown": "https://wpnews.pro/news/nineteen-subdomains-one-allowed-ip.md", "text": "https://wpnews.pro/news/nineteen-subdomains-one-allowed-ip.txt", "jsonld": "https://wpnews.pro/news/nineteen-subdomains-one-allowed-ip.jsonld"}}