Skip to main content
[░░░░░░░░░░░░░░░░░░░░]0% — 22 min left
~/blog/reverse-proxy-homelab.mdx
$cat~/blog/reverse-proxy-homelab.mdx

Reverse Proxy for a Homelab: Caddy Done Right

February 25, 202622min

Reverse Proxy for a Homelab: Caddy Done Right

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 diagram

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:

  • Clean URLsgrafana.home.local instead of 192.168.1.50:3000
  • TLS termination — HTTPS handled in one place, not per-app
  • Access control — authentication, rate limiting, IP restrictions
  • Flexibility — swap backends without changing URLs
💡Reverse vs forward proxy

A forward proxy sits in front of clients (like a corporate web filter). A reverse proxy sits in front of servers. Same concept, opposite direction.

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.

Docker Compose

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:

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
    }
}
💬Use a shared Docker network

Put all your services on a proxy network so Caddy can reach them by container name. This keeps ports internal — no need to expose 8096 to the host.

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:

  • Externally: doesn’t resolve (or points to nothing)
  • Internally: points to your homelab server

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):

*.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.

Authentication (forward auth)

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

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:

(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:

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:

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

Load balancing (multiple backends)

If you have multiple instances of a service:

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:

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

{
    debug
}

yourdomain.com {
    # ...
}

Or check logs:

docker logs caddy --tail 100 -f

Common issues

“502 Bad Gateway”

  • Backend isn’t running or isn’t reachable
  • Check container networking: docker exec caddy ping jellyfin
  • Verify the backend port matches your config

“Certificate error” on internal domains

  • You need DNS challenge for domains that aren’t publicly reachable
  • Or trust a self-signed cert on your devices

“Redirect loop”

  • Usually happens when the backend also does HTTPS redirect
  • Make sure the backend serves HTTP, let Caddy handle HTTPS

Changes not applying

  • Caddy hot-reloads, but check for syntax errors: docker exec caddy caddy validate --config /etc/caddy/Caddyfile

My production Caddyfile (annotated)

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

# 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:

  • You only need access from your tailnet
  • You want zero-config HTTPS
  • Simple single-service exposure

Use Caddy when:

  • You need custom domains
  • You’re exposing services publicly
  • You need authentication, path routing, or advanced features

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:

  • Connection pooling: Caddy does this by default for HTTP/1.1 and HTTP/2
  • Gzip/Brotli compression: encode gzip in your Caddyfile
  • Caching: Use Cloudflare for public assets, or Caddy’s cache directive (experimental)
api.yourdomain.com {
    encode gzip
    reverse_proxy api:8080
}

Further reading

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.