$linuxjunkies
>

Caddy as a Reverse Proxy

Set up Caddy as a reverse proxy with automatic HTTPS, load balancing, WebSocket passthrough, reusable snippets, and header control — no certbot required.

BeginnerUbuntuDebianFedoraArch8 min readUpdated June 7, 2026

Before you start

  • A Linux server with ports 80 and 443 open in your firewall
  • A domain name with DNS A/AAAA records pointing to the server (for automatic public TLS)
  • A backend service already running and listening on a local port
  • sudo or root access on the server

Caddy is a modern web server written in Go that provisions and renews TLS certificates automatically via Let's Encrypt or ZeroSSL. Its reverse_proxy directive makes it a compelling front-end for services like Node.js apps, Docker containers, Grafana, or any HTTP backend — without touching a separate cert management tool. This guide walks through installing Caddy, writing a practical Caddyfile, and setting up common reverse-proxy patterns including WebSocket passthrough and header manipulation.

Install Caddy

Debian / Ubuntu

sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
  | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
  | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update && sudo apt install -y caddy

Fedora / RHEL 9 / Rocky 9

sudo dnf install -y 'dnf-command(copr)'
sudo dnf copr enable @caddy/caddy -y
sudo dnf install -y caddy

Arch Linux

sudo pacman -S caddy

On all three, the package ships a systemd unit. Enable and start it now so you can reload it as you work:

sudo systemctl enable --now caddy

Caddyfile Basics

The main configuration file lives at /etc/caddy/Caddyfile. Its structure is straightforward: a site address (which can be a bare domain, an IP, or a localhost label) followed by a block of directives.

example.com {
    reverse_proxy localhost:3000
}

That single block is a complete TLS-terminating reverse proxy. Caddy detects that example.com is a public hostname, obtains a certificate automatically, listens on ports 80 and 443, and forwards decrypted traffic to your backend on port 3000. No ssl_certificate lines, no certbot cron job.

Important: Automatic HTTPS requires that your DNS A/AAAA records already point to this server and that ports 80 and 443 are reachable from the internet. If you're testing on a private network or localhost, Caddy falls back to a locally-trusted certificate using its own CA (requires trusting it in your browser or with caddy trust).

Common Reverse-Proxy Patterns

Basic single-backend proxy

app.example.com {
    reverse_proxy 127.0.0.1:8080
}

Multiple upstream backends with load balancing

app.example.com {
    reverse_proxy 127.0.0.1:8080 127.0.0.1:8081 127.0.0.1:8082 {
        lb_policy round_robin
    }
}

Caddy supports round_robin, least_conn, random, ip_hash, and first policies out of the box.

WebSocket passthrough

Caddy upgrades WebSocket connections automatically when it proxies HTTP. No extra directive is needed, but you must pass the correct headers so the backend sees the real client IP and protocol:

ws.example.com {
    reverse_proxy localhost:4000 {
        header_up Host {upstream_hostport}
        header_up X-Real-IP {remote_host}
        header_up X-Forwarded-For {remote_host}
        header_up X-Forwarded-Proto {scheme}
    }
}

Strip a path prefix before forwarding

Useful when your backend expects requests at / but your public URL has a sub-path:

example.com {
    handle /api/* {
        uri strip_prefix /api
        reverse_proxy localhost:5000
    }
    handle {
        reverse_proxy localhost:3000
    }
}

Proxy to a Docker container by name

If Caddy itself runs on the Docker host (not inside the container network), use the published port. If Caddy is in the same Docker network, use the container name as the hostname:

grafana.example.com {
    reverse_proxy grafana:3000
}

Reusable Snippets

Snippets let you define a block once and import it into multiple site blocks — ideal for shared security headers or logging configuration.

(secure_headers) {
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        X-Content-Type-Options "nosniff"
        X-Frame-Options "SAMEORIGIN"
        Referrer-Policy "strict-origin-when-cross-origin"
        -Server
    }
}

app1.example.com {
    import secure_headers
    reverse_proxy localhost:3000
}

app2.example.com {
    import secure_headers
    reverse_proxy localhost:4000
}

Snippets must appear at the top of the Caddyfile, before any site blocks. The -Server line removes Caddy's default Server response header.

TLS Configuration Options

Caddy handles TLS automatically, but you can tune it. To use a DNS challenge (needed when port 80 is not publicly reachable) install the appropriate DNS provider plugin and configure it in the tls block. For internal-only services, disable automatic HTTPS and use a self-signed or internal CA cert:

internal.corp {
    tls internal
    reverse_proxy localhost:8080
}

The tls internal directive tells Caddy to issue a cert from its own local CA. Run caddy trust once on each client machine to install that CA.

To force a specific minimum TLS version:

example.com {
    tls {
        protocols tls1.2 tls1.3
    }
    reverse_proxy localhost:3000
}

Apply Configuration Changes

After editing /etc/caddy/Caddyfile, validate then reload — no downtime, no dropped connections:

sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

A full restart (needed only when upgrading the binary) is:

sudo systemctl restart caddy

Verify the Proxy Is Working

curl -I https://app.example.com

Look for HTTP/2 200 (or your expected status code) and the security headers you configured. To check the certificate:

echo | openssl s_client -connect app.example.com:443 2>/dev/null \
  | openssl x509 -noout -issuer -dates

The issuer should be Let's Encrypt (or ZeroSSL — Caddy alternates between them) for public domains, or Caddy Local Authority for internal ones.

Check Caddy's live logs for upstream errors:

sudo journalctl -u caddy -f

Troubleshooting

  • 502 Bad Gateway — The upstream is not listening. Confirm the backend is running (ss -tlnp | grep PORT) and that the port matches your Caddyfile.
  • Certificate not issuing — Port 80 or 443 is blocked by a firewall. On systems with firewalld: sudo firewall-cmd --permanent --add-service=http --add-service=https && sudo firewall-cmd --reload. With ufw: sudo ufw allow 80,443/tcp.
  • Too many redirects — Your backend is also redirecting to HTTPS. Set the backend to serve plain HTTP on its internal port; let Caddy own TLS termination entirely.
  • Caddy can't bind port 80/443 — Another service (nginx, Apache) is already listening. Stop the conflicting service or move it to a different port first.
  • validate passes but reload fails — Check journalctl -u caddy --since "1 min ago" for the exact error. A common cause is a DNS challenge plugin that's not compiled into the binary; use the xcaddy build tool to add plugins.
tested on:Ubuntu 24.04Debian 12Fedora 40Arch rolling

Frequently asked questions

Does Caddy support HTTP/2 and HTTP/3 automatically?
Yes. Caddy enables HTTP/2 for all HTTPS sites by default. HTTP/3 (QUIC) is also supported and can be enabled with 'servers { protocol { experimental_http3 } }' in the global options block, though it is still considered experimental.
Can I use Caddy as a reverse proxy without a public domain name?
Yes. Use 'tls internal' in your site block and Caddy will issue a certificate from its local CA. Run 'caddy trust' on each client to install that CA, or use a raw IP address or localhost label for purely local testing.
How do I proxy to a backend that uses self-signed HTTPS?
Add 'transport http { tls_insecure_skip_verify }' inside your reverse_proxy block. Only use this on trusted internal backends — it disables certificate verification for the upstream connection.
What happens when a Let's Encrypt certificate is about to expire?
Caddy renews certificates automatically in the background, typically 30 days before expiry. No manual intervention or cron job is needed. Renewal failures are logged to the systemd journal.
How do I add basic authentication in front of a proxied service?
Use the basicauth directive above reverse_proxy in your site block. Generate a bcrypt hash with 'caddy hash-password' and add the username/hash pair to the basicauth block. Caddy will prompt for credentials before forwarding the request.

Related guides