Reverse Proxy with Caddy on Raspberry Pi

Set up Caddy as a reverse proxy on Raspberry Pi with automatic HTTPS. Route multiple services through one domain with subdomains.

Andreas · April 13, 2026 · 9 min read

Introduction

A reverse proxy sits between your clients and backend services, forwarding requests to the appropriate service. If you're running multiple applications on your Raspberry Pi — Nextcloud, Jellyfin, Pihole, Home Assistant — a reverse proxy lets you access them all through a single domain with clean subdomains, eliminating the need to remember different ports.

Beyond routing, a reverse proxy also:

  • Centralizes HTTPS/TLS termination: Certificates are managed in one place, not on each service
  • Provides load balancing: Distribute traffic across multiple backend servers
  • Enables clean URLs: Access nextcloud.home.example.com instead of 192.168.1.10:8080
  • Adds security headers: Protect against XSS, clickjacking, and MIME-sniffing
  • Simplifies DNS: Single DNS record routing to all services

For a Raspberry Pi homelab, a reverse proxy is essential infrastructure.

Why Caddy over Nginx or Traefik

Three reverse proxies dominate the self-hosting scene: Nginx, Traefik, and Caddy. Here's why Caddy wins for Raspberry Pi:

Automatic HTTPS: Caddy automatically provisions and renews Let's Encrypt certificates with zero configuration. Nginx requires manual cert management and cron jobs. Traefik comes close but adds complexity.

Simple Configuration: Caddy's config format is human-readable. Compare:

# Caddy
example.com {
  reverse_proxy localhost:8080
}

To Nginx's equivalent (3x the code with more boilerplate). For a Pi with limited resources, simpler configuration means lower memory footprint.

No Renewal Cron Jobs: Caddy handles certificate renewal automatically in the background. Nginx admins must set up separate automation.

LAN-Only Support: Caddy works equally well for internet-facing and LAN-only setups, with options for self-signed certs and DNS challenges.

Container-Friendly: Caddy's Docker integration with label-based auto-discovery means you can spin up a new service and have it automatically discovered without editing config files.

For a Raspberry Pi, where simplicity and low resource usage matter, Caddy is the ideal choice.

Prerequisites

Before starting, ensure you have:

  • Raspberry Pi 3B or later (4GB RAM recommended for comfort)
  • Docker and Docker Compose installed on your Pi
  • A domain name (for internet-facing setups; LAN-only setups can use local domains)
  • A static IP address for your Raspberry Pi on your local network
  • Port 80 and 443 accessible (or port forwarding configured on your router)
  • Basic understanding of DNS and networking concepts

If Docker isn't installed, run:

curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo usermod -aG docker $USER

Verify installation with docker --version and docker-compose --version.

Step 1 — Install Caddy with Docker

We'll use Docker to keep system dependencies minimal. Create a project directory:

mkdir -p ~/caddy/{config,data}
cd ~/caddy

Create docker-compose.yml:

version: '3.8'

services:
  caddy:
    image: caddy:latest
    container_name: caddy-reverse-proxy
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    environment:
      # Cloudflare API token for DNS challenges (optional)
      # CLOUDFLARE_API_TOKEN: your_token_here
      
      # For Let's Encrypt staging (testing without rate limits)
      # CADDY_ENVIRONMENT: STAGE=1
      pass
    volumes:
      - ./config/Caddyfile:/etc/caddy/Caddyfile
      - caddy_data:/data
      - caddy_config:/config
    networks:
      - homelab

networks:
  homelab:
    driver: bridge
    ipam:
      config:
        - subnet: 172.20.0.0/16

volumes:
  caddy_data:
  caddy_config:

What this does:

  • Maps ports 80 (HTTP) and 443 (HTTPS) from the container to your host
  • Mounts your Caddyfile (configuration) into the container
  • Uses volumes to persist certificate data and configuration across restarts
  • Creates a homelab Docker network so Caddy can reach other containers by service name

Start the service:

docker-compose up -d

Verify it's running:

docker-compose logs -f caddy

Step 2 — Basic Caddyfile (Single Service)

Create config/Caddyfile:

# Global settings
{
  # Use HTTP/2 for better performance
  order encode gzip
}

# Single service example: reverse proxy to a web app on port 8080
example.com {
  # Enable gzip compression
  encode gzip

  # Reverse proxy configuration
  reverse_proxy localhost:8080 {
    # Keep-alive for better performance
    header_up Connection keep-alive
  }
}

Line-by-line explanation:

  • {} block: Global Caddy options apply to all sites
  • order encode gzip: Specify filter order for compression
  • example.com: The domain this configuration applies to
  • encode gzip: Compress responses for bandwidth savings
  • reverse_proxy localhost:8080: Forward requests to this address
  • header_up Connection keep-alive: Tell upstream server to use persistent connections

Reload the config (Caddy watches for changes):

docker-compose restart caddy

Test by visiting https://example.com. Caddy automatically obtains a certificate from Let's Encrypt.

Step 3 — Multiple Subdomains

Most homelabs run multiple services. Update config/Caddyfile:

{
  order encode gzip
}

# Nextcloud
nextcloud.home.example.com {
  encode gzip
  reverse_proxy localhost:8080 {
    header_up Connection keep-alive
  }
}

# Jellyfin (media server)
jellyfin.home.example.com {
  encode gzip
  reverse_proxy localhost:8096 {
    header_up Connection keep-alive
    # Jellyfin requires these headers to work behind a reverse proxy
    header_up X-Real-IP {remote_host}
    header_up X-Forwarded-For {remote_host}
    header_up X-Forwarded-Proto {scheme}
  }
}

# Pihole (DNS admin)
pihole.home.example.com {
  encode gzip
  reverse_proxy localhost:80 {
    # Pihole already runs on 80 internally, use different port externally
    header_up X-Real-IP {remote_host}
    header_up X-Forwarded-For {remote_host}
  }
}

# Home Assistant
ha.home.example.com {
  encode gzip
  reverse_proxy localhost:8123 {
    header_up X-Real-IP {remote_host}
    header_up X-Forwarded-For {remote_host}
    header_up X-Forwarded-Proto {scheme}
    # Home Assistant needs websocket support
    websocket
  }
}

# Vaultwarden (password manager)
vault.home.example.com {
  encode gzip
  reverse_proxy localhost:8000 {
    header_up X-Real-IP {remote_host}
    header_up X-Forwarded-For {remote_host}
    header_up X-Forwarded-Proto {scheme}
  }
}

# Default catch-all (redirects unknown domains to HTTPS)
:80 {
  redir https://{host}{uri}
}

Key additions:

  • websocket: Enables WebSocket support for applications like Home Assistant
  • header_up X-Real-IP: Passes the client's real IP to the upstream service
  • header_up X-Forwarded-Proto: Tells backend services the request came through HTTPS
  • Each subdomain gets its own block with independent configuration

Now you can access all services through your domain:

  • https://nextcloud.home.example.com
  • https://jellyfin.home.example.com
  • https://pihole.home.example.com
  • https://ha.home.example.com
  • https://vault.home.example.com

Step 4 — Automatic HTTPS with Let's Encrypt

Caddy handles HTTPS automatically, but you need to configure how it validates domain ownership. For internet-facing setups, HTTP validation (the default) works:

{
  acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}

example.com, *.example.com {
  reverse_proxy localhost:8080
}

For multiple subdomains on the same domain, use a wildcard cert. Update docker-compose.yml to pass your Cloudflare API token:

environment:
  CLOUDFLARE_API_TOKEN: your_cloudflare_api_token_here

Caddy uses ACME (Automatic Certificate Management Environment) to:

  1. Prove domain ownership to Let's Encrypt
  2. Receive a signed certificate
  3. Automatically renew certificates 30 days before expiry

DNS Challenge for LAN-Only Setups:

If your domain isn't publicly accessible (LAN-only), Let's Encrypt's HTTP validation fails. Instead, use DNS challenges with Cloudflare:

{
  acme_ca https://acme-v02.api.letsencrypt.org/directory
  acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}

# Works even on internal network, as long as Cloudflare DNS is configured
internal.home.example.com {
  reverse_proxy localhost:8080
}

This tells Let's Encrypt to verify ownership by checking DNS records (which Caddy modifies automatically via Cloudflare's API).

Certificate files are automatically stored in the caddy_data volume and persisted across restarts.

Step 5 — LAN-Only Setup with Self-Signed Certs

If you don't need Let's Encrypt or don't own a domain, use self-signed certificates:

{
  # Disable ACME (automatic certificates)
  acme_ca internal
}

homelab.local {
  reverse_proxy localhost:8080 {
    header_up Connection keep-alive
  }
}

nextcloud.homelab.local {
  reverse_proxy localhost:8080
}

jellyfin.homelab.local {
  reverse_proxy localhost:8096
}

With acme_ca internal, Caddy generates self-signed certificates for every domain. Browsers will warn about untrusted certificates, but the connection is still encrypted.

Add to /etc/hosts on your client machine (Linux/Mac):

192.168.1.50 homelab.local nextcloud.homelab.local jellyfin.homelab.local

On Windows, edit C:\Windows\System32\drivers\etc\hosts:

192.168.1.50 homelab.local nextcloud.homelab.local jellyfin.homelab.local

Now access https://nextcloud.homelab.local from your LAN. Accept the self-signed certificate warning, and you're in.

Step 6 — Add Security Headers

Protect your services by adding HTTP security headers:

{
  order encode gzip
}

# Helper snippet for common security headers
(security-headers) {
  # Strict-Transport-Security: force HTTPS for 1 year
  header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
  
  # X-Frame-Options: prevent clickjacking
  header X-Frame-Options "DENY"
  
  # X-Content-Type-Options: prevent MIME-sniffing
  header X-Content-Type-Options "nosniff"
  
  # X-XSS-Protection: legacy XSS filter (modern browsers use CSP)
  header X-XSS-Protection "1; mode=block"
  
  # Referrer-Policy: control what referrer data is shared
  header Referrer-Policy "strict-origin-when-cross-origin"
  
  # Permissions-Policy: restrict browser features
  header Permissions-Policy "geolocation=(), microphone=(), camera=()"
}

nextcloud.home.example.com {
  encode gzip
  import security-headers
  reverse_proxy localhost:8080
}

jellyfin.home.example.com {
  encode gzip
  import security-headers
  reverse_proxy localhost:8096 {
    websocket
  }
}

The (security-headers) snippet is reusable across all your services. import security-headers applies all headers to that domain.

What these headers do:

  • HSTS: Browsers never use HTTP for this domain, preventing downgrade attacks
  • X-Frame-Options: Prevents embedding your site in iframes (clickjacking defense)
  • X-Content-Type-Options: Prevents browsers from guessing file types
  • Referrer-Policy: Hides referrer URLs from upstream services
  • Permissions-Policy: Blocks access to sensitive browser APIs

Caddy vs Nginx vs Traefik

Feature Caddy Nginx Traefik
Auto HTTPS ✓ Yes ✗ Manual ✓ Yes
Config Simplicity ✓ Simple ✗ Complex ~ Medium
Learning Curve ✓ Easy ✗ Steep ~ Medium
Memory Usage ✓ ~50MB ✓ ~10MB ~ 100MB
Docker Labels ✓ Yes ✗ No ✓ Yes
Wildcard Certs ✓ Yes ✓ Yes ✓ Yes
Cron Jobs Needed ✗ No ✓ Yes ✗ No
LAN Self-Signed ✓ Built-in ✗ Manual ~ Complex
Performance ✓ Good ✓ Excellent ~ Good
Ideal For Homelabs, simplicity High-traffic, performance Complex routing, Docker Swarm

For a Raspberry Pi homelab where simplicity and low resource usage matter, Caddy is the winner.

Advanced: Caddy with Docker Labels

Instead of manually editing the Caddyfile, you can use Docker labels to automatically discover and configure services:

Update docker-compose.yml:

version: '3.8'

services:
  caddy:
    image: caddy:latest
    container_name: caddy-reverse-proxy
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - caddy_data:/data
      - caddy_config:/config
    networks:
      - homelab

  nextcloud:
    image: nextcloud:latest
    container_name: nextcloud
    restart: unless-stopped
    ports:
      - "8080:80"
    labels:
      caddy: "nextcloud.home.example.com"
      caddy.reverse_proxy: "{{upstreams}}"
    networks:
      - homelab
    # ... rest of nextcloud config

  jellyfin:
    image: jellyfin/jellyfin:latest
    container_name: jellyfin
    restart: unless-stopped
    ports:
      - "8096:8096"
    labels:
      caddy: "jellyfin.home.example.com"
      caddy.reverse_proxy: "{{upstreams}}"
      caddy.header: |
        X-Real-IP {remote_host}
        X-Forwarded-For {remote_host}
    networks:
      - homelab
    # ... rest of jellyfin config

networks:
  homelab:
    driver: bridge

volumes:
  caddy_data:
  caddy_config:

Then use this minimal Caddyfile:

{
  order encode gzip
}

:80 {
  encode gzip
  reverse_proxy localhost:8080
}

Caddy automatically discovers services via Docker labels:

  • caddy: The domain to use
  • caddy.reverse_proxy: The upstream service
  • caddy.header: Custom headers

When you start a new service with proper labels, Caddy picks it up instantly.

Troubleshooting

Port 80 Already in Use

If something else (web server, Pihole) is using port 80:

# Find what's using port 80
sudo lsof -i :80

# Either stop the conflicting service or change Caddy's port in docker-compose.yml
ports:
  - "8080:80"  # External port 8080 maps to container port 80

Then adjust your Caddyfile or DNS to point to example.com:8080.

Certificate Errors / Untrusted Certificate

If you see certificate warnings:

  1. Clear stale certificates: docker-compose exec caddy rm -rf /data/caddy/certificates
  2. Restart Caddy: docker-compose restart caddy
  3. Check logs: docker-compose logs caddy for ACME errors
  4. DNS issue: Verify your domain resolves to your Pi's IP: nslookup example.com

For Let's Encrypt rate limits, test with the staging server:

{
  acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
}

502 Bad Gateway

Means Caddy can't reach your upstream service. Check:

# Is the service running?
docker-compose ps

# Can Caddy reach it?
docker-compose exec caddy curl http://localhost:8080

# Are firewall rules blocking it?
sudo ufw status

DNS Not Resolving

For LAN-only setups, ensure you added the IP to /etc/hosts:

# Test name resolution
nslookup nextcloud.homelab.local

For internet-facing domains, verify DNS points to your Pi's public IP:

dig example.com

Summary

You now have a professional-grade reverse proxy running on your Raspberry Pi. Caddy automatically handles HTTPS, routes traffic to multiple services, and requires minimal configuration or maintenance.

Key takeaways:

  • Caddy simplifies reverse proxy setup compared to Nginx or Traefik
  • Automatic HTTPS with Let's Encrypt removes certificate management headaches
  • Multiple subdomains let you access all services through clean URLs
  • Security headers protect against common web attacks
  • LAN-only setups work with self-signed certificates and /etc/hosts
  • Docker integration enables automatic service discovery

Your Pi now acts as a professional gateway to your entire homelab. Add more services by creating new subdomains in your Caddyfile — Caddy handles the rest.

Next steps:

  • Explore Caddy's full documentation: https://caddyserver.com/docs
  • Set up wildcard DNS for dynamic subdomains
  • Monitor Caddy with Prometheus metrics
  • Implement rate limiting for sensitive services
  • Set up automated backups of certificate data

Happy self-hosting!

Related Tools

Comments