⏱ 15 min read 📊 Intermediate 🗓 Updated Jan 2025
✨ Why Caddy?

Caddy is a modern, open-source web server written in Go. It stands out from nginx and Apache in one critical way: it handles TLS automatically, without plugins, certbot, cron jobs, or manual renewal. For self-hosters and developers, this alone makes Caddy the default choice. The configuration language is also dramatically simpler — what takes 30 lines in nginx takes 3 in a Caddyfile.

Automatic HTTPS

Caddy is the only web server that provisions and renews Let's Encrypt TLS certificates automatically. Zero configuration needed. Supports ACME HTTP-01, DNS-01, and TLS-ALPN challenges. Point a domain at your server, write a Caddyfile, and HTTPS just works.

  • Certificates stored in ~/.local/share/caddy (Linux) or $HOME/Library/Application Support/Caddy (macOS)
  • Auto-renews certificates before expiry — no cron job needed
  • On-demand TLS: provision certificates the first time a domain is requested
  • Local HTTPS for development via a built-in local CA (caddy trust)
  • Supports ZeroSSL and custom ACME endpoints in addition to Let's Encrypt

Caddyfile Syntax

Caddy's config language is drastically simpler than nginx or Apache. A full reverse proxy with HTTPS is just 3 lines. Caddy also exposes a JSON API for programmatic configuration and supports hot-reload without downtime via caddy reload.

  • Intuitive block-based syntax — no semicolons, no location blocks
  • Directives are ordered by priority automatically; no try_files hacks
  • JSON API: POST /load to push a new config atomically
  • caddy reload applies changes without dropping connections
  • Named matchers for reusable request matching logic

HTTP/2 and HTTP/3

HTTP/2 is enabled by default for all HTTPS sites in Caddy. HTTP/3 (QUIC) is available via the experimental protocol config. Both multiplexing and header compression are built in — no extra modules to install.

  • HTTP/2 enabled automatically for all TLS connections
  • HTTP/3 via servers { protocol { experimental_http3 } } in global config
  • QUIC requires UDP 443 to be open at the firewall
  • Server push available (HTTP/2) for preloading critical assets
  • Graceful fallback to HTTP/1.1 for older clients

Security Defaults

Caddy sets secure TLS defaults out of the box: TLS 1.2 minimum, modern cipher suites, OCSP stapling, and automatic HSTS for HTTPS sites. Security headers (CSP, X-Frame-Options, etc.) must be added manually via the header directive — but the TLS foundation is already hardened.

  • TLS 1.2 minimum enforced globally; TLS 1.3 preferred
  • Modern cipher suites only: ECDHE with AES-GCM and ChaCha20-Poly1305
  • OCSP stapling enabled by default for faster TLS handshakes
  • Automatic HSTS with includeSubDomains when HTTPS is active
  • No server version disclosed in response headers by default
📦 Installation

Caddy distributes official packages for macOS (Homebrew), Debian/Ubuntu, RHEL/Fedora, and Docker. The binary is a single self-contained executable with no runtime dependencies. Choose the method that matches your environment below.

1

Install via Homebrew

The easiest way on macOS. Homebrew keeps the formula up to date with each Caddy release.

brew install caddy

This installs the caddy binary to /usr/local/bin/caddy (Intel Macs) or /opt/homebrew/bin/caddy (Apple Silicon). No PATH changes needed.

2

Verify the installation

caddy version

Should print something like v2.8.4 h1:.... If the command is not found, ensure your Homebrew bin directory is in $PATH.

3

Start Caddy as a Homebrew service (runs on login)

brew services start caddy

Uses a launchd plist at ~/Library/LaunchAgents/homebrew.mxcl.caddy.plist. Caddy will start automatically on login. The Caddyfile is expected at /opt/homebrew/etc/Caddyfile (Apple Silicon) or /usr/local/etc/Caddyfile (Intel). Check or create it there.

Stop the service: brew services stop caddy  |  Restart: brew services restart caddy

4

Or run Caddy manually in the foreground

caddy run --config /path/to/Caddyfile

Useful for development. Ctrl+C to stop. Without --config, Caddy looks for a Caddyfile in the current directory.

5

Test with a minimal Caddyfile

Create a file named Caddyfile with:

localhost

respond "Hello from Caddy!"

Then run caddy run in the same directory. Visit https://localhost — Caddy auto-creates a local CA and issues a trusted certificate.

Local HTTPS trust

For the local certificate to be trusted by your browser, run this once: sudo caddy trust. This installs Caddy's local CA into the macOS system keychain. You'll see the green padlock on localhost.

1

Add the official Caddy apt repository (Debian / Ubuntu)

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 caddy

This installs the caddy binary, creates a caddy system user, and registers a systemd service. The Caddyfile is placed at /etc/caddy/Caddyfile.

2

RHEL / Fedora / CentOS — use the Caddy COPR

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

The COPR repository mirrors official Caddy releases. Same result as the Debian path: systemd service, caddy user, config at /etc/caddy/Caddyfile.

3

Or download the static binary directly (any Linux distro)

curl -OL "https://caddyserver.com/api/download?os=linux&arch=amd64" -o caddy
chmod +x caddy
sudo mv caddy /usr/local/bin/

Replace amd64 with arm64 for Raspberry Pi / ARM servers. Verify with caddy version.

4

Verify the installation

caddy version
5

Enable and start the systemd service (apt/dnf installs only)

The package installer pre-creates the service and the caddy user automatically:

sudo systemctl enable --now caddy
sudo systemctl status caddy

Caddy starts immediately and will restart on reboot. Logs: journalctl -u caddy --follow

6

Manual binary install — create the systemd service

Download the official unit file from the Caddy distribution repo and install it:

sudo curl -o /etc/systemd/system/caddy.service \
  https://raw.githubusercontent.com/caddyserver/dist/master/init/caddy.service
sudo useradd --system --home /var/lib/caddy --shell /usr/sbin/nologin caddy
sudo mkdir -p /etc/caddy /var/log/caddy
sudo chown caddy:caddy /var/log/caddy
sudo systemctl daemon-reload
sudo systemctl enable --now caddy
7

Edit the Caddyfile and reload without downtime

sudo nano /etc/caddy/Caddyfile
sudo systemctl reload caddy

systemctl reload sends SIGUSR1 to Caddy, which is equivalent to caddy reload. Zero connections are dropped. Caddy also validates the config before applying it — a syntax error will leave the old config running.

8

Key paths on Linux

Config: /etc/caddy/Caddyfile  |  TLS data: /var/lib/caddy/.local/share/caddy/  |  Logs: journalctl -u caddy or /var/log/caddy/

1

Pull the official image

docker pull caddy:latest

The official image is on Docker Hub at hub.docker.com/_/caddy. Use caddy:alpine for a smaller image (~15 MB vs ~50 MB). Both are maintained by the Caddy project.

2

Run a quick test — serve the current directory

docker run -p 80:80 -p 443:443 \
  -v $PWD/Caddyfile:/etc/caddy/Caddyfile \
  -v caddy_data:/data \
  -v caddy_config:/config \
  caddy

The caddy_data named volume persists TLS certificates between container restarts. The caddy_config volume stores runtime JSON state.

3

Create a Caddyfile alongside docker-compose.yml

example.com {
    reverse_proxy app:3000
}

Docker service names (like app) resolve automatically within a Docker network — no IP addresses needed.

4

Recommended docker-compose.yml

services:
  caddy:
    image: caddy:latest
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"   # HTTP/3 QUIC
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data        # TLS certs and ACME state
      - caddy_config:/config    # Runtime config
    networks:
      - web

  app:
    image: your-app:latest
    networks:
      - web

networks:
  web:

volumes:
  caddy_data:
  caddy_config:

Mount the Caddyfile as :ro (read-only) — Caddy only needs to read it at startup or reload. Never mount /data as a bind mount to a local directory unless you have correct permissions; named volumes are safer for TLS state.

5

Start the stack

docker compose up -d
6

Reload config after editing the Caddyfile

docker exec caddy caddy reload --config /etc/caddy/Caddyfile

This hot-reloads the config without restarting the container. Zero dropped connections.

7

View logs

docker logs caddy -f
8

HTTPS in Docker — what you need to know

Port access required for automatic HTTPS

For Let's Encrypt to issue a certificate, Caddy needs ports 80 and 443 reachable from the internet (HTTP-01 challenge). For local development using localhost or an internal IP, Caddy uses its built-in local CA instead. If you're behind a NAT or Cloudflare Tunnel, you can use DNS-01 challenge with a DNS provider plugin baked into a custom Caddy image.

📝 Caddyfile Essentials

The Caddyfile is Caddy's human-friendly configuration format. Each block starts with an address (domain or wildcard), and directives inside the block configure how Caddy handles requests for that site. These are the patterns you will use constantly.

Static file server

Serve files from a directory. encode gzip zstd enables transparent compression for text-based assets (HTML, CSS, JS, JSON). Caddy sets correct MIME types automatically.

example.com {
    root * /var/www/html
    file_server
    encode gzip zstd
}

Reverse proxy — single upstream

Forward all requests to a backend service. Caddy handles automatic HTTPS termination — your backend only needs to listen on HTTP.

example.com {
    reverse_proxy localhost:3000
}

Reverse proxy — multiple apps by subdomain

Each site block is independent. Caddy gets a separate Let's Encrypt certificate for each domain automatically.

app1.example.com {
    reverse_proxy localhost:3001
}

app2.example.com {
    reverse_proxy localhost:3002
}

Reverse proxy — multiple apps by path

Route different URL paths to different backends on the same domain. handle blocks are evaluated in order; the final handle is the catch-all.

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

Basic authentication

Protect a site or path with HTTP Basic Auth. Passwords are stored as bcrypt hashes — never plaintext. Generate a hash with caddy hash-password (interactive) or docker exec -it caddy caddy hash-password.

private.example.com {
    basicauth * {
        alice $2a$14$...bcrypt-hash-here...
    }
    reverse_proxy localhost:4000
}

The * matcher applies basic auth to all paths. Use /admin/* to restrict only a subpath.

Security headers

Caddy's header directive sets, modifies, or removes response headers. The -Server syntax removes the header entirely. Adjust CSP to match your app's requirements.

example.com {
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        X-Content-Type-Options "nosniff"
        X-Frame-Options "DENY"
        Referrer-Policy "strict-origin-when-cross-origin"
        Content-Security-Policy "default-src 'self'"
        -Server
    }
    reverse_proxy localhost:3000
}

Access logging

Structured JSON logs with automatic log rotation. roll_size triggers rotation at 100 MB, roll_keep 5 retains 5 compressed archives, and roll_keep_for 720h deletes logs older than 30 days.

example.com {
    log {
        output file /var/log/caddy/access.log {
            roll_size 100mb
            roll_keep 5
            roll_keep_for 720h
        }
        format json
    }
    reverse_proxy localhost:3000
}
🧪 Common Recipes

Patterns that come up repeatedly in real Caddy deployments. Copy-paste and adapt to your Caddyfile.

Strip path prefix when proxying

Use handle_path instead of handle to strip the matched prefix before forwarding the request. Useful when your upstream app is designed to run at / but you're exposing it at /app/.

example.com {
    handle_path /app/* {
        reverse_proxy localhost:8080
    }
}

A request to /app/login arrives at the backend as /login.

Health checks on upstream

Caddy can actively probe upstreams and automatically remove unhealthy backends from the rotation. If the health check fails, Caddy returns a 502 rather than forwarding to a dead backend.

example.com {
    reverse_proxy localhost:3000 {
        health_uri      /health
        health_interval 10s
        health_timeout  5s
    }
}

WebSocket proxying

WebSocket connections work automatically in Caddy — no special configuration needed. Caddy detects Upgrade: websocket headers and performs the connection upgrade transparently, passing it through to the upstream.

# Nothing extra needed — just proxy as usual:
example.com {
    reverse_proxy localhost:3000
}

This works for Socket.IO, native WebSockets, and SSE (Server-Sent Events).

Redirect HTTP to HTTPS (manual)

For named domains, Caddy redirects HTTP to HTTPS automatically. If you need a manual redirect (e.g., you turned off auto_https):

http://example.com {
    redir https://{host}{uri} permanent
}

https://example.com {
    reverse_proxy localhost:3000
}

Custom TLS certificate

Use your own certificate instead of having Caddy provision one from Let's Encrypt. Useful when certificates are managed externally (e.g., by Vault, AWS ACM, or an internal CA).

example.com {
    tls /path/to/cert.pem /path/to/key.pem
    reverse_proxy localhost:3000
}

Caddy will still watch for cert file changes and reload them hot.

Load balancing across multiple upstreams

Caddy supports round-robin, random, least-connections, and IP-hash load balancing. Combine with health checks for automatic failover.

example.com {
    reverse_proxy {
        to localhost:3001 localhost:3002 localhost:3003
        lb_policy least_conn
        health_uri /health
        health_interval 15s
    }
}

Rate limiting (with plugin)

Basic rate limiting requires the caddy-ratelimit plugin baked into a custom Caddy build. For simpler setups, put Cloudflare or a WAF in front. Custom Caddy builds with plugins: caddyserver.com/download.

# With caddy-ratelimit plugin installed:
example.com {
    rate_limit {remote_host} 100r/m
    reverse_proxy localhost:3000
}

Global options block

The { } block at the top of a Caddyfile (before any site blocks) sets global options. Common settings: email for Let's Encrypt, disabling automatic HTTPS (useful behind Cloudflare Tunnel), and enabling HTTP/3.

{
    email [email protected]
    auto_https off          # if TLS is handled upstream
    servers {
        protocol {
            experimental_http3
        }
    }
}

example.com {
    reverse_proxy localhost:3000
}
🔧 Validate & Troubleshoot

Caddy provides excellent built-in tools for validating config and diagnosing problems. Use these before applying any change in production.

Validate Caddyfile syntax

Check your Caddyfile for syntax errors without applying it. Returns a non-zero exit code on failure — safe to use in CI pipelines.

caddy validate --config /etc/caddy/Caddyfile

In Docker: docker exec caddy caddy validate --config /etc/caddy/Caddyfile

Inspect the compiled JSON config

See the internal JSON representation that Caddy actually runs. Useful for debugging directive ordering and understanding how Caddy interprets your Caddyfile.

caddy adapt --config /etc/caddy/Caddyfile \
  --adapter caddyfile | jq .

Auto-reload on file change (dev mode)

Run Caddy in watch mode during development. Any change to the Caddyfile triggers an automatic reload — no manual caddy reload needed.

caddy run --watch

Caddy watches the Caddyfile in the current directory by default. Combine with --config to specify a path.

ACME challenge troubleshooting

If Caddy fails to obtain a Let's Encrypt certificate, check these common causes:

  • Port 80 must be publicly reachable for HTTP-01 challenge
  • DNS must point to your server's IP before Caddy starts
  • If behind a reverse proxy or CDN, disable their caching for /.well-known/acme-challenge/
  • Let's Encrypt rate limits: 5 failures per hour per domain
  • For DNS-01 challenge (behind NAT): use a custom Caddy build with a DNS provider plugin

Locate TLS certificate files

If you need to inspect or copy the certificates Caddy obtained:

# Linux (systemd package install)
ls /var/lib/caddy/.local/share/caddy/certificates/

# macOS (Homebrew)
ls "$HOME/Library/Application Support/Caddy/certificates/"

# Docker (named volume)
docker run --rm -v caddy_data:/data alpine ls \
  /data/caddy/certificates/

View logs

Caddy logs are structured JSON by default (Zap logger). Filter and pretty-print them with jq.

# Linux systemd
journalctl -u caddy --since "10 minutes ago"

# Docker
docker logs caddy --since 10m -f

# Pretty-print JSON log line
journalctl -u caddy -n 50 -o cat | jq .msg

Test reverse proxy headers

Verify that Caddy is forwarding the correct headers to your backend. curl -v shows request and response headers.

# Check response headers from Caddy
curl -I https://example.com

# Check what headers your backend receives
# (add a /debug/headers route in your app, or use httpbin)
curl https://example.com/debug/headers

Caddy automatically sets X-Forwarded-For, X-Forwarded-Proto, and X-Real-IP when reverse proxying.

Caddy API — live config inspection

Caddy's admin API (localhost:2019 by default) lets you inspect and update the running config programmatically without a reload.

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

# Reload config via API
curl -X POST "http://localhost:2019/load" \
  -H "Content-Type: text/caddyfile" \
  --data-binary @/etc/caddy/Caddyfile

Disable the admin API in production if not needed: { admin off } in the global options block.

Permissions pitfall on Linux

Caddy runs as the caddy user (when installed via apt/dnf). Binding to ports 80 and 443 requires elevated privileges. The package installer grants this via CAP_NET_BIND_SERVICE on the binary automatically. If you installed the binary manually, run: sudo setcap cap_net_bind_service=+ep $(which caddy). Without this, Caddy will fail to start on ports below 1024.