TL;DR — Quick Summary

Complete guide to Caddy as a reverse proxy with automatic HTTPS. Caddyfile syntax, load balancing policies, health checks, and multi-site production Caddyfile.

Caddy is a modern, open-source web server written in Go whose defining feature is automatic HTTPS — it provisions and renews TLS certificates from Let’s Encrypt or ZeroSSL the moment you point a domain at it, with zero manual Certbot setup. When used as a reverse proxy, Caddy combines that zero-config TLS management with a clean declarative syntax, built-in load balancing, HTTP/2 and HTTP/3 by default, and a single statically-compiled binary with no runtime dependencies. This guide covers everything you need to run Caddy as a production reverse proxy: installation, Caddyfile syntax, all load balancing policies, active and passive health checks, header manipulation, authentication, on-demand TLS, the admin API, and a complete multi-site Caddyfile.

Why Choose Caddy as a Reverse Proxy

Before diving into configuration, here is a direct comparison of the major reverse proxy options available in 2026:

FeatureCaddyNginxTraefikHAProxyApache
Automatic HTTPSBuilt-in ACME clientManual (Certbot)Built-in (via ACME)ManualManual (mod_md)
Configuration formatCaddyfile (minimal)nginx.conf (verbose)YAML/TOML/Docker labelshaproxy.cfghttpd.conf
HTTP/2DefaultExplicit configDefaultNo (TCP only)Explicit config
HTTP/3 (QUIC)DefaultExperimentalVia pluginNoNo
Single binaryYes (Go, no deps)No (C, with modules)Yes (Go)Yes (C)No
Runtime config APIFull REST APINoFull REST APIStats socket onlyNo
Load balancing8 policies built-inLimited built-inMultiple providersExcellentBasic
Memory footprint~20-50 MB~5-15 MB~25-60 MB~5-10 MB~30-80 MB
Learning curveLowMedium-HighMediumHighMedium-High

Choose Caddy when: you want automatic TLS, minimal config, and HTTP/3 without plugin builds or external tools. Ideal for self-hosted apps, multi-tenant reverse proxy, and teams that want zero certificate management overhead.

Choose Nginx when: you need maximum raw throughput, years of battle-tested documentation, or very specific worker/buffer tuning unavailable in Caddy.

Choose Traefik when: you run heavy Docker/Kubernetes workloads and want dynamic service discovery via container labels rather than editing config files.

Prerequisites

  • A Linux server running Ubuntu 22.04+, Debian 12+, or a RHEL/CentOS-compatible distribution
  • A domain name with DNS A/AAAA records pointing to the server’s public IP address
  • Ports 80 and 443 open in your firewall (required for ACME HTTP-01 challenge and HTTPS traffic)
  • Root or sudo access
  • No other process bound to ports 80 or 443 (stop Nginx, Apache, or any other web server first)
# Open required ports with UFW
sudo ufw allow 80/tcp && sudo ufw allow 443/tcp && sudo ufw status

Installing Caddy

Ubuntu and Debian (APT)

sudo apt update && 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
caddy version

RHEL, CentOS, Fedora (DNF)

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

Docker

docker run -d \
  -p 80:80 -p 443:443 -p 443:443/udp \
  -v /path/to/Caddyfile:/etc/caddy/Caddyfile \
  -v caddy_data:/data \
  -v caddy_config:/config \
  caddy:latest

xcaddy — Custom Builds with Plugins

When you need third-party modules such as caddy-dns for DNS-01 challenges or caddy-rate-limit:

# Install xcaddy
go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest

# Build Caddy with a DNS provider plugin (Cloudflare example)
xcaddy build \
  --with github.com/caddy-dns/cloudflare \
  --with github.com/mholt/caddy-ratelimit

sudo mv caddy /usr/bin/caddy
sudo systemctl restart caddy

The APT installation creates the caddy system user, places the default Caddyfile at /etc/caddy/Caddyfile, stores certificates in /var/lib/caddy/.local/share/caddy, and registers a systemd service.

Caddyfile Syntax

The Caddyfile is Caddy’s primary configuration format. It is intentionally human-readable — you describe what you want, and Caddy fills in secure defaults.

Structure

# Global options block (no address)
{
    email admin@example.com          # ACME account email
    admin off                        # Disable admin API in production
    grace_period 10s                 # Drain connections on shutdown
}

# Site block — address triggers automatic HTTPS
example.com {
    directive argument
}

# Multiple addresses share the same config
app.example.com, www.app.example.com {
    reverse_proxy localhost:3000
}

Matchers

Matchers filter which requests a directive applies to:

example.com {
    # Named matcher (reusable)
    @api path /api/*
    reverse_proxy @api localhost:8000

    # Inline matcher (path only)
    reverse_proxy /static/* localhost:9000

    # Wildcard matcher (* = all requests)
    file_server *
}

Common matcher types: path, host, method, header, query, remote_ip, protocol, not, and expression (CEL expressions for complex logic).

Placeholders

Caddy provides runtime placeholders for dynamic values:

example.com {
    reverse_proxy localhost:3000 {
        header_up X-Real-IP        {remote_host}
        header_up X-Forwarded-Port {server_port}
        header_up X-Request-ID     {uuid}
    }
}

Common placeholders: {remote_host}, {remote_port}, {server_port}, {host}, {path}, {method}, {uuid}, {upstream_hostport}.

Reverse Proxy Configuration

Basic Single Backend

app.example.com {
    reverse_proxy localhost:3000
}

This single line gives you: automatic HTTPS, HTTP/2, HTTP/3, HTTP-to-HTTPS redirect, X-Forwarded-For header forwarding, and transparent WebSocket support.

Path-Based Routing

example.com {
    reverse_proxy /api/*    localhost:8000
    reverse_proxy /ws/*     localhost:4000
    reverse_proxy /admin/*  localhost:9000

    root * /var/www/frontend
    file_server
}

Header Manipulation

Use header_up to modify request headers sent to the upstream and header_down to modify response headers returned to the client:

app.example.com {
    reverse_proxy localhost:3000 {
        # Add/override upstream request headers
        header_up Host              {upstream_hostport}
        header_up X-Real-IP         {remote_host}
        header_up X-Forwarded-Proto {scheme}

        # Remove a header before forwarding
        header_up -Authorization

        # Modify response headers from upstream
        header_down -X-Internal-Server-ID
        header_down Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
    }
}

WebSocket and Long-Lived Connections

Caddy proxies WebSocket connections automatically. For long-lived connections, tune the flush interval:

ws.example.com {
    reverse_proxy localhost:4000 {
        flush_interval -1    # Immediate flush (required for SSE and WebSockets)
    }
}

Load Balancing

Multiple Upstream Addresses

app.example.com {
    reverse_proxy localhost:3001 localhost:3002 localhost:3003
}

Default policy is round_robin — requests cycle through backends sequentially.

All Load Balancing Policies

app.example.com {
    reverse_proxy localhost:3001 localhost:3002 localhost:3003 {
        lb_policy least_conn         # Backend with fewest active connections
        # lb_policy round_robin      # Sequential cycling (default)
        # lb_policy first            # First available backend always
        # lb_policy random           # Random selection each request
        # lb_policy ip_hash          # Client IP → consistent backend (session affinity)
        # lb_policy cookie caddy_lb  # Cookie-based session persistence
        # lb_policy header X-Shard   # Route by request header value
        # lb_policy uri_hash         # Request URI → consistent backend
    }
}

Policy selection guide:

  • round_robin — Stateless services where any instance can handle any request
  • least_conn — Long-running requests (APIs, streaming) to avoid overloading slow backends
  • ip_hash / cookie — Stateful applications that require session affinity
  • uri_hash — Cache-friendly routing where the same URL should hit the same backend
  • first — Primary/standby failover where the first healthy backend absorbs all traffic

Active Health Checks

Active checks probe backends on a schedule and remove unhealthy instances from the pool:

app.example.com {
    reverse_proxy localhost:3001 localhost:3002 localhost:3003 {
        lb_policy least_conn

        health_uri      /health        # Endpoint to probe
        health_interval 15s            # Probe every 15 seconds
        health_timeout  5s             # Fail probe after 5 seconds
        health_status   200            # Expected HTTP status code
        health_headers  Authorization "Bearer healthcheck-token"
    }
}

Passive Health Checks

Passive checks detect failures from real traffic without external probing:

app.example.com {
    reverse_proxy localhost:3001 localhost:3002 localhost:3003 {
        lb_policy round_robin

        # Mark a backend unhealthy after 3 failures within 30 seconds
        fail_duration   30s
        max_fails       3

        # Mark unhealthy if latency exceeds 500ms
        unhealthy_latency 500ms

        # Mark unhealthy if response code is 5xx
        unhealthy_status 5xx
    }
}

Combining Active and Passive

app.example.com {
    reverse_proxy localhost:3001 localhost:3002 localhost:3003 {
        lb_policy least_conn

        health_uri      /health
        health_interval 10s
        health_timeout  3s

        fail_duration     20s
        max_fails         2
        unhealthy_latency 300ms
    }
}

Automatic HTTPS in Depth

How Caddy Obtains Certificates

When Caddy encounters a public domain name, it:

  1. Registers an ACME account with Let’s Encrypt (or ZeroSSL as fallback) using your email
  2. Completes one of the ACME domain validation challenges to prove ownership
  3. Receives and installs the signed certificate and private key
  4. Automatically redirects port 80 → 443
  5. Schedules background renewal approximately 30 days before expiration

ACME Challenge Types

{
    email admin@example.com
}

# HTTP-01 (default): Caddy serves /.well-known/acme-challenge/ on port 80
example.com {
    reverse_proxy localhost:3000
}

# TLS-ALPN-01: Uses port 443, works when port 80 is blocked
tls-only.example.com {
    tls {
        challenges tls-alpn-01
    }
    reverse_proxy localhost:3001
}

# DNS-01: No inbound port required — needs xcaddy + DNS plugin
# Used for wildcard certificates and internal services
wildcard.example.com {
    tls {
        dns cloudflare {env.CF_API_TOKEN}
    }
    reverse_proxy localhost:3002
}

On-Demand TLS for Dynamic Domains

On-demand TLS issues certificates at connection time rather than startup — useful for SaaS platforms where each customer has their own subdomain:

{
    on_demand_tls {
        ask      http://localhost:9001/check-domain    # Validation endpoint
        interval 2m
        burst    5
    }
}

:443 {
    tls {
        on_demand
    }
    reverse_proxy localhost:3000
}

Your validation endpoint must return 200 OK for allowed domains and any other status to reject.

Internal CA for Development

{
    local_certs    # Generate self-signed certs and install root CA locally
}

localhost, 127.0.0.1, ::1 {
    reverse_proxy localhost:3000
}

Caddy installs its root CA into the system trust store so browsers accept the certificate without warnings.

Custom TLS Options

secure.example.com {
    tls {
        protocols tls1.2 tls1.3    # Minimum TLS version
        ciphers TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
        curves x25519 secp384r1
    }
    reverse_proxy localhost:3000
}

Authentication

HTTP Basic Auth

admin.example.com {
    basicauth /admin/* {
        # Generate hashed password: caddy hash-password
        alice $2a$14$Zkx19XLiW6VYouLHR5NmfOFU0z2GT.VVKf3KCrXxpHPkAXmn9sQHO
    }
    reverse_proxy localhost:9000
}

Generate the password hash:

caddy hash-password --plaintext 'yourpassword'

Forward Auth (Authelia, oauth2-proxy)

app.example.com {
    forward_auth authelia:9091 {
        uri /api/verify?rd=https://auth.example.com
        copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
    }
    reverse_proxy localhost:3000
}

File Server and SPA Support

Static File Server

static.example.com {
    root * /var/www/html
    encode gzip zstd
    file_server {
        hide .git .env .htaccess
        precompressed gzip br zstd
    }
}

Single-Page Application (SPA)

spa.example.com {
    root * /var/www/app/dist
    encode gzip zstd

    # Serve index.html for all non-file routes (client-side routing)
    try_files {path} /index.html

    file_server
}

Compression and Logging

Compression

example.com {
    encode {
        zstd           # Preferred (better ratio, faster decode)
        gzip 6         # Fallback (compression level 1-9, default 6)
        minimum_length 1024    # Skip compressing responses under 1KB
    }
    reverse_proxy localhost:3000
}

Structured JSON Access Logging

example.com {
    log {
        output file /var/log/caddy/access.log {
            roll_size    100MiB
            roll_keep    10
            roll_keep_for 720h
        }
        format json
        level  INFO
    }
    reverse_proxy localhost:3000
}

Log entries include: timestamp, request method, URI, status code, response size, duration, remote IP, and TLS version.

The Caddy Admin API

Caddy exposes a local REST API on localhost:2019 for runtime configuration without reloading:

# View current running configuration
curl http://localhost:2019/config/

# List active certificates
curl http://localhost:2019/pki/ca/local

# Load a new configuration from a Caddyfile
curl -X POST http://localhost:2019/load \
  -H "Content-Type: text/caddyfile" \
  --data-binary @/etc/caddy/Caddyfile

# Dynamically add a new route at runtime
curl -X POST http://localhost:2019/config/apps/http/servers/srv0/routes \
  -H "Content-Type: application/json" \
  -d '{"handle":[{"handler":"reverse_proxy","upstreams":[{"dial":"localhost:5000"}]}]}'

Disable the admin API in production when not needed:

{
    admin off
}

Or bind it to a unix socket for secure access:

{
    admin unix//run/caddy/admin.sock
}

Production Multi-Site Caddyfile

This is a complete production Caddyfile for multi-site hosting with security headers, compression, structured logging, load balancing, and reusable snippets:

{
    email admin@example.com
    grace_period 30s
    admin unix//run/caddy/admin.sock
}

# Reusable security headers snippet
(security_headers) {
    header {
        Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
        X-Content-Type-Options    "nosniff"
        X-Frame-Options           "SAMEORIGIN"
        Referrer-Policy           "strict-origin-when-cross-origin"
        Permissions-Policy        "camera=(), microphone=(), geolocation=()"
        -Server
        -X-Powered-By
    }
}

# Main application — load balanced with health checks
app.example.com {
    import security_headers

    encode zstd gzip

    log {
        output file /var/log/caddy/app-access.log { roll_size 50MiB; roll_keep 7 }
        format json
    }

    reverse_proxy localhost:3001 localhost:3002 localhost:3003 {
        lb_policy       least_conn
        health_uri      /health
        health_interval 10s
        health_timeout  3s
        fail_duration   30s
        max_fails       3

        header_up X-Real-IP         {remote_host}
        header_up X-Forwarded-Proto {scheme}
        header_down -X-Internal-Trace-ID
    }
}

# API service — single backend with forward auth
api.example.com {
    import security_headers

    encode zstd gzip

    header {
        Access-Control-Allow-Origin  "https://app.example.com"
        Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
    }

    log {
        output file /var/log/caddy/api-access.log { roll_size 50MiB; roll_keep 7 }
        format json
    }

    reverse_proxy localhost:8000 {
        header_up X-Real-IP {remote_host}
        flush_interval -1
    }
}

# Admin panel — basic auth protected
admin.example.com {
    import security_headers

    basicauth * {
        admin $2a$14$Zkx19XLiW6VYouLHR5NmfOFU0z2GT.VVKf3KCrXxpHPkAXmn9sQHO
    }

    reverse_proxy localhost:9000
}

# Static site with SPA fallback
www.example.com {
    import security_headers

    root * /var/www/frontend/dist

    encode zstd gzip

    @static path *.css *.js *.png *.jpg *.webp *.gif *.svg *.woff2 *.ico
    header @static Cache-Control "public, max-age=31536000, immutable"

    try_files {path} /index.html
    file_server
}

Gotchas and Edge Cases

Port 80 must be reachable for HTTP-01 challenges. If your cloud provider or firewall blocks port 80, use the tls-alpn-01 challenge or a DNS-01 challenge via xcaddy with a DNS plugin. Caddy will keep retrying certificate issuance but your site will be inaccessible until the first certificate is issued.

Let’s Encrypt rate limits. The production ACME server allows 5 duplicate certificates per domain per week. During testing, set acme_ca https://acme-staging-v02.api.letsencrypt.org/directory to use the staging environment (which has much higher limits) and remove it before going live.

The caddy user needs read access to your web roots. The systemd service runs as the caddy system user. Always run sudo chown -R caddy:caddy /var/www/yoursite after creating or copying files.

Flush interval matters for streaming. Server-Sent Events (SSE) and streaming APIs require flush_interval -1 inside the reverse_proxy block. Without it, Caddy buffers the response and clients never receive streamed data.

xcaddy replaces the system binary. When you build a custom Caddy with xcaddy, copy the resulting binary to /usr/bin/caddy and restart the service. The APT-installed binary will be overwritten on the next apt upgrade caddy — pin the package or manage the binary separately.

Troubleshooting

# Validate Caddyfile syntax before reloading
caddy validate --config /etc/caddy/Caddyfile

# Auto-format the Caddyfile
caddy fmt --overwrite /etc/caddy/Caddyfile

# Watch live Caddy logs (includes ACME events)
sudo journalctl -u caddy -f --no-pager

# Check which ports Caddy is listening on
sudo ss -tlnp | grep caddy

# Test TLS certificate and chain
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates

# Verify the backend is reachable from the server
curl -I http://localhost:3000

# Check certificate storage
ls -la /var/lib/caddy/.local/share/caddy/certificates/

Common errors and fixes:

  • dial tcp: connection refused in logs → backend is not running or not bound to the expected port
  • no such host during ACME → DNS A/AAAA record not pointing to this server’s IP
  • certificate obtained not appearing → port 80 is blocked or another process owns it
  • permission denied serving files → run sudo chown -R caddy:caddy /var/www/yourdir

Summary

  • Caddy is a single Go binary with no external dependencies that provides automatic HTTPS via ACME, HTTP/2 and HTTP/3 by default, and a minimal Caddyfile syntax
  • The reverse_proxy directive handles proxying, load balancing, header manipulation, WebSocket support, and health checks in one block
  • Eight load balancing policies cover every scenario from stateless round-robin to cookie-based session affinity
  • Active health checks (health_uri, health_interval) and passive health checks (fail_duration, max_fails) can be combined for robust failure detection
  • On-demand TLS enables per-tenant certificate issuance for SaaS platforms without any pre-configuration
  • The admin API at localhost:2019 allows runtime configuration changes and certificate inspection without restarting
  • Use snippets ((name) {} and import) to share configuration blocks across multiple site definitions