It started, like most of my regrets, with a bookmark folder.

app-one:3000. dashboard:8501. that-thing-i-forgot:8080. A dozen self-hosted apps, each squatting on its own port, each throwing a browser security warning because I’d long since stopped pretending to manage individual certs. Half were self-signed. Half were plain HTTP. All of them were ugly.

Every new app was the same ritual: pick a port, pray it’s free, wire up TLS by hand, give up on TLS, add another :PORT bookmark, lose it. The homelab worked. It just looked like a ransom note.

So I sat down to do it properly. Once.

The investigation

The core problem wasn’t the apps. It was that I had N apps and N TLS problems. Every service believed it was personally responsible for the open internet. None of them were good at it.

Here’s the mess, drawn honestly:

        BEFORE                              AFTER
  ┌──────────────────┐            ┌──────────────────────────┐
  │  browser         │            │  browser                 │
  └───┬───┬───┬──────┘            └──────────┬───────────────┘
      │   │   │   (which port?)              │  one name, valid TLS
      ▼   ▼   ▼                              ▼
   :3000 :8501 :8080                    ┌─────────┐
   self-signed / http / ???            │  Caddy   │  *.lab.example.tld
   each app owns its own cert          └──┬──┬──┬─┘  wildcard cert
                                          │  │  │
                                          ▼  ▼  ▼
                                127.0.0.1:3000/8501/8080
                                  (apps bound to localhost)

The fix wasn’t to make every app better at certificates. It was to make none of them do certificates. Put one thing in front. Let it own TLS, routing, and renewal. Bind everything else to 127.0.0.1 so the only thing facing the world is the proxy.

That “one thing” is a reverse proxy. Mine’s Caddy.

The “aha”

The trick that makes it sing is a wildcard certificate*.lab.example.tld, one cert that’s valid for every subdomain I’ll ever invent — obtained over a DNS-01 challenge instead of the usual HTTP-01.

DNS-01 matters here. HTTP-01 proves you own a name by serving a file on it, which means each name needs to be reachable. DNS-01 proves it by dropping a TXT record at your DNS provider, which means you can mint a wildcard without exposing a single app first. One challenge, infinite names.

Once that clicked, the whole bookmark folder collapsed into one file.

The fix

Caddy needs a DNS-provider plugin to answer the DNS-01 challenge, so build it with the module for your provider:

xcaddy build \
  --with github.com/caddy-dns/cloudflare

# verify the module is in there
./caddy list-modules | grep dns.providers

Then the global block tells Caddy how to get the wildcard, and each app is — genuinely — three lines:

# Caddyfile
{
    email admin@example.tld
    # credentials for the DNS-01 challenge
    acme_dns cloudflare {env.CF_API_TOKEN}
}

# the wildcard, requested once, renewed forever
*.lab.example.tld {
    tls {
        dns cloudflare {env.CF_API_TOKEN}
    }
}

app-one.lab.example.tld {
    reverse_proxy 127.0.0.1:3000
}

dashboard.lab.example.tld {
    reverse_proxy 127.0.0.1:8501
}

Reload without dropping connections:

caddy validate --config /etc/caddy/Caddyfile
caddy reload  --config /etc/caddy/Caddyfile

Confirm a fresh subdomain actually got real HTTPS:

curl -sI https://dashboard.lab.example.tld | head -n1
# HTTP/2 200
echo | openssl s_client -connect dashboard.lab.example.tld:443 2>/dev/null \
  | openssl x509 -noout -subject
# subject=CN = *.lab.example.tld

Adding the next app is now: pick a port, add a three-line block, caddy reload. Valid cert, no thought required.

browserCaddy*.lab certHTTPS:3000:8501:8080127.0.0.1 — localhost only
One proxy faces the world; the apps stay on localhost and never touch TLS.

Why it happened

The old setup wasn’t dumb, it was just additive. Each app got stood up in isolation, made its own peace with HTTPS, and got a port-shaped bookmark. Nobody ever sat down to factor out the part every app shares: a public face and a certificate.

A reverse proxy is exactly that factoring. TLS, hostname routing, and renewal are cross-cutting concerns — solve them once, in front, and every app inherits the solution for free. The wildcard cert is what makes “inherits for free” literally true: no per-app issuance, no per-app renewal, no per-app anything.

Takeaways

  • One TLS problem beats N TLS problems. Put a reverse proxy in front and let it own certs, routing, and renewal so your apps never have to.
  • Wildcard + DNS-01 is the unlock. *.domain minted via a DNS TXT challenge covers every subdomain you’ll ever invent — and you don’t have to expose an app to prove ownership.
  • Bind apps to 127.0.0.1. If only the proxy faces the world, a misconfigured app can’t accidentally serve itself raw to the internet.
  • New app = three lines. A subdomain block that reverse_proxys to a localhost port, a reload, done. Exposing a service stops being a project.
  • Build the right Caddy. DNS-01 needs the provider plugin compiled in — xcaddy build --with, then list-modules to confirm before you wonder why the challenge hangs.