Reverse Proxy with Wildcard HTTPS on Internal Names

Give every self-hosted service a real, browser-trusted HTTPS URL like jellyfin.home.example.com, using a wildcard certificate and split-horizon DNS -- no port 80 exposed, no browser warnings.

What you'll build and why

Right now, each self-hosted service you run probably lives at its own IP and port -- 192.168.1.50:8096 for Jellyfin, 192.168.1.51:8123 for Home Assistant, and so on. This guide gives each one a real name instead -- jellyfin.home.example.com -- with a genuine, browser-trusted HTTPS certificate, using a domain you actually own plus a wildcard certificate that covers every subdomain under it at once.

Don't build this if: you're running one service and remembering one port number is genuinely fine for your use case -- this guide's value scales with how many services you're juggling, and for just one or two, it may not be worth the setup.

How it works

  You own: example.com (a real, registered domain)
You request a wildcard cert for: *.home.example.com
via Let's Encrypt's DNS-01 challenge -- this only needs
your DNS provider's API to prove you control the domain,
no port 80/443 needs to be reachable from the internet
at any point.
Split-horizon DNS: *.home.example.com resolves
differently depending on who's asking --
- from the internet: doesn't resolve at all (or
resolves to nothing useful) -- these names were
never meant to be reached from outside
- from inside your network (via Pi-hole/your
router's DNS): resolves to your reverse proxy's
internal IP
Client (inside your network)
│  https://jellyfin.home.example.com
▼
Split-horizon DNS -- resolves to the reverse proxy's LAN IP
▼
Caddy (reverse proxy) -- terminates the real, valid
│                  wildcard cert, routes by hostname
▼
jellyfin:8096 (the actual service, still on its own port,
now reached by name instead)

This is different from a self-signed certificate: because you completed a real ACME DNS-01 challenge against a domain you actually own, the certificate is signed by Let's Encrypt and trusted by browsers without warnings -- even though the name only ever resolves inside your own network.

Before you start

Decision: which DNS provider, and does it have a Caddy DNS plugin? DNS-01 challenge automation needs API access to your domain's DNS provider -- Caddy supports this through provider-specific plugins (community-maintained; Cloudflare, Route53, DigitalOcean, and many others have one). Check whether your domain's DNS provider has a Caddy plugin before starting; if not, you'd need to either move DNS for this subdomain to a provider that does, or use a different ACME client with broader provider support.

You need a real domain you control DNS for. A subdomain of a domain you already own (e.g. home.example.com under an example.com you own) works fine -- you don't need a dedicated new domain.

Steps

Step 1: Get a Caddy build with your DNS provider's plugin

Official Caddy Docker images don't include every DNS provider plugin by default. Use Caddy's build tool to get a build with your specific provider's plugin, or use a community Docker image that already bundles common ones.

Step 2: Configure your DNS provider's API credentials

Generate an API token from your DNS provider scoped to DNS editing for your domain (not broader account access than needed). This is what proves domain ownership during the DNS-01 challenge -- keep it as a secret, not committed anywhere.

Step 3: Write the Caddyfile

*.home.example.com {
tls {
dns <your-provider-plugin-name> {env.DNS_API_TOKEN}
}
@jellyfin host jellyfin.home.example.com
handle @jellyfin {
reverse_proxy jellyfin:8096
}
@homeassistant host homeassistant.home.example.com
handle @homeassistant {
reverse_proxy homeassistant:8123
}
}

Replace <your-provider-plugin-name> with your actual plugin's directive name (varies by provider -- check its specific documentation), and substitute your own domain and services.

Step 4: Run Caddy with the API token as a secret

services:
caddy:
image: your-custom-caddy-build
container_name: caddy
ports:
- "443:443"
volumes:
- "./Caddyfile:/etc/caddy/Caddyfile"
- "caddy_data:/data"
environment:
DNS_API_TOKEN: "<YOUR-DNS-API-TOKEN>"
networks:
- homelab
volumes:
caddy_data:
networks:
homelab:
external: true

Put every service Caddy proxies to on the same Docker network (homelab here) so Caddy can reach them by container name.

$ docker compose up -d

Expected output: Caddy's logs show it successfully completing the DNS-01 challenge and obtaining the wildcard certificate on first start.

Step 5: Set up split-horizon DNS

On your internal DNS resolver (Pi-hole's Local DNS settings, or Unbound if you built the recursive DNS guide): add an entry pointing *.home.example.com (or each specific subdomain) to Caddy's LAN IP. This is what makes the name resolve internally without ever needing a public DNS record pointing at your home network.

Verify it works

  • https://jellyfin.home.example.com (from inside your network) loads with a valid certificate -- no browser warning
  • Checking the certificate details in the browser confirms it's issued by Let's Encrypt, not self-signed
  • From outside your network (mobile data), the same hostname does not resolve to anything useful -- confirms split-horizon DNS is actually scoping this internally, not accidentally public
  • Failure test: stop Caddy (docker compose down). Confirm the hostname now fails to load (expected -- there's no fallback) rather than silently falling back to the raw IP:port, confirming the proxy is actually in the path, not bypassed

Secure it

  • What's exposed: nothing new to the internet -- DNS-01 doesn't need port 80/443 open, and split-horizon DNS means these names don't resolve externally. The API token for your DNS provider is the most sensitive credential in this setup -- treat it accordingly (Secure it's next point).
  • The DNS API token: scope it as narrowly as your provider allows (DNS-edit for the specific zone, not full account access), and keep it out of the Caddyfile itself and out of version control -- an environment variable or secret, as shown in Step 4.
  • Certificate renewal: Caddy renews automatically well before expiry -- no manual intervention needed, but worth confirming it's actually happening (check Caddy's logs periodically) rather than assuming silently.
  • The failure that hurts most: a leaked DNS API token, which could be used to modify your domain's DNS beyond just this subdomain if scoped too broadly -- this is the real reason to scope it narrowly (previous point), not just general good practice.

Back it up and maintain it

What matters: the Caddyfile itself (small, worth versioning in git, excluding the token) and Caddy's data volume (holds the obtained certificates -- losing it means Caddy re-requests them, which works but isn't instant).

Update cadence: update Caddy's image on your own schedule; certificate renewal is automatic and separate from the Caddy version itself.

Troubleshooting

Logs: docker logs caddy.

Symptom Likely cause Diagnostic Fix
Caddy fails to obtain the certificate DNS API token invalid/wrong scope, or the plugin name in the Caddyfile doesn't match your actual DNS provider directive docker logs caddy for the specific ACME error Fix the token or the Caddyfile's provider directive name against your plugin's own documentation
Certificate obtained, but the hostname doesn't resolve inside your network Split-horizon DNS entry (Step 5) missing or pointing at the wrong IP Check your Pi-hole/Unbound Local DNS entries Add or correct the entry
Hostname resolves and loads, but shows the wrong service Caddyfile's @matcher blocks routing incorrectly, or two services accidentally matched by the same host rule Review the Caddyfile's host matchers for overlaps Fix the specific matcher for that hostname
Works from one device, not another, both on the same network The failing device has its own hardcoded DNS server (bypassing your internal resolver and its split-horizon entry) Check the device's own network/DNS settings Point it at your internal DNS resolver instead of a hardcoded external one
Certificate renewal seems to have silently stopped working Caddy lost access to the DNS API (token expired/revoked) or the data volume was lost, forcing re-requests that are then failing for the same reason as initial setup docker logs caddy around the expected renewal window Fix the DNS API access the same way as initial setup troubleshooting

Undo

docker compose down removes Caddy; delete the Caddyfile and data volume to remove all state. Remove the split-horizon DNS entries from Pi-hole/Unbound. Services return to being reachable only by their raw IP:port.

Go further

  • Caddy vs. Traefik vs. Nginx Proxy Manager -- if you're deciding whether Caddy specifically was the right choice for your setup
  • Add authentication in front of specific services directly in Caddy (forward_auth) once you've built the SSO guide, rather than relying on each service's own login
  • Automate adding new services to the Caddyfile as part of your Docker Compose homelab stack's own directory convention

Resources

Official documentation:

Source and releases:

Community:

Related DaemonPress projects:


Last verified: 2026-09-21, checked against official Caddy documentation.