Caddy isn’t just a web server; it’s a reverse proxy that can automatically manage TLS certificates for you, which is why you might want to put it behind Nginx.

Here’s how Caddy handles a request for example.com when it’s configured as a reverse proxy:

Client --(HTTPS)--> Nginx --(HTTP/HTTPS)--> Caddy --(HTTP)--> Backend Service
  1. Client to Nginx: The client initiates an HTTPS connection to Nginx. Nginx, typically already configured with a TLS certificate for example.com, terminates this TLS connection.
  2. Nginx to Caddy: Nginx then forwards the request to Caddy. This can be over HTTP or HTTPS, depending on your Nginx and Caddy configurations. If it’s HTTPS, Nginx needs to trust Caddy’s certificate.
  3. Caddy to Backend: Caddy receives the request and, based on its Caddyfile configuration, decides where to send it. It forwards the request (usually over HTTP, but can be HTTPS) to the actual backend service (e.g., a Node.js app, a Python app, another web server). Caddy can also apply its automatic HTTPS features to the backend if configured.

This setup allows Nginx to handle initial TLS termination, load balancing, and static file serving, while Caddy can manage dynamic TLS for internal services or provide automatic HTTPS for services that don’t have their own certificates.

Let’s dive into a practical configuration. Suppose you have a backend application running on localhost:8080 and you want to expose it via app.example.com through Nginx and Caddy.

Nginx Configuration (/etc/nginx/sites-available/app.example.com)

server {
    listen 80;
    server_name app.example.com;

    location / {
        proxy_pass http://localhost:9000; # Nginx forwards to Caddy on port 9000
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

server {
    listen 443 ssl http2;
    server_name app.example.com;

    ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384';
    ssl_prefer_server_ciphers off;

    location / {
        proxy_pass http://localhost:9000; # Nginx forwards to Caddy on port 9000
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Caddy Configuration (Caddyfile)

Let’s assume Caddy is running on localhost:9000 and needs to proxy to your actual backend on localhost:8080.

:9000 {
    reverse_proxy localhost:8080
}

This Caddyfile tells Caddy to listen on port 9000. For any incoming request, it will reverse proxy it to localhost:8080.

How it works:

  • Nginx: Acts as the public-facing entry point. It handles the external TLS termination for app.example.com using certificates managed by Let’s Encrypt. It then forwards the request to Caddy. The proxy_set_header directives are crucial for passing important request information like the original host, client IP, and protocol to Caddy and subsequently to your backend.
  • Caddy: Listens on localhost:9000. It receives the request from Nginx. The reverse_proxy localhost:8080 directive tells Caddy to forward this request to your backend application. Caddy can also manage its own internal TLS if needed for the backend service, or it can simply relay HTTP traffic.

The surprising truth about Caddy’s automatic HTTPS: When Caddy acts as a reverse proxy behind another server like Nginx, it can still obtain and manage certificates for its own internal communication with backend services if you configure it to do so. This is often overlooked, as people assume automatic HTTPS is only for public-facing servers. Caddy can provision certificates for localhost or internal domain names if it’s the one establishing the TLS connection to the backend.

Example of Caddy managing internal TLS:

If your backend application also listens on HTTPS (e.g., localhost:8443) and you want Caddy to establish a secure connection to it:

:9000 {
    reverse_proxy localhost:8443 {
        tls_internal
    }
}

Here, tls_internal tells Caddy to use its internal CA to provision a certificate for localhost and establish an HTTPS connection to localhost:8443. Nginx would then need to trust Caddy’s internal CA.

This layered approach offers flexibility. Nginx handles the heavy lifting of public TLS and network edge concerns, while Caddy can manage TLS for internal services or provide advanced proxying features without exposing those internal services directly to the internet.

The next step you’ll likely encounter is configuring health checks for your backend services within Caddy’s reverse_proxy directive.

Want structured learning?

Take the full Caddy course →