Skip to content

Caddy

A deep dive into Caddy — how it works, its configuration model, and the full range of directives available for reverse proxying, routing, SSL, and more.


What is Caddy

Caddy is a web server and reverse proxy written in Go. It is Sartiq's reverse proxy, replacing Nginx Proxy Manager (NPM).

Why Caddy over NPM

Caddy NPM
Config format Plain text file (Caddyfile) SQLite database (web UI)
Version control Committed to git, PR-reviewable Not versionable
SSL certificates Automatic, zero config Automatic, via UI
WebSocket proxy Automatic Requires manual header config
Recovery Redeploy from repo Restore database backup

Why Caddy over Nginx

Caddy Nginx
SSL setup Automatic (Let's Encrypt) Manual (certbot + cron)
Config verbosity ~3 lines per route ~10 lines per route
WebSocket Automatic Manual proxy_set_header
Hot reload Automatic on config change nginx -s reload
Health checks Built-in Requires nginx-plus

How Caddy Works

Automatic HTTPS

When Caddy sees a domain name in the Caddyfile, it automatically:

  1. Obtains a TLS certificate from Let's Encrypt (or ZeroSSL)
  2. Redirects HTTP → HTTPS
  3. Renews certificates before expiry

No tls, ssl_certificate, or certbot configuration needed. This happens by default for any public domain.

Request Flow

Client → DNS → Caddy (:443)
                  ├── TLS termination (automatic cert)
                  ├── Match host + path
                  └── Forward to upstream

Configuration Model

Caddy uses a Caddyfile — a human-readable config format. Each block defines a site:

site-address {
    directive1
    directive2
}

A site address can be: - A domain: api.sartiq.com - A domain with port: api.sartiq.com:8443 - Just a port: :8080 (all interfaces) - localhost (generates self-signed cert)


Directives Reference

reverse_proxy

The core directive. Forwards requests to a backend.

# Basic — proxy everything
api.sartiq.com {
    reverse_proxy localhost:8000
}

# Path-specific
api.sartiq.com {
    reverse_proxy /api/* localhost:8000
    reverse_proxy /compute/* localhost:9000
}

# Multiple upstreams (load balancing)
api.sartiq.com {
    reverse_proxy node1:8000 node2:8000 {
        lb_policy round_robin
    }
}

# With health checks
api.sartiq.com {
    reverse_proxy localhost:8000 {
        health_uri /health
        health_interval 10s
        health_timeout 5s
    }
}

# With custom headers to upstream
api.sartiq.com {
    reverse_proxy localhost:8000 {
        header_up X-Real-IP {remote_host}
        header_up X-Forwarded-Proto {scheme}
    }
}

handle and handle_path

Route requests by path. handle_path strips the matched prefix before forwarding.

api.sartiq.com {
    # /app/dashboard → backend receives /app/dashboard
    handle /app/* {
        reverse_proxy localhost:3000
    }

    # /api/v2/users → backend receives /users (prefix stripped)
    handle_path /api/v2/* {
        reverse_proxy localhost:8001
    }

    # Everything else
    handle {
        reverse_proxy localhost:8000
    }
}

handle blocks are mutually exclusive — only the first matching block executes (like nginx location with break).

rewrite

Internally rewrite the URI before processing. The client doesn't see the change.

api.sartiq.com {
    rewrite /old-endpoint /new-endpoint
    reverse_proxy localhost:8000
}

# Regex rewrite
api.sartiq.com {
    @legacy path_regexp legacy ^/v1/(.*)$
    rewrite @legacy /v2/{re.legacy.1}
    reverse_proxy localhost:8000
}

redir

Send an HTTP redirect to the client. Unlike rewrite, the client sees the new URL.

# Permanent redirect (301)
old.sartiq.com {
    redir https://api.sartiq.com{uri} permanent
}

# Temporary redirect (302)
api.sartiq.com {
    redir /old-page /new-page temporary
}

# Redirect with path matching
api.sartiq.com {
    redir /docs /docs/ 301
}

Modify response headers.

api.sartiq.com {
    # Set a header
    header Cache-Control "public, max-age=3600"

    # Add a header (doesn't replace existing)
    header +X-Custom-Header "value"

    # Remove a header
    header -Server

    # Set only if not already present
    header ?X-Frame-Options "DENY"

    # Multiple headers
    header {
        Cache-Control "no-store"
        X-Content-Type-Options "nosniff"
        -Server
    }

    reverse_proxy localhost:8000
}

encode

Enable compression (gzip, zstd).

api.sartiq.com {
    encode gzip
    reverse_proxy localhost:8000
}

# Both zstd and gzip (zstd preferred when client supports it)
api.sartiq.com {
    encode zstd gzip
    reverse_proxy localhost:8000
}

basic_auth

Protect routes with HTTP basic authentication.

admin.sartiq.com {
    basic_auth {
        admin $2a$14$Zkx19XLiW...  # bcrypt hash
    }
    reverse_proxy localhost:8000
}

# Only protect a specific path
api.sartiq.com {
    basic_auth /admin/* {
        admin $2a$14$Zkx19XLiW...
    }
    reverse_proxy localhost:8000
}

Generate a password hash:

docker run --rm caddy:2-alpine caddy hash-password

respond

Return a static response without proxying. Useful for health checks and blocking.

api.sartiq.com {
    # Health check endpoint
    respond /health 200

    # Block a path
    respond /blocked "Access denied" 403

    reverse_proxy localhost:8000
}

log

Configure access logging.

api.sartiq.com {
    log {
        output stdout
        format json
        level INFO
    }
    reverse_proxy localhost:8000
}

# Log to a file
api.sartiq.com {
    log {
        output file /var/log/caddy/access.log {
            roll_size 100mb
            roll_keep 5
        }
    }
    reverse_proxy localhost:8000
}

TLS Configuration

Default (automatic)

Just use a domain name — Caddy handles everything:

api.sartiq.com {
    reverse_proxy localhost:8000
}

Let's Encrypt sends expiry warnings to this email:

{
    email devops@sartiq.com
}

api.sartiq.com {
    reverse_proxy localhost:8000
}

Self-signed (local development)

localhost {
    reverse_proxy localhost:8000
}

Caddy generates a self-signed certificate for localhost automatically.

Internal CA (no public domain)

For services without public DNS:

internal-service.local {
    tls internal
    reverse_proxy localhost:8000
}

Custom certificate files

If you have your own cert (rare — prefer automatic):

api.sartiq.com {
    tls /path/to/cert.pem /path/to/key.pem
    reverse_proxy localhost:8000
}

Disable HTTPS (HTTP only)

For internal services behind another proxy:

:8080 {
    reverse_proxy localhost:8000
}

Using a port without a domain skips automatic HTTPS.


Matchers

Matchers let you apply directives conditionally. They are the equivalent of nginx location blocks and if conditions.

Path matcher (inline)

reverse_proxy /api/* localhost:8000

Named matchers

@api path /api/*
@static path /static/* /media/*
@websocket header Connection *Upgrade*

reverse_proxy @api localhost:8000
file_server @static
reverse_proxy @websocket localhost:8000

Common matcher types

Matcher Syntax Matches
Path path /api/* Request path
Path regex path_regexp ^/v[0-9]+/ Path by regex
Header header Content-Type application/json Request header value
Method method GET POST HTTP method
Remote IP remote_ip 10.0.0.0/8 Client IP range
Query query key=value Query string parameter
Not not path /health Negate any matcher

Combining matchers

# Must match ALL conditions (AND)
@admin {
    path /admin/*
    remote_ip 10.0.0.0/8
}
reverse_proxy @admin localhost:8000

Global Options

Set at the top of the Caddyfile, outside any site block:

{
    # ACME email for Let's Encrypt
    email devops@sartiq.com

    # Use staging Let's Encrypt (for testing, avoids rate limits)
    acme_ca https://acme-staging-v02.api.letsencrypt.org/directory

    # Disable automatic HTTPS redirect
    auto_https disable_redirects

    # Custom HTTP and HTTPS ports
    http_port 8080
    https_port 8443

    # Admin API (disable in production)
    admin off
}

Docker Usage

docker-compose.yml

services:
  caddy:
    image: caddy:2-alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy-data:/data      # certificates
      - caddy-config:/config

volumes:
  caddy-data:
  caddy-config:

Useful commands

# Validate config
docker compose exec caddy caddy validate --config /etc/caddy/Caddyfile

# Reload config (no downtime)
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile

# View loaded config as JSON
docker compose exec caddy caddy adapt --config /etc/caddy/Caddyfile

# View certificates
docker compose exec caddy caddy list-modules | grep tls