---
name: forge-caddy
description: Caddyfile discipline for production traffic. Global options, automatic HTTPS done right, security header pack, reverse_proxy with health checks, structured logs, multi-tenant on-demand TLS with validation, rate limiting, file_server hardening. Contains ready-to-paste Caddyfile + snippets. Use when writing or auditing a Caddyfile.
license: MIT
---

# forge-caddy

You are writing a Caddyfile that fronts production traffic. Default agent-written Caddy configs work but miss easy wins: no rate limits, no security headers, no structured logging, ACME staging accidentally left on, and a wide-open admin endpoint. This skill exists to cover the basics that turn Caddy from "it works" into "it works correctly."

The mental model: **Caddy's defaults are excellent (automatic HTTPS, HTTP/3, sane TLS). You opt-in to the production hardening that defaults cannot infer.**

## Quick reference (the things you must never ship)

1. `acme_ca` pointing at the staging URL in a production config.
2. Caddy admin API enabled (`admin off` is the right setting).
3. No `email` directive (ACME cannot register / renew).
4. `file_server browse on` (open directory listing).
5. Manually setting `X-Forwarded-For` (Caddy already does it; you'll double-set).
6. Inventing your own HTTP-to-HTTPS redirect (Caddy does it automatically on port 80).
7. Multi-tenant `tls { on_demand }` without an `ask` validator.
8. `reverse_proxy` without a `health_uri`.
9. No security header snippet imported per site.
10. Tracking ANSI color codes shipped into the access log.

## Hard rules

### Caddyfile structure

**1. Global options at the very top.** Email, admin off, protocols.

```caddy
{
    email admin@example.com
    admin off                  # disable admin API in production

    servers {
        protocols h1 h2 h3
    }

    # log format used by all sites unless overridden
    log default {
        output stdout
        format json
        level INFO
    }
}
```

**2. One site block per (sub)domain.** Multiple domains in one block when they truly share config.

**3. Use snippets (`(name) { ... }`) and `import` for repeated patterns.**

### TLS and automatic HTTPS

**4. Let Caddy manage TLS.** Do not manually configure cert paths unless using an internal CA. The automatic flow handles renewal, OCSP stapling, TLS-ALPN.

**5. `email` global is mandatory.** Without it, ACME accounts cannot be created or recovered.

**6. `acme_ca` left at production default.** Never ship with the staging URL.

```caddy
# BAD - browsers don't trust staging certs
{
    acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
}

# GOOD - leave default unless you explicitly want staging
```

**7. Wildcard certs need a DNS plugin and credentials.** Use sparingly.

### Admin API

**8. `admin off` in production.** Default admin binds to localhost but is still a powerful surface.

**9. If you need the admin API, bind to a private interface and add auth via a reverse proxy.**

### Security headers (the snippet)

**10. Set the security header pack on every public site.**

```caddy
(security_headers) {
    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"
        Permissions-Policy "camera=(), microphone=(), geolocation=()"
        -Server
    }
}
```

Then `import security_headers` in each site.

**11. CSP is per-site. There is no useful default.** Write one; ship with `Content-Security-Policy-Report-Only` first to find breakages.

**12. `-Server` removes Caddy's version banner.** Small but free.

**13. `Cache-Control` on static assets only.** Explicit no-store for HTML.

### Reverse proxy

**14. `reverse_proxy` to an upstream by name, with health checks.**

```caddy
reverse_proxy backend:3000 {
    lb_policy round_robin
    health_uri /health
    health_interval 5s
    health_timeout 2s
    health_status 2xx

    transport http {
        keepalive 60s
        keepalive_idle_conns 100
    }
}
```

**15. Set timeouts.** Default Caddy timeouts are reasonable for HTTP; set explicitly for upload/download/websocket workloads.

```caddy
reverse_proxy backend:3000 {
    transport http {
        read_timeout 30s
        write_timeout 30s
        dial_timeout 5s
    }
}
```

**16. WebSocket proxying needs NO extra config in Caddy v2.** Remove any `header_upstream Upgrade ...` lines copied from nginx tutorials.

**17. `X-Forwarded-For` is set automatically.** Do not add it again.

### Rate limiting

**18. Use the `caddy-ratelimit` plugin or fail2ban at the host level.** Caddy core does not include rate limiting by default.

```caddy
(rate_limit_auth) {
    rate_limit {
        zone auth {
            key {remote_host}
            events 5
            window 1m
        }
    }
}

api.example.com {
    @auth path /api/login /api/signup /api/reset
    handle @auth {
        import rate_limit_auth
        reverse_proxy backend:3000
    }
    handle {
        reverse_proxy backend:3000
    }
}
```

**19. Rate-limit auth endpoints separately from general traffic.** 5/min on `/api/login`, 60/min on `/api/*`, 600/min on `/`.

### Logging

**20. Structured JSON logs to file + stdout (not just one).** Many platforms collect stdout; a file is helpful for local-disk troubleshooting.

```caddy
example.com {
    log {
        output file /var/log/caddy/example.com.access.log {
            roll_size 100MiB
            roll_keep 10
            roll_keep_for 720h
        }
        output stdout
        format json
        level INFO
    }
}
```

**21. Log level `INFO` in prod by default.** `DEBUG` for short troubleshooting windows only.

**22. Access logs separated from error logs.** Different downstream consumers.

**23. Redact `Authorization` and `Cookie` headers in access logs.** Caddy supports this via `log_credentials false` (default) and `redact_headers` option in the log encoder.

### Files and security

**24. `file_server` directives need `browse off` (default) in production.** Directory listings are accidentally enabling enumeration.

**25. `@matchers` are precise.** `@safe path /static/*` is good. `* /static*` matches more than you think.

```caddy
example.com {
    @static path /static/*
    handle @static {
        root * /srv
        file_server
        header Cache-Control "public, max-age=31536000, immutable"
    }
    handle {
        reverse_proxy backend:3000
    }
}
```

### Performance

**26. HTTP/2 and HTTP/3 by default in Caddy.** Leave them on. Verify with `curl --http3 https://yoursite/`.

**27. `encode gzip zstd` on text responses.**

```caddy
example.com {
    encode gzip zstd
    reverse_proxy backend:3000
}
```

**28. Static asset caching: long max-age + immutable for hashed filenames; no-cache for HTML.**

### Multi-tenant / multi-domain

**29. `tls { on_demand }` for true SaaS multi-tenant. Pair with `ask` to validate.**

```caddy
{
    on_demand_tls {
        ask https://internal/validate-domain
        interval 2m
        burst 5
    }
}

https:// {
    tls {
        on_demand
    }
    reverse_proxy backend:3000
}
```

Without `ask`, anyone can point DNS at your Caddy and exhaust your ACME rate limit.

### Common gotchas

- **Trailing slashes:** `redir /old /new` keeps query string; `redir /old{uri} /new{uri}` may not.
- **HTTP→HTTPS redirect:** automatic when port 80 is reachable. Do not add a manual `:80` block unless you need custom redirect behavior.
- **`request_body` size limit:** default 10MB. Override for upload endpoints with `request_body { max_size 100MB }`.
- **Cron/admin-only paths:** match by IP via `@adminip remote_ip 10.0.0.0/8` and reject otherwise.

## Common AI-output patterns to reject

| Pattern | Why wrong | Fix |
| --- | --- | --- |
| `acme_ca https://acme-staging-v02...` in prod | Browsers don't trust cert | Leave default |
| No `email` directive | ACME cannot register | Set in global block |
| `admin :2019` enabled in prod | Exposes powerful API | `admin off` |
| Manually setting `Upgrade` headers for WebSocket | Caddy v2 already handles it | Remove |
| `file_server browse` enabled | Directory listing leak | Default `off` |
| `Authorization` logged in access logs | Token leak | Redact in log encoder |
| `tls { on_demand }` with no `ask` | Anyone DOSes your ACME | Add validator URL |
| Manual `X-Forwarded-For` set | Double-set | Caddy does it |
| `on: push` to deploy.sh | Wrong layer (that's Actions) | (see forge-github-actions) |

## Worked example: full production Caddyfile

```caddy
# global options

{
    email admin@example.com
    admin off

    servers {
        protocols h1 h2 h3
    }

    log default {
        output stdout
        format json
        level INFO
    }
}

# snippets

(security_headers) {
    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"
        Permissions-Policy "camera=(), microphone=(), geolocation=()"
        Content-Security-Policy "default-src 'self'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' https://api.example.com"
        -Server
    }
}

(rate_limit_auth) {
    rate_limit {
        zone auth {
            key {remote_host}
            events 5
            window 1m
        }
    }
}

# sites

example.com, www.example.com {
    redir https://example.com{uri} permanent

    encode gzip zstd

    import security_headers

    @static path /static/* /assets/*
    handle @static {
        root * /srv/static
        file_server
        header Cache-Control "public, max-age=31536000, immutable"
    }

    handle {
        reverse_proxy backend:3000 {
            health_uri /health
            health_interval 5s
            transport http {
                keepalive 60s
            }
        }
    }

    log {
        output file /var/log/caddy/example.com.log {
            roll_size 100MiB
            roll_keep 10
        }
        output stdout
        format json
    }
}

api.example.com {
    import security_headers

    @auth path /api/login /api/signup /api/reset
    handle @auth {
        import rate_limit_auth
        reverse_proxy backend:3000
    }

    handle {
        reverse_proxy backend:3000 {
            health_uri /health
            health_interval 5s
        }
    }

    log {
        output stdout
        format json
    }
}
```

What this demonstrates: global block with email + admin off (rules 1, 5, 8); JSON logging globally and per-site (rule 20); reusable security_headers snippet (rule 10); www → apex redirect (common pattern); separate handlers for static (with immutable cache) vs proxy traffic (rules 14, 26); rate-limit snippet imported only for auth endpoints (rule 19); reverse_proxy with health check (rule 14); separate api subdomain with its own configuration.

## Workflow

When writing or auditing a Caddyfile:

1. **Start from the global options block.** email, admin off, protocols.
2. **Write the `(security_headers)` snippet, import everywhere.**
3. **Site blocks: domain, headers, reverse_proxy (or file_server).**
4. **Set logging.**
5. **Run `caddy validate` and `caddy fmt`.**
6. **`caddy reload` for live changes - never restart unless absolutely necessary.**

## Verification

```bash
bash skills/infra/forge-caddy/verify/check_caddy.sh path/to/Caddyfile
```

Flags: ACME staging URL, admin enabled outside dev, missing email, file_server browse on.

## When to skip this skill

- Local development Caddyfiles.
- Caddy as a library (JSON config) - same principles, different syntax.
- nginx / Traefik / HAProxy - parallel discipline, different file format.

## Related skills

- [`forge-kubernetes`](../forge-kubernetes/SKILL.md) - if Caddy runs inside the cluster.
- [`forge-secrets`](../../security/forge-secrets/SKILL.md) - Caddy itself does not handle app secrets, but TLS keys belong here.
- [`forge-api-design`](../../backend/forge-api-design/SKILL.md) - the backend Caddy proxies to.
