Desert Forge IT — Game Hosting from $2/weekGet a Server →

How to Set Up Caddy as a Reverse Proxy with Automatic HTTPS

Beginner 20 min — Serve and proxy your web services with automatic TLS certificates and zero hassle.

1. What is Caddy?

Caddy is a modern, open-source web server written in Go. It stands out from traditional servers like Nginx and Apache for three key reasons:

  • Automatic HTTPS: Caddy obtains and renews TLS certificates from Let's Encrypt (or ZeroSSL) out of the box. No certbot cron jobs, no manual renewal — it just works.
  • Simple configuration: The Caddyfile format is human-readable and dramatically shorter than Nginx or Apache config files. A working reverse proxy can be defined in two lines.
  • Single binary: Caddy ships as a single, statically-linked binary with no dependencies. Download it, run it, done. No modules to compile, no library conflicts.

Caddy v2 is production-ready and widely used for self-hosted dashboards, game panels, API gateways, and static sites. If you are tired of fighting with Nginx config syntax or renewing certificates by hand, Caddy is worth a serious look.

2. Install Caddy

On Debian or Ubuntu, the recommended installation method is via the official apt repository. This ensures you get automatic updates through your package manager.

sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl

Add the Caddy GPG key and repository:

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

Install Caddy:

sudo apt update
sudo apt install caddy

Verify the installation:

caddy version

The package automatically creates a caddy systemd service, a caddy user, and the default config directory at /etc/caddy/. Caddy is now running and serving a placeholder page on port 80.

3. Basic Caddyfile

The Caddyfile lives at /etc/caddy/Caddyfile. Open it in your editor:

sudo nano /etc/caddy/Caddyfile

Replace the contents with a simple static file server:

example.com {
    root * /var/www/mysite
    file_server
}

Here is how Caddyfile syntax works:

  • The first token (example.com) is the site address. When it is a domain name, Caddy automatically enables HTTPS.
  • Curly braces { } wrap the site block, which contains directives for that site.
  • root * /var/www/mysite sets the document root. The * matches all request paths.
  • file_server enables the static file server, which serves files from the root directory.

Apply the new config by reloading Caddy:

sudo systemctl reload caddy

You can also validate your Caddyfile before reloading:

caddy validate --config /etc/caddy/Caddyfile

4. Reverse Proxy a Service

The most common use case for Caddy is proxying traffic to an application running on localhost. For example, if you have a Node.js app, a game panel, or Grafana listening on port 3000:

example.com {
    reverse_proxy localhost:3000
}

That is the entire configuration. Caddy will:

  1. Obtain a TLS certificate for example.com automatically
  2. Listen on ports 80 and 443
  3. Redirect HTTP to HTTPS
  4. Forward all requests to localhost:3000
  5. Pass standard proxy headers (X-Forwarded-For, X-Forwarded-Proto, etc.)

If your backend uses WebSockets (common for game panels and real-time apps), Caddy handles the upgrade automatically — no extra config needed.

Reload to apply:

sudo systemctl reload caddy

5. Multiple Services

To proxy multiple subdomains to different backend services, add one site block per subdomain:

app.example.com {
    reverse_proxy localhost:3000
}

api.example.com {
    reverse_proxy localhost:8080
}

grafana.example.com {
    reverse_proxy localhost:3100
}

Each subdomain gets its own TLS certificate automatically. Make sure each subdomain has a DNS A record pointing to your server's public IP.

You can also route by path instead of subdomain if you prefer a single domain:

example.com {
    handle /api/* {
        reverse_proxy localhost:8080
    }

    handle {
        reverse_proxy localhost:3000
    }
}

The handle directive creates mutually exclusive route groups. The first matching block handles the request. The final handle without a matcher acts as the fallback.

6. Automatic HTTPS

Caddy uses the ACME protocol to obtain free TLS certificates from Let's Encrypt. Here is what happens behind the scenes:

  1. When Caddy sees a domain name in your Caddyfile, it requests a certificate from Let's Encrypt.
  2. Let's Encrypt issues an HTTP-01 challenge: it asks Caddy to serve a specific token on port 80.
  3. Caddy responds to the challenge automatically, proving you control the domain.
  4. The certificate is issued, stored in /var/lib/caddy/.local/share/caddy/, and renewed automatically before expiry (certificates last 90 days, Caddy renews at roughly 30 days remaining).

For this to work, you need:

  • DNS A record: Your domain (or subdomain) must point to your server's public IP address.
  • Port 80 open: The ACME HTTP-01 challenge requires port 80 to be reachable from the internet. If another service is using port 80, stop it or reconfigure it.
  • Port 443 open: Caddy serves HTTPS on port 443.

If you are behind a firewall or NAT, forward both ports 80 and 443 TCP to your server.

For local development or internal services where you do not need a public certificate, use localhost or an IP address as the site address. Caddy will generate a self-signed certificate instead:

localhost {
    reverse_proxy localhost:3000
}

To use the DNS-01 challenge instead (useful when port 80 is blocked), you need a Caddy build with a DNS provider plugin. For example, with the Cloudflare module:

example.com {
    tls {
        dns cloudflare {env.CF_API_TOKEN}
    }
    reverse_proxy localhost:3000
}

7. Security Headers

Use the header directive to add security-related HTTP headers. These protect your site from common attacks:

example.com {
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        X-Content-Type-Options "nosniff"
        X-Frame-Options "DENY"
        Content-Security-Policy "default-src 'self'"
        Referrer-Policy "strict-origin-when-cross-origin"
        Permissions-Policy "geolocation=(), microphone=(), camera=()"
        -Server
    }

    reverse_proxy localhost:3000
}

What each header does:

  • Strict-Transport-Security (HSTS): Tells browsers to always use HTTPS for this domain. The max-age is in seconds (31536000 = 1 year).
  • X-Content-Type-Options: Prevents browsers from MIME-sniffing a response away from the declared content type.
  • X-Frame-Options: Blocks the page from being loaded in an iframe, preventing clickjacking. Use SAMEORIGIN instead of DENY if your app uses iframes internally.
  • Content-Security-Policy: Controls which sources the browser is allowed to load resources from. default-src 'self' restricts everything to same-origin; adjust as needed for your app.
  • Referrer-Policy: Controls how much referrer information is sent with requests.
  • Permissions-Policy: Disables browser features your site does not use.
  • -Server: The minus prefix removes the Server header from responses, hiding the server software identity.

8. Compression

Enable response compression with the encode directive. Caddy supports gzip and Zstandard (zstd):

example.com {
    encode zstd gzip
    reverse_proxy localhost:3000
}

The order matters: list zstd first because it is a more efficient algorithm. Caddy will negotiate with the client's Accept-Encoding header and use the best available option. Modern browsers support gzip universally; zstd support is growing.

Compression is applied to text-based content types (HTML, CSS, JavaScript, JSON, etc.) automatically. Binary files like images and videos are skipped since they are already compressed.

9. Snippets

When multiple site blocks share the same configuration (like security headers and compression), use snippets to avoid repeating yourself. Define a snippet with parentheses at the top of your Caddyfile, then import it:

(common) {
    encode zstd gzip
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        X-Content-Type-Options "nosniff"
        X-Frame-Options "DENY"
        -Server
    }
}

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

api.example.com {
    import common
    reverse_proxy localhost:8080
}

grafana.example.com {
    import common
    reverse_proxy localhost:3100
}

Snippets keep your Caddyfile DRY and maintainable. You can define as many snippets as you need, and they can contain any valid Caddyfile directives.

The import directive can also load external files, which is useful for splitting large configurations:

import /etc/caddy/sites/*.caddy

This imports every file matching the glob pattern, letting you keep one file per site.

10. Troubleshooting

  • Certificate not issued (DNS not propagated): After creating a DNS A record, it can take up to 48 hours to propagate globally, though most providers update within minutes. Verify with dig +short example.com. The returned IP must match your server. Caddy retries certificate issuance automatically, so once DNS resolves correctly, the certificate will be obtained.
  • Certificate not issued (port 80 in use): The ACME HTTP-01 challenge requires port 80. Check what is using it with sudo ss -tlnp | grep :80. Stop any conflicting service (Apache, Nginx, etc.) or switch to DNS-01 challenge.
  • 502 Bad Gateway: This means Caddy reached the backend but got no valid response. Verify your backend service is actually running and listening on the expected port: sudo ss -tlnp | grep :3000. Also check that the backend is bound to 127.0.0.1 or 0.0.0.0, not a different interface.
  • Config syntax error: Always validate before reloading: caddy validate --config /etc/caddy/Caddyfile. Common mistakes include missing closing braces, tabs instead of spaces in certain contexts, and using Caddy v1 syntax (like proxy instead of reverse_proxy).
  • Caddy won't bind to port 80/443: On Linux, non-root processes cannot bind to ports below 1024. The apt package handles this automatically by granting cap_net_bind_service. If you installed Caddy manually, run: sudo setcap cap_net_bind_service=+ep $(which caddy).
  • Checking logs: Caddy logs to journald when running as a systemd service. View live logs with journalctl -u caddy -f. For more verbose output, add a global debug option at the top of your Caddyfile:
{
    debug
}

example.com {
    reverse_proxy localhost:3000
}

← Back to all guides