Wolkra

Set up Caddy as a reverse proxy

Updated

A reverse proxy receives all web traffic on ports 80 and 443 and forwards each request to the right app on the server. Caddy is a good choice because it obtains and renews HTTPS certificates by itself: one line such as reverse_proxy localhost:3000 per site is enough.

This guide installs Caddy on Ubuntu 24.04 or Debian 12 and builds up the configuration step by step.

Why put a proxy in front

  • One entry point. Several apps on one server, each on its own domain or path.
  • HTTPS in one place. Apps speak plain HTTP on localhost; Caddy handles TLS.
  • Security. Apps never listen on public ports. You can add headers, rate limits or a password in the proxy.
  • Flexibility. Move an app to another port or server and change one line.

1. Prerequisites

  • A server with the first steps done.
  • DNS records for each domain pointing to the server (A for IPv4, AAAA for IPv6).
  • Ports 80 and 443 open:
sudo ufw allow 80,443/tcp
sudo ufw allow 443/udp     # HTTP/3

Open the same ports in your cloud firewall if you use one.

2. Install Caddy

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
systemctl status caddy

The package runs Caddy as a systemd service with the config at /etc/caddy/Caddyfile.

3. The smallest useful Caddyfile

Say an app listens on localhost:3000. Replace the file with:

app.example.com {
    reverse_proxy localhost:3000
}

Validate and reload:

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

On the first request Caddy gets a certificate, redirects HTTP to HTTPS and serves the app. That's it.

4. Several apps on one server

Add a block per site:

app.example.com {
    reverse_proxy localhost:3000
}

api.example.com {
    reverse_proxy localhost:8080
}

status.example.com {
    reverse_proxy localhost:3001
}

Or split one domain by path:

example.com {
    handle_path /api/* {
        reverse_proxy localhost:8080
    }
    handle {
        root * /var/www/site
        file_server
    }
}

handle_path strips /api before forwarding; use handle if the backend expects the prefix.

5. Useful defaults with a snippet

Snippets keep repeated settings in one place:

(common) {
    encode zstd gzip
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains"
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
        -Server
    }
    log {
        output file /var/log/caddy/access.log {
            roll_size 50MiB
            roll_keep 5
        }
    }
}

app.example.com {
    import common
    reverse_proxy localhost:3000
}

Create the log directory once: sudo mkdir -p /var/log/caddy && sudo chown caddy:caddy /var/log/caddy.

6. Protect an admin tool with a password

Generate a password hash:

caddy hash-password

Use it in the site block:

admin.example.com {
    basic_auth {
        alice <paste-hash-here>
    }
    reverse_proxy localhost:9000
}

Older Caddy versions spell the directive basicauth. For anything sensitive, also restrict by address:

admin.example.com {
    @outside not remote_ip 198.51.100.0/24
    respond @outside 403
    reverse_proxy localhost:9000
}

The range is an example. Put your office or VPN range there; with the WireGuard guide that could be your VPN subnet.

7. Proxy to Docker containers

If Caddy runs on the host, bind containers to localhost so they are not public:

services:
  app:
    image: ghcr.io/example/app:1.0
    ports:
      - "127.0.0.1:3000:3000"

If Caddy runs in Docker, put it in the same Compose project and proxy to service names (reverse_proxy app:3000). The Docker Compose guide shows a full stack.

8. Load balance across several backends

Caddy can spread requests over several app instances, on the same server or on other servers over a private network:

app.example.com {
    reverse_proxy 10.0.0.11:3000 10.0.0.12:3000 {
        lb_policy least_conn
        health_uri /healthz
        health_interval 10s
    }
}

The addresses are examples on a private range. When you want the balancing outside your servers, a managed load balancer does the same job with health checks and can reach servers that have no public IP.

9. WebSockets and real client IPs

WebSockets work through reverse_proxy without extra settings. Caddy sends X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host to the backend; configure your app to trust them from 127.0.0.1 only.

If Caddy itself sits behind another proxy or load balancer, tell it which addresses to trust:

{
    servers {
        trusted_proxies static 10.0.0.0/24
    }
}

This global block goes at the very top of the Caddyfile.

10. Logs and troubleshooting

journalctl -u caddy --since "10 min ago"
sudo tail -f /var/log/caddy/access.log
curl -I https://app.example.com

Common problems:

Problem Fix
Certificate not issued DNS must point to this server; ports 80/443 open in UFW and cloud firewall
502 Bad Gateway The backend is not running or listens on another port: ss -tlnp
Redirect loop The app also redirects to HTTPS; let Caddy do it and trust X-Forwarded-Proto
Changes ignored Run systemctl reload caddy and check caddy validate output

Certificate issuers rate limit repeated failures, so fix DNS before you retry many times.

Redirects and canonical domains

Most sites should answer on one name and redirect the others. Send www to the bare domain:

www.example.com {
    redir https://example.com{uri} permanent
}

example.com {
    reverse_proxy localhost:3000
}

Caddy gets certificates for both names. Swap them if you prefer www as the main address.

Rate limiting and request size

Caddy keeps its core small, so rate limiting needs a plugin. For most small sites, Fail2ban on the access log or a limit in the application is enough. Limiting the request body size is built in:

app.example.com {
    request_body {
        max_size 20MB
    }
    reverse_proxy localhost:3000
}

That stops oversized uploads before they reach your app.

Static files next to an app

Serving assets straight from disk is faster than passing them through the app:

app.example.com {
    handle /static/* {
        root * /var/www/app
        file_server
        header Cache-Control "public, max-age=604800"
    }
    handle {
        reverse_proxy localhost:3000
    }
}

11. Where certificates live

Caddy stores certificates and keys under /var/lib/caddy/.local/share/caddy. Include that path in backups to avoid re-issuing everything after a restore, though Caddy will simply obtain new certificates if it is missing.

Next steps

With Caddy in front you can host many apps on one server. Typical next steps are WordPress, a Docker stack, or a self-hosted tool from self-hosting. Don't forget the security basics and backups.

Frequently asked questions

Does Caddy really get HTTPS certificates automatically?

Yes. When a site address in the Caddyfile is a public domain that points to the server, and ports 80 and 443 are reachable, Caddy obtains and renews certificates from a public ACME authority on its own.

Is Caddy fast enough for production?

Yes. For typical web apps the reverse proxy is rarely the bottleneck, and Caddy supports HTTP/2 and HTTP/3 out of the box.

Can Caddy proxy to apps in Docker?

Yes. Run Caddy in the same Compose network and use service names, or bind containers to 127.0.0.1 and proxy to localhost ports.

Caddy or Nginx?

Caddy needs less configuration and handles certificates itself. Nginx has a larger ecosystem of examples and modules. Both are solid choices.

Why does Caddy fail to get a certificate?

Usually DNS does not point to the server yet, or port 80 or 443 is blocked by a firewall. Check both, then look at journalctl -u caddy.