How to Set Up a Reverse Proxy with Caddy

How to Set Up a Reverse Proxy with Caddy

What is a Reverse Proxy and Why Do You Need One?

If you're running multiple services on your server, you've probably hit a wall. You've got Jellyfin on port 8096, Nextcloud on port 443, maybe Grafana on 3000. But you only have one IP address. And typing myserver.com:8096 is ugly. What if you want jellyfin.myserver.com instead?

That's where a reverse proxy comes in. It sits in front of all your services, listens on ports 80 and 443, and routes traffic to the right place based on the domain name. Think of it like a receptionist at a building - visitors say who they're here to see, and the receptionist points them to the right office.

There are several reverse proxy options out there - Nginx, Traefik, HAProxy. But today we're using Caddy, and here's why: it handles SSL certificates automatically. No messing with Let's Encrypt commands, no cron jobs, no certificate renewal headaches. Caddy just does it. For beginners, this alone makes it worth using.

What You'll Need

How Caddy Works

Before we dive into the config, let's understand what's actually happening. When someone visits jellyfin.yourdomain.com:

  • The request hits your server on port 443 (HTTPS)
  • Caddy checks the hostname and finds a matching rule
  • Caddy forwards the request to your Jellyfin container (running internally on port 8096)
  • Jellyfin responds to Caddy, and Caddy sends it back to the visitor

The visitor never talks directly to Jellyfin. They only talk to Caddy. This has security benefits too - your actual services aren't exposed directly to the internet.

Setting Up Caddy with Docker

We'll use Docker Compose because it makes everything easier to manage. Create a new directory for your Caddy setup:

mkdir -p ~/caddy
cd ~/caddy

Now create a docker-compose.yml file:

version: "3.8"

services:
  caddy:
    image: caddy:2-alpine
    container_name: caddy
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - ./data:/data
      - ./config:/config
    networks:
      - proxy

networks:
  proxy:
    external: true

A few things to note here:

  • We're using the Alpine variant because it's smaller
  • Port 443/udp is for HTTP/3 support - not required, but nice to have
  • The /data volume stores your SSL certificates - don't delete this!
  • We're using an external network called "proxy" so other containers can connect to it

Create that network before starting Caddy:

docker network create proxy

Writing Your Caddyfile

The Caddyfile is where the magic happens. It's surprisingly simple compared to Nginx configs. Create a file called Caddyfile in the same directory:

{
    email your@email.com
}

jellyfin.yourdomain.com {
    reverse_proxy jellyfin:8096
}

nextcloud.yourdomain.com {
    reverse_proxy nextcloud:80
}

grafana.yourdomain.com {
    reverse_proxy grafana:3000
}

That's it. Seriously. The email at the top is for Let's Encrypt notifications (like when a cert is about to expire, though Caddy auto-renews anyway). Each block defines a domain and where to send traffic.

The container names (jellyfin, nextcloud, grafana) work because Docker's internal DNS resolves them. As long as those containers are on the same "proxy" network, Caddy can reach them by name.

Connecting Your Services to Caddy

For Caddy to proxy to your services, they need to be on the same Docker network. Here's an example of how to modify an existing service. Let's say you have Jellyfin running like this:

version: "3.8"

services:
  jellyfin:
    image: jellyfin/jellyfin
    container_name: jellyfin
    restart: unless-stopped
    volumes:
      - ./config:/config
      - /path/to/media:/media
    ports:
      - "8096:8096"

To work with Caddy, change it to:

version: "3.8"

services:
  jellyfin:
    image: jellyfin/jellyfin
    container_name: jellyfin
    restart: unless-stopped
    volumes:
      - ./config:/config
      - /path/to/media:/media
    networks:
      - proxy

networks:
  proxy:
    external: true

Notice we removed the ports section entirely. You don't need to expose ports to the host anymore - Caddy handles external access. The service only needs to be reachable within the Docker network.

This is actually more secure. Your services aren't listening on public ports directly.

Starting Everything Up

Make sure your DNS is configured first. Each subdomain (jellyfin.yourdomain.com, etc.) should have an A record pointing to your server's IP. If you're using Cloudflare, set the records to "DNS only" initially - you can enable the proxy later if you want.

Start Caddy:

cd ~/caddy
docker compose up -d

Check the logs to make sure everything is working:

docker logs caddy

You should see Caddy obtaining certificates for each domain. If there are errors, it's usually one of these:

  • DNS not pointing to your server yet (can take up to 48 hours to propagate, but usually much faster)
  • Ports 80 or 443 blocked by a firewall
  • Another service already using those ports

Useful Caddyfile Patterns

Once you get the basics working, here are some patterns you'll find useful.

Basic Authentication

Want to password-protect a service that doesn't have its own auth?

stats.yourdomain.com {
    basicauth {
        admin $2a$14$your_hashed_password_here
    }
    reverse_proxy uptime-kuma:3001
}

Generate the password hash with:

docker exec -it caddy caddy hash-password

Headers and Security

Add security headers to all your sites:

(security_headers) {
    header {
        X-Content-Type-Options nosniff
        X-Frame-Options DENY
        Referrer-Policy strict-origin-when-cross-origin
    }
}

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

Redirect HTTP to HTTPS

Caddy does this automatically. You don't need to configure anything. Every HTTP request gets redirected to HTTPS. This is one of those "batteries included" features that makes Caddy great for beginners.

Wildcard Certificates

If you have lots of subdomains, you might want a wildcard certificate. This requires DNS validation since you can't prove ownership of a wildcard through HTTP. Here's an example using Cloudflare:

*.yourdomain.com {
    tls {
        dns cloudflare {env.CLOUDFLARE_API_TOKEN}
    }
    
    @jellyfin host jellyfin.yourdomain.com
    handle @jellyfin {
        reverse_proxy jellyfin:8096
    }
    
    @nextcloud host nextcloud.yourdomain.com
    handle @nextcloud {
        reverse_proxy nextcloud:80
    }
}

For this to work, you'll need the Caddy image with DNS provider plugins built in. You can build your own or use a community image like caddy-dns/cloudflare.

Troubleshooting Common Issues

502 Bad Gateway

This usually means Caddy can't reach the backend service. Check that:

  • The service is actually running (docker ps)
  • The service is on the same network as Caddy
  • The port number in your Caddyfile matches the service's internal port
  • The container name is spelled correctly

Certificate Errors

If Caddy can't get certificates:

  • Make sure ports 80 and 443 are open and not blocked by UFW/iptables
  • Verify DNS is pointing to your server: dig jellyfin.yourdomain.com
  • Check if another service (like Apache or Nginx) is already using port 80

WebSocket Issues

Some apps use WebSockets for real-time features. If something isn't working right, try adding this to your config:

jellyfin.yourdomain.com {
    reverse_proxy jellyfin:8096 {
        header_up Host {host}
        header_up X-Real-IP {remote}
        header_up X-Forwarded-For {remote}
        header_up X-Forwarded-Proto {scheme}
    }
}

Why Caddy Over Nginx or Traefik?

I've used all three, and here's my honest take:

Nginx is powerful and fast, but the config syntax is verbose and SSL setup requires manual Let's Encrypt configuration with certbot. It's fine once you know it, but there's a learning curve.

Traefik is great if you want everything auto-discovered from Docker labels. But it has a steep learning curve, and the documentation can be confusing with v1 vs v2 differences.

Caddy hits the sweet spot for most homelabbers. The Caddyfile is readable, automatic HTTPS just works, and you can get a multi-service setup running in 15 minutes. The only downside is slightly higher memory usage than Nginx, but we're talking maybe 30MB - irrelevant on any modern VPS.

If you're on a cheap VPS from Hetzner or Vultr with 2GB+ RAM, Caddy won't even make a dent in your resources.

Wrapping Up

You now have a working reverse proxy that:

  • Routes traffic to multiple services based on subdomain
  • Handles SSL certificates automatically
  • Keeps your services off public ports
  • Can be extended with auth, headers, and more

The best part? Once it's set up, you barely have to think about it. Adding a new service is just a few lines in the Caddyfile and a docker compose restart caddy.

Got questions or running into issues? Drop a comment below. And if you haven't set up your server yet, check out our guides on getting your first VPS and Docker basics - they'll give you a solid foundation for everything we covered here.