cheat-sheets

Reverse Proxy Patterns

How to put a reverse proxy in front of homelab services, with the same task shown in Traefik, Nginx and Caddy: routing by hostname, a wildcard certificate through the ACME DNS-01 challenge, split-horizon DNS and internal-only services. Tool-specific detail lives in Traefik, Nginx and Cert-Manager; this sheet is about choosing and combining them.

Documentation: Traefik ACME · Caddy automatic HTTPS · certbot DNS plugins · Let’s Encrypt challenge types

Verification: snippets were written from the upstream documentation and not executed. Check image tags, provider names and DNS-plugin options against the current docs before use.

See also: Homelab · Pi-hole · AdGuard Home · Unbound · VLANs · Tailscale · WireGuard · Docker-Compose · Docker Networking · TLS (Transport Layer Security) · DNS Record Types


Traefik vs Nginx vs Caddy #

TraefikNginxCaddy
Config modelDynamic: Docker labels, file provider, KubernetesStatic files, reload on changeCaddyfile or JSON, reload on change
Docker auto-discoveryBuilt in (labels)No (use nginx-proxy or templates)Via caddy-docker-proxy plugin
Automatic HTTPSYes (ACME resolvers)No (use certbot or acme.sh)Yes, on by default
DNS-01 / wildcardBuilt in, many providers via legocertbot DNS plugins or acme.shNeeds a build with a DNS provider module
DashboardYesNo (Nginx Proxy Manager adds a UI)No
Best forMany containers that come and goStatic sites, caching, fine control, non-HTTP via streamSimplest config for a handful of services
GotchaLabels get long; Docker socket access is sensitiveManual certificate renewal wiringDNS provider needs xcaddy or the downloaded custom build

Pick one edge proxy per host or per VLAN, and keep services on a private Docker network so only the proxy publishes ports 80/443 (Docker Networking).


Wildcard Certificates via DNS-01 #

HTTP-01 needs port 80 reachable from the internet. DNS-01 proves control by creating a _acme-challenge TXT record through your DNS provider’s API, so it works for internal-only hostnames and for wildcards (*.home.example.com). Use a scoped API token (edit DNS for that zone only), kept in an env file or Docker secret, never in the compose file.

Traefik #

traefik.yml (static):

yaml
entryPoints:
  web:
    address: ":80"
    http:
      redirections:
        entryPoint: { to: websecure, scheme: https }
  websecure:
    address: ":443"
    http:
      tls:
        certResolver: le
        domains:
          - main: "home.example.com"
            sans: ["*.home.example.com"]
certificatesResolvers:
  le:
    acme:
      email: admin@example.com
      storage: /letsencrypt/acme.json
      dnsChallenge:
        provider: cloudflare
        resolvers: ["1.1.1.1:53", "1.0.0.1:53"]
providers:
  docker:
    exposedByDefault: false

Compose side:

yaml
services:
  traefik:
    image: traefik:v3
    restart: unless-stopped
    ports: ["80:80", "443:443"]
    env_file: .env            # CF_DNS_API_TOKEN=<TOKEN>
    volumes:
      - ./traefik.yml:/etc/traefik/traefik.yml:ro
      - ./letsencrypt:/letsencrypt
      - /var/run/docker.sock:/var/run/docker.sock:ro
    networks: [proxy]
  whoami:
    image: traefik/whoami
    labels:
      - traefik.enable=true
      - traefik.http.routers.whoami.rule=Host(`whoami.home.example.com`)
      - traefik.http.routers.whoami.entrypoints=websecure
    networks: [proxy]
networks:
  proxy:
    name: proxy

chmod 600 letsencrypt/acme.json if you create it yourself. Provider names and env variables are listed in the lego DNS provider docs.

Caddy #

The stock binary has no DNS providers. Build one with the module, or use a prebuilt image that includes it:

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

Caddyfile:

*.home.example.com {
    tls {
        dns cloudflare {env.CF_API_TOKEN}
    }

    @grafana host grafana.home.example.com
    handle @grafana {
        reverse_proxy grafana:3000
    }

    @whoami host whoami.home.example.com
    handle @whoami {
        reverse_proxy whoami:80
    }

    handle {
        abort
    }
}
bash
caddy validate --config /etc/caddy/Caddyfile
caddy reload --config /etc/caddy/Caddyfile

Nginx with certbot #

bash
sudo apt install certbot python3-certbot-dns-cloudflare
printf 'dns_cloudflare_api_token = <TOKEN>\n' | sudo tee /etc/letsencrypt/cloudflare.ini >/dev/null
sudo chmod 600 /etc/letsencrypt/cloudflare.ini
sudo certbot certonly --dns-cloudflare \
  --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
  -d home.example.com -d '*.home.example.com'
nginx
server {
    listen 443 ssl;
    http2 on;                         # nginx 1.25.1+; older: "listen 443 ssl http2;"
    server_name grafana.home.example.com;

    ssl_certificate     /etc/letsencrypt/live/home.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/home.example.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;       # WebSockets
        proxy_set_header Connection $connection_upgrade;
    }
}

$connection_upgrade needs a map in the http {} block:

nginx
map $http_upgrade $connection_upgrade { default upgrade; '' close; }

Renewals: certbot renew --dry-run, and a deploy hook --deploy-hook "systemctl reload nginx".

Staging first #

Let’s Encrypt rate-limits production. Test with the staging CA (https://acme-staging-v02.api.letsencrypt.org/directory: Traefik caServer, certbot --staging, Caddy acme_ca global option), then switch back.


Split-Horizon DNS #

Use the same hostname inside and outside the house: internal clients get the private IP, the internet gets the public IP or nothing.

APPROACHHOW
Public zone has no recordOnly the internal resolver knows app.home.example.com (nothing leaks, certificate still works via DNS-01)
Internal overrideLocal DNS record on Pi-hole / AdGuard Home / Unbound points the name or whole wildcard at the proxy IP
Wildcard rewriteOne record for *.home.example.com to the proxy IP, then routing happens by Host header in the proxy
Remote accessReach the same names over Tailscale or WireGuard; point the VPN DNS at the internal resolver

Wildcard to the proxy, three resolvers:

# Pi-hole v6: Settings -> Local DNS Records, or add to the config
pihole-FTL --config misc.dnsmasq_lines '["address=/home.example.com/192.168.20.10"]'

# AdGuard Home: Filters -> DNS rewrites
#   *.home.example.com  ->  192.168.20.10

# Unbound
local-zone: "home.example.com." redirect
local-data: "home.example.com. A 192.168.20.10"

address=/domain/ip and redirect both match the domain and all subdomains. Because an A answer is returned for every name, the proxy must reject unknown hosts (see the Caddy abort and the Traefik default 404).

Check from a client:

bash
dig +short app.home.example.com @192.168.20.2        # internal resolver
dig +short app.home.example.com @1.1.1.1             # should be empty or public

Beware DNS rebinding protection: some routers and upstream resolvers drop answers that point to private ranges. Fix by using your own resolver, or add the domain to the rebind allow-list (for example rebind-domain-ok=/home.example.com/ in dnsmasq).


Internal-Only Services #

GOALTECHNIQUE
Not reachable from the internetDo not port-forward 80/443; use DNS-01 for certs; keep the public zone free of A records
Only from LAN or VPNTraefik ipAllowList middleware (named ipWhiteList in v2), Nginx allow 192.168.0.0/16; deny all;, Caddy @lan remote_ip private_ranges
Separate entrypointsInternal entrypoint bound to the LAN address (address: "192.168.20.10:443"), public one to the WAN side
Per-network proxiesOne proxy instance on the public VLAN, one on the internal VLAN; see VLANs
Auth in front of appsForward-auth (Authelia, Authentik, oauth2-proxy) via Traefik forwardAuth, Nginx auth_request, Caddy forward_auth
Do not trust the headerOnly trust X-Forwarded-For from your proxy: set forwardedHeaders.trustedIPs in Traefik, set_real_ip_from in Nginx

Traefik middleware example (labels):

yaml
labels:
  - traefik.http.middlewares.lan-only.ipallowlist.sourcerange=192.168.0.0/16,100.64.0.0/10
  - traefik.http.routers.admin.rule=Host(`admin.home.example.com`)
  - traefik.http.routers.admin.middlewares=lan-only

Caddy:

admin.home.example.com {
    @denied not remote_ip private_ranges 100.64.0.0/10
    respond @denied 403
    reverse_proxy admin:8080
}

Nginx:

nginx
location / {
    allow 192.168.0.0/16;
    allow 100.64.0.0/10;
    deny all;
    proxy_pass http://127.0.0.1:8080;
}

Troubleshooting #

SYMPTOMCHECK
Certificate request fails with TXT lookup errorsPropagation: set the resolvers or delay (propagation.delayBeforeChecks in Traefik), and check split-horizon DNS is not hiding the public TXT record from the proxy’s own resolver
unauthorized from DNS APIToken lacks Zone:DNS:Edit on the zone, or Zone:Zone:Read for the lookup
Browser shows the default Traefik certRouter Host() rule does not match, or the router has no tls; check the dashboard
502 / 504 from proxyWrong upstream name or port, service on another Docker network, or the upstream listens on 127.0.0.1 only
Redirect loopApp behind the proxy thinks it is plain HTTP; set the forwarded proto header or the app’s “trusted proxy” option
Works internally, fails externallyDNS is correct, but 443 is not forwarded, or the firewall blocks it (UFW (uncomplicated firewall))
Rate limited by Let’s EncryptUse the staging CA while testing; wildcard reduces the number of certificates
curl -vk --resolve app.home.example.com:443:192.168.20.10 https://app.home.example.comTest the proxy directly, bypassing DNS
Search all sheets

Tip: / or Ctrl/Cmd+K to open, Esc to close.