LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Caddy as a Homelab Reverse Proxy

caddyreverse-proxyhomelabhttpstlsnetworkingself-hosted

Every homelab eventually reaches the same inflection point. You have five services running on five different ports — Proxmox on 8006, Grafana on 3000, Immich on 2283, Forgejo on 3001, Home Assistant on 8123 — and you are tired of remembering which port maps to what. You want real domain names. You want HTTPS. You do not want to think about certificate management ever again.

If you have been doing this for a while, your first instinct is probably Nginx. Nginx is battle-tested, fast, and well-documented. The problem is that Nginx has no concept of TLS certificate automation. You configure Nginx to serve HTTPS, then you separately run Certbot to get the certificate, then you write a renewal cron job, then you reload Nginx after renewal. Four separate systems, four places for things to break. Every six months your certs expire and you spend forty minutes debugging which piece of the chain failed.

Traefik solves some of this in Docker environments but adds its own complexity — label hell, the dashboard is opaque when routes are not loading, and the static versus dynamic configuration split trips up everyone at least once.

Caddy takes a different approach entirely. The philosophy is that HTTPS should be the default, not an option you configure. You give Caddy a domain name, and it handles everything: ACME account registration, certificate issuance from Let’s Encrypt or ZeroSSL, automatic renewal, OCSP stapling, and HSTS headers. The Caddyfile for a basic HTTPS reverse proxy is three lines. Not three hundred lines — three.

This guide covers Caddy in depth for homelab use: installation, the Caddyfile syntax and JSON API, automatic certificate management including DNS-01 challenges for internal services, reverse proxying with health checks, static file serving, rate limiting, and an honest comparison with Nginx and Traefik including when Caddy is not the right tool.

Everything here applies to Caddy v2, which is the current series (the latest stable release at time of writing is 2.11.3). Caddy v1 reached end-of-life years ago and the config model is completely different — if you find v1 tutorials, ignore them.


Why Caddy Exists and What Makes It Different

The traditional approach to HTTPS involves assembling a toolchain. Nginx serves your traffic. Certbot (or acme.sh) handles certificate issuance. A cron job or systemd timer handles renewal. Post-renewal hooks reload Nginx. Each piece works but none of them know about each other. When Certbot changes where it stores certs, your Nginx config breaks. When your DNS propagation is slow during renewal, the HTTP-01 challenge fails silently. When you add a new virtualhost, you have to register it in three places.

Caddy was written to make this entire toolchain unnecessary. Matt Holt started the project in 2015 with a simple observation: the configuration you write for a web server almost always implies what certificates you need. If you tell Caddy to serve photos.example.com, it can figure out on its own that it needs a certificate for photos.example.com. There is no reason to make this a separate step.

Caddy is written in Go and ships as a single statically-linked binary with no external dependencies. No OpenSSL version conflicts, no module path confusion, no shared library hell. You copy the binary, point it at a Caddyfile, and it works. The binary includes its own ACME client (based on the certmagic library, which Holt also wrote), its own certificate storage layer, its own OCSP stapling implementation, and its own local CA for development use.

The v2 rewrite, released in 2020, replaced the v1 config model completely. v2 has two configuration formats: the Caddyfile, which is a human-readable domain-specific language, and a JSON format that is Caddy’s actual native config. The Caddyfile is compiled to JSON internally — when you run caddy adapt --config Caddyfile, you see exactly what JSON Caddy is running. This dual-format design means you can use the simple Caddyfile for 95% of cases and drop down to JSON for complex programmatic configuration when needed.

Recent releases have pushed Caddy further into privacy-forward TLS territory. Caddy 2.10 added fully-automated Encrypted ClientHello (ECH), which encrypts the SNI field in TLS handshakes — the last plaintext piece that reveals which domain a client is connecting to. It also added post-quantum key exchange via x25519mlkem768 as a default, and support for ACME profiles including 6-day certificate lifetimes from Let’s Encrypt. Caddy 2.11 added automatic ECH key rotation, time-rolling log options, and improved reverse proxy behavior around Host header rewriting for HTTPS upstreams. These are not features you typically see in a web server’s release notes — they reflect a project that treats TLS as its primary concern, not an afterthought.


Installation

The Caddy project maintains its own package repository for Debian/Ubuntu, which is the preferred installation method because it sets up a proper systemd service, creates the caddy system user, and establishes the correct directory layout.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Install prerequisites
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl

# Add the Caddy GPG key and repo
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 caddy

After installation, the directory layout is:

/etc/caddy/Caddyfile              # Your configuration file
/var/lib/caddy/                   # Caddy's working directory (owned by caddy user)
/var/lib/caddy/.local/share/caddy/certificates/  # Certificate storage
/var/log/caddy/                   # Log output
/usr/bin/caddy                    # The binary

The caddy user owns /var/lib/caddy/ and Caddy runs as that user under systemd. This matters for certificate storage permissions and for binding to ports 80 and 443 — the systemd service grants the binary the CAP_NET_BIND_SERVICE capability so it can bind privileged ports without running as root.

Docker

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
docker run -d \
  --name caddy \
  --restart unless-stopped \
  -p 80:80 \
  -p 443:443 \
  -p 443:443/udp \
  -v caddy_data:/data \
  -v caddy_config:/config \
  -v $PWD/Caddyfile:/etc/caddy/Caddyfile \
  caddy:latest

The UDP port 443 binding is for HTTP/3 (QUIC), which Caddy enables by default when serving HTTPS.

Building with Plugins (xcaddy)

The standard Caddy binary does not include DNS provider modules or the rate limiting module. If you need DNS-01 certificate challenges (required for internal services and wildcard certs), you must build a custom binary with the appropriate DNS provider plugin compiled in.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Install xcaddy
go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest

# Build Caddy with the Cloudflare DNS module
xcaddy build \
  --with github.com/caddy-dns/cloudflare

# Build with multiple plugins
xcaddy build \
  --with github.com/caddy-dns/cloudflare \
  --with github.com/mholt/caddy-ratelimit

# Verify the build includes your modules
./caddy list-modules | grep dns

Alternatively, the Caddy download page at caddyserver.com/download lets you select plugins and download a pre-built binary without needing a Go toolchain.

An important note for DNS plugin users: Caddy 2.10 upgraded to libdns 1.0, which broke compatibility with DNS provider modules that had not updated. If you are running Caddy 2.10 or later, ensure your DNS provider module is version 1.6 or newer (for the Cloudflare module: github.com/caddy-dns/cloudflare@v1.6.0 or later).

Managing the Caddy Process

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Start/stop/restart via systemd
systemctl start caddy
systemctl stop caddy
systemctl restart caddy

# Graceful reload — reloads config with zero downtime, no dropped connections
systemctl reload caddy
# or equivalently:
caddy reload --config /etc/caddy/Caddyfile

# Direct process management (for non-systemd installs)
caddy run --config /etc/caddy/Caddyfile         # Foreground, blocks
caddy start --config /etc/caddy/Caddyfile       # Background daemon
caddy stop                                       # Stop background daemon

caddy reload is what you want in production. It sends the new configuration to the running process, which validates it and swaps it in atomically. No connections are dropped, no certificates need to be re-read from disk, no listeners bounce. This is the same mechanism the JSON admin API uses under the hood.


The Caddyfile — Syntax and Structure

The Caddyfile is designed to be readable by humans who are not configuration language experts. A minimal example:

1
2
3
photos.example.com {
    reverse_proxy localhost:2283
}

That is a complete, production-ready HTTPS reverse proxy configuration. Caddy will obtain a Let’s Encrypt certificate for photos.example.com automatically, redirect HTTP to HTTPS, and proxy all traffic to your Immich instance on port 2283.

Global Options Block

The global options block, if present, must come first. It controls Caddy-wide behavior.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
    # Email for ACME account registration and expiry notices
    email admin@example.com

    # Default ACME certificate authority
    # Let's Encrypt production (default):
    # acme_ca https://acme-v02.api.letsencrypt.org/directory
    # ZeroSSL:
    # acme_ca https://acme.zerossl.com/v2/DV90

    # Admin API endpoint (default: localhost:2019)
    # Restrict to localhost only in production
    admin localhost:2019

    # Caddy's logging configuration
    log {
        output file /var/log/caddy/access.log {
            roll_size 100mb
            roll_keep 5
        }
        format json
    }
}

Site Addresses

Caddy interprets the site address to decide what TLS mode to use:

Address format TLS behavior
example.com Automatic HTTPS via ACME (Let’s Encrypt/ZeroSSL)
*.example.com Wildcard cert via ACME DNS-01 challenge
http://example.com No TLS, HTTP only
https://example.com TLS required, ACME or manual cert
localhost Internal CA cert via tls internal
:8080 HTTP on port 8080, no TLS
192.168.1.10 No automatic TLS for bare IP addresses

Snippets and Imports

Snippets let you define reusable blocks of directives, which is essential for avoiding copy-paste across multiple site blocks:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# Define a snippet for shared security headers
(security_headers) {
    header {
        X-Frame-Options DENY
        X-Content-Type-Options nosniff
        Referrer-Policy strict-origin-when-cross-origin
        Permissions-Policy "camera=(), microphone=(), geolocation=()"
        -Server
    }
}

# Use it in any site block
grafana.example.com {
    import security_headers
    reverse_proxy localhost:3000
}

Named Matchers

Named matchers allow conditional request handling. The @name syntax defines a matcher that can be reused:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
example.com {
    # Match only LAN traffic
    @internal remote_ip 192.168.0.0/16 10.0.0.0/8

    # Match a specific path prefix
    @api path /api/*

    # Match by header
    @websocket header Connection *Upgrade*

    # Handle each matcher
    handle @internal {
        reverse_proxy localhost:8080
    }
    handle @api {
        reverse_proxy localhost:3000
    }
    respond "Access denied" 403
}

Environment Variable Substitution

Sensitive values like API tokens should never live directly in the Caddyfile. Caddy supports environment variable substitution with {env.VAR_NAME}:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{
    email {env.ACME_EMAIL}
}

*.home.example.com {
    tls {
        dns cloudflare {env.CLOUDFLARE_API_TOKEN}
    }
    # ...
}

When running under systemd, inject environment variables via an override file:

1
sudo systemctl edit caddy
1
2
3
[Service]
Environment="CLOUDFLARE_API_TOKEN=your-token-here"
Environment="ACME_EMAIL=admin@example.com"

A Complete Annotated Multi-Service Caddyfile

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
{
    email admin@home.example.com

    # Log all access in JSON format for log aggregation
    log {
        output file /var/log/caddy/access.log
        format json
    }
}

# Snippet: security headers applied to all internal services
(security_headers) {
    header {
        X-Frame-Options DENY
        X-Content-Type-Options nosniff
        X-XSS-Protection "1; mode=block"
        Referrer-Policy strict-origin-when-cross-origin
        Permissions-Policy "camera=(), microphone=(), geolocation=()"
        -Server
    }
}

# Snippet: restrict access to LAN only
(lan_only) {
    @not_lan not remote_ip 192.168.0.0/16 10.0.0.0/8 172.16.0.0/12
    abort @not_lan
}

# Proxmox VE
proxmox.home.example.com {
    import security_headers
    import lan_only
    reverse_proxy https://192.168.1.10:8006 {
        transport http {
            tls_insecure_skip_verify  # Proxmox uses a self-signed cert
        }
    }
}

# Grafana
grafana.home.example.com {
    import security_headers
    import lan_only
    reverse_proxy localhost:3000
}

# Immich — large file uploads need body size adjustment
photos.home.example.com {
    import security_headers
    import lan_only
    request_body {
        max_size 10GB
    }
    reverse_proxy localhost:2283
}

# Forgejo — Git over HTTPS, SSH handled separately
git.home.example.com {
    import security_headers
    import lan_only
    reverse_proxy localhost:3001
}

Automatic HTTPS — How It Works

Caddy’s certificate automation is built on the ACME protocol (RFC 8555). When Caddy starts and sees a hostname in your configuration, it checks whether a valid certificate exists in its storage. If not, it initiates an ACME order with Let’s Encrypt or ZeroSSL.

The Three ACME Challenge Types

HTTP-01: The most common challenge. Caddy creates a token file and serves it at http://<domain>/.well-known/acme-challenge/<token>. The CA fetches that URL from the public internet to verify you control the domain. Requirements: port 80 must be open and reachable from the internet. This is the default when port 80 is accessible.

TLS-ALPN-01: Caddy answers a special TLS handshake on port 443 using the ALPN extension. Requires port 443 reachable from the internet. Useful when port 80 is blocked.

DNS-01: Caddy creates a _acme-challenge.your-domain TXT record via your DNS provider’s API. The CA validates by querying DNS rather than making an HTTP request. No port exposure required, works for private/internal domains, and is the only way to obtain wildcard certificates.

Let’s Encrypt vs ZeroSSL

Caddy registers ACME accounts with both Let’s Encrypt and ZeroSSL by default and rotates between them. If Let’s Encrypt is experiencing issues or you hit rate limits, Caddy falls back to ZeroSSL automatically. Both CAs are free, both issue 90-day DV certificates (with ACME profiles now supporting 6-day certs), and both are trusted by all major browsers.

Let’s Encrypt rate limits: 50 certificates per registered domain per week. For a homelab with many subdomains, wildcard certificates (which count as one certificate regardless of how many subdomains you use) are strongly preferred.

Certificate Storage

Certificates are stored under /var/lib/caddy/.local/share/caddy/certificates/ when running as the caddy system user. The layout is:

certificates/
  acme-v02.api.letsencrypt.org-directory/
    home.example.com/
      home.example.com.crt
      home.example.com.key
      home.example.com.json    # ACME order metadata
  acme.zerossl.com-v2-DV90/
    ...

Caddy renews certificates automatically when they reach 30 days remaining. You do not need a cron job. You do not need to reload Caddy after renewal — it detects new certificates in storage and starts using them without any restart.

Internal CA for Development

For local development or for services that never need a CA-trusted certificate:

1
2
3
4
https://localhost {
    tls internal
    reverse_proxy localhost:8080
}

tls internal tells Caddy to issue a certificate from its own built-in CA. The cert is not publicly trusted, but you can install Caddy’s local CA into your system trust store:

1
caddy trust

This installs the CA certificate into your OS certificate store (and into Firefox’s NSS store on Linux), after which browsers on that machine will trust Caddy’s locally-issued certificates without warnings.


DNS Challenge for Internal Homelab Services

This is where Caddy becomes genuinely powerful for homelabs. The scenario: you have a NAS, a Proxmox cluster, and a media server running on your LAN. You want real, CA-trusted TLS certificates for these services — not self-signed certs, not browser warnings — but these hosts are not accessible from the public internet. The HTTP-01 and TLS-ALPN-01 challenges cannot reach them. DNS-01 is the only option.

The DNS-01 challenge does not require your services to be reachable at all. It only requires that Caddy can call your DNS provider’s API to create a TXT record. The CA validates by querying public DNS for that record. Your internal services are never contacted.

How the DNS-01 Flow Works

  Caddy                     DNS Provider API            Let's Encrypt
    |                             |                           |
    |-- ACME order request ---------------------------------> |
    |                             |                           |
    |                         CA responds: add TXT record     |
    | <------------------------------------------------------- |
    |                             |                           |
    |-- Set TXT record ---------> |                           |
    |   _acme-challenge.          |                           |
    |   home.example.com          |                           |
    |                             |                           |
    |-- Notify CA: ready ---------------------------------->  |
    |                             |                           |
    |                         CA queries DNS for TXT record   |
    |                             | <------------------------ |
    |                             |                           |
    |                         CA validates TXT matches        |
    |                             |                           |
    |                         Certificate issued              |
    | <------------------------------------------------------- |
    |                             |                           |
    |-- Delete TXT record ------> |                           |

DNS Provider Modules

Caddy does not include DNS provider modules in the standard build. The caddy-dns GitHub organization maintains modules for most major DNS providers. These must be compiled in with xcaddy or obtained via the download page.

Provider Module path
Cloudflare github.com/caddy-dns/cloudflare
Route53 (AWS) github.com/caddy-dns/route53
Porkbun github.com/caddy-dns/porkbun
Namecheap github.com/caddy-dns/namecheap
Gandi github.com/caddy-dns/gandi
Hetzner github.com/caddy-dns/hetzner
deSEC github.com/caddy-dns/desec
DYNDNS/generic github.com/caddy-dns/ddnss

Remember: if you are running Caddy 2.10 or later, install provider modules version 1.6 or later to ensure libdns 1.0 compatibility.

Configuring Cloudflare DNS-01

First, create a Cloudflare API token with minimal permissions. In the Cloudflare dashboard: My Profile > API Tokens > Create Token. Use the “Edit zone DNS” template and scope it to only the specific zone you need. The token needs Zone:DNS:Edit — do not create a global API key.

1
2
# Build Caddy with the Cloudflare module
xcaddy build --with github.com/caddy-dns/cloudflare@v1.6.0
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
{
    email admin@example.com
}

# Wildcard certificate covering all *.home.example.com subdomains
*.home.example.com {
    tls {
        dns cloudflare {env.CLOUDFLARE_API_TOKEN}
    }

    # Route traffic based on subdomain using SNI matching
    @proxmox host proxmox.home.example.com
    @grafana host grafana.home.example.com
    @photos  host photos.home.example.com

    handle @proxmox {
        reverse_proxy https://192.168.1.10:8006 {
            transport http { tls_insecure_skip_verify }
        }
    }
    handle @grafana {
        reverse_proxy localhost:3000
    }
    handle @photos {
        reverse_proxy localhost:2283
    }
}

The *.home.example.com wildcard certificate covers every subdomain with a single certificate. One issuance, one renewal, one cert entry in Let’s Encrypt’s rate limit accounting.

Split-Horizon DNS

For internal services, you typically want split-horizon DNS: internal DNS resolves *.home.example.com to your LAN IP (e.g., 192.168.1.5 where Caddy runs), while external DNS either does not resolve the record or points nowhere. This means:

  • Services are only reachable from inside your LAN
  • The Let’s Encrypt certificate is still publicly trusted
  • No VPN required for internal access
  • The certificate is obtained via DNS-01 (no public HTTP access needed)

Configure this with your local DNS resolver (Pi-hole, AdGuard Home, Unbound, or your router’s DNS). Add an A record for *.home.example.com pointing to your Caddy host’s LAN IP. Clients on your LAN resolve the name internally; clients outside your LAN get no resolution at all.


Reverse Proxy Configuration

Basic Proxying

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# Simplest case — proxy all traffic to a local port
grafana.example.com {
    reverse_proxy localhost:3000
}

# Proxy to a remote host with explicit scheme
app.example.com {
    reverse_proxy http://192.168.1.20:8080
}

# HTTPS upstream with self-signed cert (common for Proxmox, pfSense, etc.)
proxmox.example.com {
    reverse_proxy https://192.168.1.10:8006 {
        transport http {
            tls_insecure_skip_verify
        }
    }
}

Multiple Upstreams and Load Balancing

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
api.example.com {
    reverse_proxy 10.0.0.1:8080 10.0.0.2:8080 10.0.0.3:8080 {
        lb_policy round_robin

        # Other policies:
        # lb_policy random
        # lb_policy least_conn     -- route to the upstream with fewest active connections
        # lb_policy ip_hash        -- same client IP always goes to same upstream (session affinity)
        # lb_policy first          -- always use first healthy upstream (active-passive)
        # lb_policy uri_hash       -- same URI always goes to same upstream (cache friendliness)

        # Active health checks
        health_uri /healthz
        health_interval 10s
        health_timeout 5s
        health_status 200
    }
}

lb_policy round_robin: Default. Good for stateless applications where any backend can handle any request.

lb_policy least_conn: Best for long-lived connections (WebSockets, streaming) where connection count is a meaningful measure of load.

lb_policy ip_hash: For stateful applications that do not use shared session storage — the same client always hits the same backend. Be aware this can cause uneven load if a small number of IPs generate most traffic.

lb_policy first: Active-passive failover. All traffic goes to the first healthy upstream; other upstreams only receive traffic if the first fails.

Health Checks

Caddy supports both passive and active health checking.

Passive health checks (enabled by default): Caddy monitors responses from upstreams. When an upstream returns consecutive errors or times out, it marks it as unhealthy and stops sending traffic. It periodically sends a probe to check if the upstream has recovered.

Active health checks: Caddy proactively sends a request to a configured health endpoint on a timer, independent of actual traffic:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
app.example.com {
    reverse_proxy backend:8080 {
        # Active health check
        health_uri /health
        health_interval 15s
        health_timeout 3s
        health_status 200   # Expected status code

        # Passive health check tuning
        fail_duration 30s            # How long to mark an upstream as failed
        max_fails 3                  # Consecutive failures before marking unhealthy
        unhealthy_request_count 100  # Total requests before checking error rate
        unhealthy_status 500 502 503 # Status codes that count as failures
    }
}

Header Manipulation

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
app.example.com {
    reverse_proxy backend:8080 {
        # Add headers to the upstream request
        header_up X-Forwarded-For {remote_host}
        header_up X-Real-IP {remote_host}
        header_up X-Forwarded-Proto {scheme}

        # Remove headers from the upstream request
        header_up -X-Internal-Token

        # Modify response headers coming back to the client
        header_down -Server
        header_down -X-Powered-By
        header_down X-Frame-Options DENY
    }
}

Trusted Proxies

If Caddy sits behind a CDN or upstream load balancer (Cloudflare, AWS ALB), you need to tell it which IPs to trust for X-Forwarded-For headers. Otherwise Caddy will log the load balancer’s IP as the client IP rather than the real client:

1
2
3
4
5
6
7
app.example.com {
    trusted_proxies static 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16
    # Or for Cloudflare, use the dynamic module:
    # trusted_proxies cloudflare

    reverse_proxy backend:8080
}

Timeouts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
api.example.com {
    reverse_proxy slow-backend:8080 {
        transport http {
            dial_timeout 5s
            response_header_timeout 30s
            read_timeout 60s
            write_timeout 60s
            idle_timeout 120s
            keepalive 30s
        }
    }
}

WebSocket Proxying

Caddy handles WebSocket upgrades automatically. You do not need to set Upgrade, Connection, or proxy_pass headers manually as you would in Nginx. A regular reverse_proxy directive handles WebSocket connections transparently:

1
2
3
4
# Caddy handles the HTTP -> WebSocket upgrade automatically
ha.example.com {
    reverse_proxy localhost:8123
}

This is a genuine quality-of-life improvement over Nginx, where you must explicitly configure the upgrade:

1
2
3
4
5
6
7
# Nginx requires this explicitly
location /api/websocket {
    proxy_pass http://localhost:8123;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
}

gRPC Proxying

For gRPC backends using HTTP/2 cleartext (h2c), prefix the upstream address:

1
2
3
grpc.example.com {
    reverse_proxy h2c://localhost:9090
}

Static File Serving

Caddy is a competent static file server, though this is not where it differentiates itself from Nginx.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# Basic file serving
static.example.com {
    root * /var/www/html
    file_server
}

# Directory browsing
files.example.com {
    root * /srv/files
    file_server browse
}

# Hide sensitive files from directory listings
files.example.com {
    root * /srv/files
    file_server browse {
        hide .git .env .htpasswd
    }
}

Serving a Hugo or SPA Site

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
blog.example.com {
    root * /var/www/blog

    # Enable gzip and zstd compression
    encode gzip zstd

    # Try the exact path first, then index.html for SPAs
    try_files {path} /index.html

    file_server

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

    # Short cache for HTML
    @html {
        path *.html /
    }
    header @html Cache-Control "public, max-age=3600"
}

The try_files {path} /index.html pattern is essential for single-page applications where the client-side router handles URL paths that do not correspond to real files. Without it, a browser refresh on a deep link returns 404.


Rate Limiting and Security Headers

Rate Limiting

This is where you need to be clear-eyed about Caddy’s limitations. The standard Caddy distribution does not include a rate limiting module. Rate limiting requires the caddy-ratelimit plugin by Matt Holt (github.com/mholt/caddy-ratelimit), which you must compile in with xcaddy.

This is a real difference from Nginx, where limit_req_zone is built in and extensively documented. It is also a difference from Traefik, where rate limiting is a first-class middleware. For homelab use it is rarely a blocking issue, but it is worth knowing before you commit to Caddy for a public-facing service where you need rate limiting at the edge.

1
2
3
xcaddy build \
  --with github.com/caddy-dns/cloudflare \
  --with github.com/mholt/caddy-ratelimit
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
api.example.com {
    rate_limit {
        zone api_zone {
            key {remote_host}
            events 100
            window 60s
        }
    }
    reverse_proxy localhost:3000
}

The caddy-ratelimit module uses a sliding window ring buffer per key. Unlike Nginx’s leaky bucket implementation, it does not require you to pre-allocate a shared memory zone — expired rate limiters are garbage-collected automatically. It also supports distributed rate limiting across a Caddy cluster by writing state to a shared storage backend.

Security Headers

Security headers that should be standard on every service:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
(security_headers) {
    header {
        # Prevent clickjacking
        X-Frame-Options DENY

        # Prevent MIME type sniffing
        X-Content-Type-Options nosniff

        # Control referrer information
        Referrer-Policy strict-origin-when-cross-origin

        # Disable unnecessary browser features
        Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()"

        # Remove fingerprinting headers
        -Server
        -X-Powered-By
    }
}

HSTS

Caddy adds Strict-Transport-Security automatically when serving HTTPS, with a default max-age of two years. To customize:

1
2
3
4
example.com {
    header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
    reverse_proxy localhost:8080
}

Be careful with preload. Once a domain is in the HSTS preload list, browsers refuse to connect over HTTP even for the first visit — before they have seen your HSTS header. This is permanent and takes months to undo.

IP Allowlisting

1
2
3
4
5
6
7
admin.example.com {
    # Block anyone not on the LAN
    @not_lan not remote_ip 192.168.0.0/16 10.0.0.0/8
    abort @not_lan

    reverse_proxy localhost:8080
}

Basic Authentication

1
2
3
# Generate a bcrypt password hash
caddy hash-password --plaintext 'your-password-here'
# Output: $2a$14$...
1
2
3
4
5
6
private.example.com {
    basicauth /* {
        alice $2a$14$OS.3VT4jZ7b1FE3Ren.8HONyHXLVhsWyO8TlbAEJnK7x5N0hy4qKW
    }
    reverse_proxy localhost:8080
}

Forward Auth (Authentik, Authelia)

For centralized authentication, delegate to an external identity provider:

1
2
3
4
5
6
7
protected.example.com {
    forward_auth authentik.example.com {
        uri /outpost.goauthentik.io/auth/caddy
        copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email
    }
    reverse_proxy localhost:8080
}

When the auth upstream returns 2xx, the request proceeds. Any other status code (typically a redirect to the login page) is returned directly to the client. The copy_headers directive copies authentication metadata from the auth response into the proxied request, so your backend can see which user made the request.


The JSON Config API

The Caddyfile is a convenience layer. Caddy’s actual native configuration format is JSON, and the Caddyfile is compiled to JSON before Caddy runs it. Understanding the JSON format unlocks Caddy’s full flexibility and its admin API.

Viewing the JSON Equivalent

1
2
3
4
5
# See the JSON that your Caddyfile produces
caddy adapt --config /etc/caddy/Caddyfile --pretty

# Query the running config via the admin API
curl localhost:2019/config/

The output of caddy adapt is verbose — a simple three-line Caddyfile produces 100+ lines of JSON once all defaults are filled in. This verbosity is why the Caddyfile exists for human use, while JSON is better for programmatic generation.

Admin API Operations

The admin API runs on localhost:2019 by default. It accepts GET, POST, PUT, PATCH, and DELETE requests to manage specific parts of the config tree.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# Get the current full config
curl -s localhost:2019/config/ | jq .

# Load a completely new config from a JSON file
curl -s -X POST \
  -H "Content-Type: application/json" \
  -d @config.json \
  localhost:2019/load

# Add a new route dynamically (without reloading the entire config)
curl -s -X POST \
  -H "Content-Type: application/json" \
  -d '{"match": [{"host": ["newservice.example.com"]}], "handle": [...]}' \
  localhost:2019/config/apps/http/servers/srv0/routes/0

# Remove a specific route
curl -s -X DELETE localhost:2019/config/apps/http/servers/srv0/routes/0

When to Use JSON Over Caddyfile

The Caddyfile covers 95% of use cases. Reach for JSON when:

  • You are generating Caddy configuration programmatically (from a service registry, a YAML manifest, or a script)
  • You need complex conditional logic that Caddyfile syntax cannot express
  • You want to update individual parts of the config without touching others
  • You are building automation that adds/removes routes as services start and stop

A practical example: a script that reads a services.yaml file and generates Caddy JSON to register each service. This avoids the Caddyfile entirely and treats Caddy like a programmable API gateway:

1
2
3
4
5
6
7
#!/bin/bash
# Generate Caddy JSON from a service list and hot-reload
python3 generate_caddy_config.py services.yaml > /tmp/caddy_config.json
curl -s -X POST \
  -H "Content-Type: application/json" \
  -d @/tmp/caddy_config.json \
  localhost:2019/load

This is the model Caddy was designed for. The admin API makes zero-downtime config updates trivially scriptable — no signal handling, no file watches, no nginx -s reload.


Caddy vs Nginx vs Traefik

Three different tools built with three different assumptions about the world.

Nginx was built in 2004 to solve the C10K problem — serving tens of thousands of concurrent connections on a single server. It is written in C, uses an event-driven non-blocking architecture, and has been performance-tuned for two decades. Its config model is imperative and explicit: you describe exactly what Nginx should do, and it does that. There is no magic. TLS configuration is manual — you point it at certificate files, and when those files change you reload. This explicitness is both its strength and its maintenance burden.

Traefik was built for cloud-native environments. Its key insight is that in Docker and Kubernetes, services come and go dynamically, and manually maintaining a reverse proxy config for ephemeral containers does not scale. Traefik watches the Docker daemon or Kubernetes API and configures itself from labels and annotations. Deploy a container with traefik.http.routers.myapp.rule=Host('myapp.example.com') and Traefik routes to it automatically. This works well — until it does not. The static/dynamic config split confuses new users, debugging why a route is not loading requires diving into the Traefik dashboard and its provider configuration, and Traefik’s certificate management, while automatic, is less mature than Caddy’s.

Caddy occupies the middle ground. It is dynamic enough for homelabs and small production deployments via its admin API, but does not require a container orchestrator. Its TLS automation is the most sophisticated of the three — OCSP stapling, multiple CA rotation, automatic wildcard cert management via DNS-01, ECH, post-quantum key exchange — none of which require any configuration. The Caddyfile syntax is genuinely readable. The trade-off is a smaller ecosystem, some features requiring plugin builds, and a performance ceiling that, while excellent, falls short of Nginx for pure static file serving.

Comparison Table

Feature Caddy Nginx Traefik
Automatic TLS Yes, built-in No (requires Certbot/acme.sh) Yes, but more configuration
Config format Caddyfile (human-readable) + JSON nginx.conf (verbose, declarative) TOML/YAML (static) + labels (dynamic)
Docker integration Manual or via API Manual Native (label-based auto-discovery)
Kubernetes integration Manual or via JSON API Via ingress controller Native (IngressRoute CRD or Ingress)
Static file performance Good (~38k req/s) Best (~45k req/s) Lower (~32k req/s)
Memory footprint 30-100MB 15-50MB 50-200MB
HTTP/3 (QUIC) Built-in Requires build flag / recent version Built-in in v3
WebSocket proxying Automatic Manual header config required Automatic
gRPC proxying Supported (h2c://) Supported Supported
Rate limiting Plugin required (caddy-ratelimit) Built-in (limit_req_zone) Built-in middleware
Plugin ecosystem Growing, requires xcaddy builds Mature, many modules Growing
Learning curve Low Medium Medium-High
Production maturity Good Excellent Good (v3+)
Single binary Yes No (shared libs) Yes
Admin API Yes (REST) Limited (nginx -s) Yes (dashboard + API)

The Honest Recommendation

Choose Caddy if you are running a homelab, a small-to-medium self-hosted stack, or a new project where developer experience matters. Caddy’s automatic HTTPS is not a gimmick — it genuinely eliminates an entire category of operational toil. The Caddyfile is readable by anyone who can read English. For internal services with DNS-01 wildcard certs, Caddy is the simplest solution that exists. Caddy handles the vast majority of homelab use cases — reverse proxying, static files, WebSockets, basic auth, forward auth — without plugins and without configuration ceremony. At the traffic levels of a homelab or small team, the performance difference versus Nginx is irrelevant.

Choose Nginx if you need the absolute ceiling of static file performance, you need specific Nginx modules (ModSecurity, Lua scripting via OpenResty, specific auth modules), you are operating at scale where the performance difference matters, or you are working in an environment where everyone already knows Nginx. Nginx’s battle-tested production history is real. If you are putting a reverse proxy in front of a high-traffic service where you need every millisecond and every module, Nginx is the correct choice. Accept that you will manage TLS separately.

Choose Traefik if you are running Docker Compose or Kubernetes and want automatic route discovery from container labels or annotations. Traefik’s label-based model shines in environments where services are deployed and torn down frequently — you do not want to edit a Caddyfile every time a container starts. If your infrastructure is heavily containerized and you want routes to appear and disappear automatically with containers, Traefik’s model is genuinely better suited to that workflow. Just budget time for understanding the static/dynamic config split, and use Traefik v3 which has substantially improved the configuration model over v2.


Complete Homelab Example

The following is a production-ready configuration for a typical homelab running five services behind Caddy, using a wildcard certificate via Cloudflare DNS-01.

Directory Structure

/home/user/caddy/
  docker-compose.yml
  Caddyfile
  .env

.env File

1
2
CLOUDFLARE_API_TOKEN=your_cloudflare_token_with_dns_edit_permissions
ACME_EMAIL=admin@home.example.com

docker-compose.yml

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
services:
  caddy:
    # Use a community image that includes the Cloudflare DNS module
    # Or build your own: see the Dockerfile comment below
    image: caddy:latest-cloudflare   # community image, or build your own
    # To build your own:
    # build:
    #   context: .
    #   dockerfile: Dockerfile
    container_name: caddy
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"    # HTTP/3
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    env_file:
      - .env
    networks:
      - caddy
      - backend

volumes:
  caddy_data:
  caddy_config:

networks:
  caddy:
    external: false
  backend:
    external: true   # shared network where your other services run

If you need to build your own image with plugins:

1
2
3
4
5
6
7
FROM caddy:builder AS builder
RUN xcaddy build \
    --with github.com/caddy-dns/cloudflare@v1.6.0 \
    --with github.com/mholt/caddy-ratelimit

FROM caddy:latest
COPY --from=builder /usr/bin/caddy /usr/bin/caddy

Full Caddyfile

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
{
    # Global options
    email {env.ACME_EMAIL}

    # Admin API — only on localhost, never expose externally
    admin localhost:2019

    # Structured logging for aggregation (Loki, Grafana, etc.)
    log {
        output file /data/logs/access.log {
            roll_size 100mb
            roll_keep 10
        }
        format json
        level INFO
    }
}

# -------------------------------------------------------
# Snippets
# -------------------------------------------------------

# Security headers — apply to all services
(security_headers) {
    header {
        X-Frame-Options DENY
        X-Content-Type-Options nosniff
        X-XSS-Protection "1; mode=block"
        Referrer-Policy strict-origin-when-cross-origin
        Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()"
        -Server
        -X-Powered-By
    }
}

# LAN-only access restriction
(lan_only) {
    @external not remote_ip 192.168.0.0/16 10.0.0.0/8 172.16.0.0/12
    abort @external
}

# Standard proxy headers
(proxy_headers) {
    header_up X-Forwarded-For {remote_host}
    header_up X-Forwarded-Proto {scheme}
    header_up X-Real-IP {remote_host}
    header_up Host {upstream_hostport}
}

# -------------------------------------------------------
# Wildcard certificate via Cloudflare DNS-01
# One cert, covers all *.home.example.com subdomains
# -------------------------------------------------------

*.home.example.com {
    tls {
        dns cloudflare {env.CLOUDFLARE_API_TOKEN}
    }

    # Route by subdomain
    @proxmox host proxmox.home.example.com
    @grafana host grafana.home.example.com
    @photos  host photos.home.example.com
    @git     host git.home.example.com
    @ha      host ha.home.example.com

    # --------------------------------------------------
    # Proxmox VE — self-signed cert on backend
    # --------------------------------------------------
    handle @proxmox {
        import security_headers
        import lan_only
        reverse_proxy https://192.168.1.10:8006 {
            transport http {
                tls_insecure_skip_verify
            }
            header_up Host {upstream_hostport}
            # Proxmox needs WebSocket for console access
            header_up Upgrade {http.upgrade}
            header_up Connection "upgrade"
        }
    }

    # --------------------------------------------------
    # Grafana — standard HTTP backend
    # --------------------------------------------------
    handle @grafana {
        import security_headers
        import lan_only
        reverse_proxy grafana:3000 {
            import proxy_headers
            health_uri /api/health
            health_interval 30s
            health_timeout 5s
        }
    }

    # --------------------------------------------------
    # Immich — large file uploads (photos/videos)
    # --------------------------------------------------
    handle @photos {
        import security_headers
        import lan_only

        # Immich handles video files — allow large uploads
        request_body {
            max_size 10GB
        }

        reverse_proxy immich_server:2283 {
            import proxy_headers
            transport http {
                response_header_timeout 300s    # Long timeout for video processing
            }
        }
    }

    # --------------------------------------------------
    # Forgejo — Git hosting
    # --------------------------------------------------
    handle @git {
        import security_headers
        import lan_only
        reverse_proxy forgejo:3000 {
            import proxy_headers
            health_uri /
            health_interval 60s
            health_timeout 10s
        }
    }

    # --------------------------------------------------
    # Home Assistant — WebSocket required for real-time
    # updates; Caddy handles the upgrade automatically
    # --------------------------------------------------
    handle @ha {
        import security_headers
        # Home Assistant may be accessed externally —
        # comment out lan_only if you want external access
        # and use forward_auth or another auth layer instead
        import lan_only
        reverse_proxy homeassistant:8123 {
            import proxy_headers
            transport http {
                response_header_timeout 0s    # Disable for long-polling
            }
        }
    }

    # Catch-all: return 404 for unrecognized subdomains
    handle {
        respond "Not found" 404
    }
}

Request Flow Diagram

  Internet / LAN
       |
       | :80 (HTTP)  :443 (HTTPS/TLS)  :443/udp (HTTP/3)
       |
  +----+--------------------------------------------+
  |              Caddy (*.home.example.com)          |
  |                                                  |
  |  TLS termination (wildcard cert from LE/ZeroSSL) |
  |  HTTP/3 + HTTP/2 + HTTP/1.1                      |
  |                                                  |
  |  SNI/Host routing:                               |
  |  proxmox.*  -->  HTTPS 192.168.1.10:8006         |
  |  grafana.*  -->  HTTP  grafana:3000              |
  |  photos.*   -->  HTTP  immich_server:2283        |
  |  git.*      -->  HTTP  forgejo:3000              |
  |  ha.*       -->  HTTP  homeassistant:8123        |
  |                                                  |
  +--------------------------------------------------+
       |          |           |          |        |
       |          |           |          |        |
  [Proxmox]  [Grafana]   [Immich]  [Forgejo]  [HA]
  192.168.1.10 :3000      :2283      :3000    :8123

Deployment

1
2
3
4
5
6
7
8
# Start everything
docker compose up -d

# Watch the logs to confirm cert issuance
docker compose logs -f caddy

# Graceful reload after Caddyfile changes
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile

On first start, Caddy will register an ACME account with Let’s Encrypt and ZeroSSL, then initiate a DNS-01 challenge for *.home.example.com. Watch for log lines confirming the TXT record was set and the certificate was issued — typically within 60-90 seconds for Cloudflare (DNS propagation is near-instant on Cloudflare’s anycast network).


Operational Notes and Common Pitfalls

Port 80 must be open for HTTP-01 challenges. If you are using DNS-01, port 80 can be closed. But if you have a mixture of public services (HTTP-01) and internal services (DNS-01), you need Caddy to listen on both. Caddy handles this automatically — it uses DNS-01 for the domains you configure it for and HTTP-01 for the rest.

The admin API binds to localhost only by default. Do not expose port 2019 to the network. If you need remote access to the admin API (for automation scripts running on other hosts), use an SSH tunnel or bind to a VPN interface, not a public IP.

Certificate storage is stateful. If you are running Caddy in Docker without a persistent volume for /data, Caddy will re-request certificates on every container restart. You will hit Let’s Encrypt rate limits quickly. Always mount a persistent volume for /data in Docker deployments.

Log file rotation. Caddy’s built-in log rolling is adequate for homelabs. For production, consider shipping logs to a remote aggregator (Loki, Elasticsearch) rather than relying on local rotation.

The Caddyfile is validated before reload. If your Caddyfile has a syntax error, caddy reload fails and the running config remains unchanged. This is a safety feature — a bad config never takes effect.

tls_insecure_skip_verify is fine for internal services with self-signed certs. You are not bypassing TLS — you are still encrypting the connection. You are just skipping certificate validation for the backend-to-Caddy hop, which is acceptable when the backend is on a trusted internal network. It is not acceptable for connections to external services.


Caddy’s automatic HTTPS is the kind of feature that sounds small until you have spent an afternoon debugging a Certbot renewal that failed because your DNS TTL was too high and the challenge expired. The operational simplicity compounds: one binary, one config file, zero cron jobs, zero certificate management, and a Caddyfile that a new team member can read and understand in ten minutes.

It is not the right tool for every situation. High-traffic static file serving, complex module requirements, and container-native dynamic routing all have better-matched tools. But for a homelab where you want HTTPS everywhere with minimal ongoing maintenance, Caddy is the best answer available.

Comments