← blog

Reverse Proxy for a Homelab: Caddy and Automatic HTTPS

22 min homelab & networking self-hosting, homelab, caddy, networking, security

You’ve got services running on different ports: Jellyfin on 8096, Grafana on 3000, Nextcloud on 443, and a dozen others. Remembering server:8096 is annoying. Typing IPs is worse. And exposing random ports to the internet? That’s a security incident waiting to happen.

A reverse proxy solves all of this: one entry point, clean URLs, automatic HTTPS, and centralized access control.

Reverse Proxy Architecture — Caddy Internet / Users Caddy Reverse Proxy :443 TLS termination Automatic HTTPS · clean URLs Let’s Encrypt (ACME) auto-issue + renew HTTPS :443 Jellyfin :8096 jellyfin.domain Grafana :3000 grafana.domain Nextcloud :80 nextcloud.domain NAS :5000 nas.domain One entry point — internal services never expose their ports

One public entry point: Caddy terminates TLS on :443, auto-manages Let’s Encrypt certificates, and routes clean hostnames to backend services that never expose their ports. Open the editable Excalidraw source.

What a reverse proxy actually does

A reverse proxy sits in front of your backend services and forwards requests to them. From the outside, users see jellyfin.yourdomain.com — they never know it’s actually running on port 8096 on some box in your closet.

Key benefits:

Why Caddy (and not Nginx or Traefik)

I’ve used all three. Here’s why I settled on Caddy:

FeatureCaddyNginxTraefik
Automatic HTTPSBuilt-in, zero configManual (certbot, cron)Built-in
Config syntaxSimple, readableVerbose, arcaneYAML/labels, learning curve
Hot reloadYesRequires reloadYes
Best forSimplicity + powerRaw performance, legacyContainer-native, k8s

Caddy’s killer feature: automatic HTTPS. It handles Let’s Encrypt certificates, renewal, and OCSP stapling without any configuration. You just define your domains, and it works.

For homelabs, that’s huge. Less toil, fewer moving parts.

Basic setup: one domain, one backend

Let’s start simple. You want jellyfin.yourdomain.com to point to Jellyfin running on port 8096. It takes two files: a compose file that runs Caddy and Jellyfin on one network, and the Caddyfile it mounts, which routes between them.

docker-compose.yml
services:
  caddy:
    image: caddy:latest
    container_name: caddy
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - ./caddy-data:/data
      - ./caddy-config:/config
    restart: unless-stopped
    networks:
      - proxy

  jellyfin:
    image: jellyfin/jellyfin
    container_name: jellyfin
    volumes:
      - ./jellyfin-config:/config
      - /media:/media
    networks:
      - proxy
    # No ports exposed — only accessible through Caddy

networks:
  proxy:
    name: proxy
Caddyfile
jellyfin.yourdomain.com {
    reverse_proxy jellyfin:8096
}

That’s it. Caddy will:

  1. Obtain a certificate from Let’s Encrypt
  2. Redirect HTTP to HTTPS
  3. Proxy requests to Jellyfin
  4. Handle TLS termination

No certbot. No cron jobs. No nginx.conf nightmares.

Multiple services, one proxy

Extend the pattern for more services:

Caddyfile
jellyfin.yourdomain.com {
    reverse_proxy jellyfin:8096
}

grafana.yourdomain.com {
    reverse_proxy grafana:3000
}

nextcloud.yourdomain.com {
    reverse_proxy nextcloud:80
}

# Wildcard for internal services
*.home.yourdomain.com {
    tls {
        dns cloudflare {env.CF_API_TOKEN}
    }
    
    @jellyfin host jellyfin.home.yourdomain.com
    handle @jellyfin {
        reverse_proxy jellyfin:8096
    }
    
    @grafana host grafana.home.yourdomain.com
    handle @grafana {
        reverse_proxy grafana:3000
    }
}

Internal domains (no public DNS needed)

For services you only access locally, you don’t need public DNS. Use a local domain like *.home.local or *.lan.

Option 1: Local DNS (Pi-hole, AdGuard Home)

If you run Pi-hole or AdGuard Home, add DNS records pointing your internal domains to your server’s IP:

jellyfin.home.local  →  192.168.1.50
grafana.home.local   →  192.168.1.50

Option 2: Split-horizon DNS

Use a real domain (e.g., home.yourdomain.com) but resolve it differently internally:

Your DNS server (or router) handles the internal resolution.

HTTPS for internal domains

Here’s the catch: Let’s Encrypt can’t verify domains it can’t reach. For internal-only domains, you have two options:

DNS challenge (recommended):

Caddyfile
*.home.yourdomain.com {
    tls {
        dns cloudflare {env.CF_API_TOKEN}
    }
    # ... your handlers
}

Caddy proves domain ownership by creating a DNS TXT record, not by responding to an HTTP request. Works for internal domains as long as you control the DNS.

Self-signed / internal CA:

Run your own certificate authority and trust it on your devices. More work, but fully offline.

Setting up DNS challenge with Cloudflare The full configuration
  1. Get a Cloudflare API token with Zone:DNS:Edit permissions for your domain.

  2. Use the Caddy image with DNS plugins:

docker-compose.yml
services:
  caddy:
    image: caddy:latest  # or build custom with dns.providers.cloudflare
    build:
      context: .
      dockerfile: Dockerfile.caddy
Dockerfile.caddy
FROM caddy:builder AS builder
RUN xcaddy build --with github.com/caddy-dns/cloudflare

FROM caddy:latest
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
  1. Set the environment variable:
YAML
environment:
  - CF_API_TOKEN=your-cloudflare-api-token
  1. Configure TLS in your Caddyfile:
Caddyfile
*.home.yourdomain.com {
    tls {
        dns cloudflare {env.CF_API_TOKEN}
    }
    # handlers...
}

Now Caddy can obtain wildcards certs for internal domains using DNS validation.

Authentication (forward auth)

Caddy integrates cleanly with forward auth providers like Tinyauth or Authelia. This protects apps that don’t have their own authentication.

Caddyfile
dashboard.yourdomain.com {
    forward_auth tinyauth:3000 {
        uri /api/auth/caddy
        copy_headers Remote-User Remote-Email
    }
    reverse_proxy heimdall:80
}

The copy_headers directive passes the authenticated user’s identity to the backend. Some apps can use this for user identification.

For a full SSO setup, see my SSO for Self-Hosted Apps guide.

Headers and security

Caddy sets sensible defaults, but you can add security headers:

Caddyfile
(security_headers) {
    header {
        # Security headers
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        X-Content-Type-Options "nosniff"
        X-Frame-Options "SAMEORIGIN"
        Referrer-Policy "strict-origin-when-cross-origin"
        
        # Remove server identification
        -Server
    }
}

jellyfin.yourdomain.com {
    import security_headers
    reverse_proxy jellyfin:8096
}

Use snippets (the (name) { } syntax) to avoid repeating yourself.

WebSocket support

Some apps use WebSockets for real-time features (chat, notifications, live updates). Caddy handles this automatically for most cases, but explicit configuration helps:

Caddyfile
chat.yourdomain.com {
    reverse_proxy chat-app:3000 {
        # WebSocket-friendly settings
        header_up Host {host}
        header_up X-Real-IP {remote_host}
        header_up X-Forwarded-For {remote_host}
        header_up X-Forwarded-Proto {scheme}
    }
}

Handling large uploads

Media servers and file sync services need larger upload limits:

Caddyfile
nextcloud.yourdomain.com {
    request_body {
        max_size 10GB
    }
    reverse_proxy nextcloud:80
}

Load balancing (multiple backends)

If you have multiple instances of a service:

Caddyfile
api.yourdomain.com {
    reverse_proxy api-1:8080 api-2:8080 api-3:8080 {
        lb_policy round_robin
        health_uri /health
        health_interval 30s
    }
}

Caddy will distribute requests and remove unhealthy backends from rotation.

Path-based routing

Route different paths to different backends:

Caddyfile
app.yourdomain.com {
    handle /api/* {
        reverse_proxy api-server:8080
    }
    
    handle /static/* {
        root * /var/www/static
        file_server
    }
    
    handle {
        reverse_proxy frontend:3000
    }
}

The order matters: Caddy evaluates handle blocks top-to-bottom, and the last handle without a matcher acts as the default.

Debugging and troubleshooting

Enable debug logging

Caddyfile
{
    debug
}

yourdomain.com {
    # ...
}

Or check logs:

shell
docker logs caddy --tail 100 -f

Common issues

“502 Bad Gateway”

“Certificate error” on internal domains

“Redirect loop”

Changes not applying

My production Caddyfile (annotated)

Here’s a simplified version of what I actually run:

Caddyfile
# Global options
{
    email you@yourdomain.com  # For Let's Encrypt notifications
    acme_ca https://acme-v02.api.letsencrypt.org/directory
}

# Snippets for reuse
(security_headers) {
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains"
        X-Content-Type-Options "nosniff"
        X-Frame-Options "SAMEORIGIN"
        -Server
    }
}

(forward_auth) {
    forward_auth tinyauth:3000 {
        uri /api/auth/caddy
        copy_headers Remote-User Remote-Email
    }
}

# Public services
jellyfin.yourdomain.com {
    import security_headers
    reverse_proxy jellyfin:8096
}

# Protected internal services
grafana.yourdomain.com {
    import security_headers
    import forward_auth
    reverse_proxy grafana:3000
}

# Auth endpoint (no forward_auth on this one!)
auth.yourdomain.com {
    reverse_proxy tinyauth:3000
}

# Wildcard for internal network (DNS challenge)
*.home.yourdomain.com {
    tls {
        dns cloudflare {env.CF_API_TOKEN}
    }
    import security_headers
    
    @nas host nas.home.yourdomain.com
    handle @nas {
        reverse_proxy nas:5000
    }
    
    @printer host printer.home.yourdomain.com
    handle @printer {
        reverse_proxy 192.168.1.100:80
    }
}

Caddy vs Tailscale Serve

If you’re already using Tailscale, you might wonder: why not just use Tailscale Serve?

ConsiderationCaddyTailscale Serve
HTTPS for tailnetDIY (DNS challenge)Automatic (*.ts.net)
Custom domainsFull controlLimited
Public exposureYou manageUse Funnel
Advanced routingFull powerBasic
Forward authYesNo

Use Tailscale Serve when:

Use Caddy when:

They’re not mutually exclusive — I use both. Tailscale Serve for quick internal access, Caddy for anything with custom domains or public exposure.

Performance considerations

Caddy is plenty fast for homelab workloads. If you’re serving high-traffic production, consider:

Caddyfile
api.yourdomain.com {
    encode gzip
    reverse_proxy api:8080
}

Further reading

FAQ

Why Caddy instead of Nginx or Traefik?

Automatic HTTPS with zero configuration: Caddy requests and renews Let’s Encrypt certificates by itself, no certbot, no cron. Nginx does everything Caddy does with more config; Traefik with more concepts. For a homelab rack, the shortest config that works wins.

Do my internal hostnames need public DNS records?

No. A wildcard certificate covers *.yourdomain.com, and split DNS — a Pi-hole or AdGuard record pointing jellyfin.yourdomain at your server — makes the name resolve only inside your network. Real certificates, no public trace.

Can the proxy add a login to apps that don’t have one?

Yes — forward auth. The proxy checks the session before the request reaches the app, so anything behind it is protected even if the app ships with no authentication. The SSO guide wires this up properly with Pocket ID and Tinyauth.

Which ports do I actually need to open?

Only 80 and 443, and only if you want services reachable from outside. Between Caddy and the backends, nothing needs publishing at all: containers on a shared Docker network are reachable by container name, and their ports stay internal.

Summary

A reverse proxy is the front door to your homelab. Caddy makes it trivial:

  1. Define your domains in a Caddyfile
  2. Point DNS to your server
  3. Let Caddy handle HTTPS

Add authentication when you need it. Add more backends as you grow. Keep everything behind one clean entry point.

Saved you an afternoon? Buy me a tea ↗