Caddy Server skill for Codex

HTTPS by default, a Caddyfile you can read. Codex writes short Caddyfiles that lean on Caddy's defaults: automatic certificates, HTTPS redirects and forwarded headers. Routing is explicit with handle and handle_path, backends get health checks, wildcard certs use a DNS plugin built with xcaddy, and SaaS custom domains use on-demand TLS behind an ask endpoint. It validates before reloading and keeps /data persistent so certificates survive restarts.

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- caddy-server

Installs to ~/.agents/skills/caddy-server/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $caddy-server, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Configures Caddy 2 as a reverse proxy and web server with automatic HTTPS - Caddyfile site blocks, reverse_proxy with health checks and load balancing, handle and handle_path routing, named matchers, headers, compression, SPA file serving, on-demand TLS with an ask endpoint, DNS challenge plugins built with xcaddy, and Docker deployments with persistent /data. Use when serving an app or static site with Caddy, migrating from Nginx, certificates fail to issue, or multi-tenant custom domains need TLS.

Use it when

  • You want a reverse proxy with certificates handled automatically
  • Customer custom domains need TLS without manual issuance
  • Caddy fails to obtain certificates or routes requests unexpectedly
  • A small Nginx plus certbot setup should become simpler

Use something else when

How to check the result

caddy validate passes, curl shows the HTTPS redirect and expected headers on each route, and after a restart the logs show certificates loaded from storage rather than re-issued.

The mistake it prevents

Running Caddy in Docker without a persistent /data volume, so every restart re-requests certificates until Let's Encrypt rate-limits the domain.

~/.agents/skills/caddy-server/SKILL.md

--- name: caddy-server description: Configures Caddy 2 as a reverse proxy and web server with automatic HTTPS - Caddyfile site blocks, reverse_proxy with health checks and load… license: MIT ---

A good Caddy setup is a short Caddyfile that caddy validate accepts, gets certificates on first request without manual steps, persists its data directory so it never re-issues on restart, and makes routing order explicit with handle blocks. Caddy’s defaults (HTTPS redirect, HTTP/2 and HTTP/3, forwarded headers) are good; most configs should add little.

Inspect first

  1. caddy version and caddy list-modules | grep -E 'dns|rate' to see which plugins the binary has. Standard builds include no DNS providers and no rate limiter.
  2. Find the config: /etc/caddy/Caddyfile for packages, a bind mount in Docker, or JSON via the admin API on localhost:2019.
  3. Confirm DNS for every site name points at this host and ports 80 and 443 are reachable from the internet; the HTTP and TLS-ALPN ACME challenges need them.
  4. Read AGENTS.md for the app’s port and health endpoint.

Baseline Caddyfile

{
	email [email protected]
	# acme_ca https://acme-staging-v02.api.letsencrypt.org/directory   # while testing
	servers {
		trusted_proxies static 10.0.0.0/8      # only if behind a load balancer
	}
}

example.com {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options nosniff
		Referrer-Policy strict-origin-when-cross-origin
		-Server
	}

	handle_path /api/* {
		reverse_proxy localhost:8080 {
			health_uri /healthz
			health_interval 10s
		}
	}

	handle {
		root * /srv/app/dist
		try_files {path} /index.html
		file_server
	}

	log {
		output file /var/log/caddy/example.access.log
		format json
	}
}

www.example.com {
	redir https://example.com{uri} permanent
}
  • Caddy redirects HTTP to HTTPS and sets X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host on proxied requests automatically; do not add them by hand.
  • Use tabs or spaces consistently; run caddy fmt --overwrite Caddyfile to normalize.

Routing: handle vs handle_path vs route

DirectiveBehaviour
handle /api/*Mutually exclusive block, path passed unchanged (/api/users)
handle_path /api/*Same, but strips the matched prefix (/users)
routeRuns directives in written order, not Caddy’s sorted order
bare directives in a siteSorted by Caddy’s directive order, which surprises people

Only the first matching handle block runs, and blocks are tried most specific path first; a final handle with no matcher is the fallback.

Named matchers keep conditions readable:

@websockets {
	header Connection *Upgrade*
	header Upgrade websocket
}
reverse_proxy @websockets localhost:8081

@blocked path /.git/* /.env* /backup/*
respond @blocked 404

WebSockets and SSE need no extra config; reverse_proxy handles upgrades and streams. For SSE add flush_interval -1 inside reverse_proxy if events arrive late.

Load balancing

reverse_proxy app1:8080 app2:8080 app3:8080 {
	lb_policy least_conn
	lb_retries 2
	health_uri /healthz
	fail_duration 30s
}

Use lb_policy cookie or ip_hash only when the app keeps in-memory sessions.

TLS options

NeedConfig
Public site, ports 80/443 openNothing; automatic
Wildcard cert or host not reachable from internetDNS challenge plugin
Internal-only servicetls internal (Caddy’s local CA; clients must trust its root)
Customer custom domains (SaaS)On-demand TLS with an ask endpoint
Bring your own certtls /path/cert.pem /path/key.pem

DNS challenge requires a custom build (network for Go modules):

xcaddy build --with github.com/caddy-dns/cloudflare
*.example.com {
	tls {
		dns cloudflare {env.CLOUDFLARE_API_TOKEN}
	}
	reverse_proxy localhost:8080
}

On-demand TLS for customer domains:

{
	on_demand_tls {
		ask http://localhost:9000/caddy/allowed-domain
	}
}

https:// {
	tls {
		on_demand
	}
	reverse_proxy localhost:8080
}

The ask endpoint must return 200 only for domains in your database. Without it, anyone can point a domain at you and make Caddy request certificates until you hit CA rate limits.

Auth and limits

  • Basic auth: basic_auth /admin/* { admin <hash> } with the hash from caddy hash-password (directive was basicauth before 2.8; both names are accepted in recent versions).
  • Rate limiting is not built in. Either build with a rate-limit module via xcaddy or enforce limits in the app or at the CDN.
  • Request body cap: request_body { max_size 20MB }.

Docker

services:
  caddy:
    image: caddy:2
    restart: unless-stopped
    ports: ["80:80", "443:443", "443:443/udp"]     # udp for HTTP/3
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data                           # certificates and ACME account
      - caddy_config:/config
volumes:
  caddy_data:
  caddy_config:

Losing /data means re-issuing every certificate on start; with many sites that hits Let’s Encrypt rate limits (50 certificates per registered domain per week).

Apply and reload

caddy validate --config /etc/caddy/Caddyfile
caddy adapt --config /etc/caddy/Caddyfile --pretty     # see the JSON Caddy will run
sudo systemctl reload caddy                            # or: caddy reload --config ...
docker compose exec -w /etc/caddy caddy caddy reload

Reloads are graceful. On a production host, show the diff and validation output and get the user’s confirmation first.

Migrating from Nginx

NginxCaddy
proxy_pass http://app; + header linesreverse_proxy app:port
location /api/ { proxy_pass http://app/; }handle_path /api/* { reverse_proxy app:port }
try_files $uri /index.htmltry_files {path} /index.html + file_server
return 301 https://...automatic, or redir
certbot cronnothing
limit_reqplugin or app-level
gzip onencode zstd gzip

Run both side by side on a staging host and compare responses with curl -sI for each route before switching DNS.

Debugging

SymptomCheck
Certificate not issuedjournalctl -u caddy for ACME errors; DNS A/AAAA records; port 80 blocked; a stale AAAA record pointing elsewhere
Rate-limited by Let’s EncryptSwitch to the staging CA while testing; check /data persistence
Route never matcheshandle ordering or a path matcher missing * (/api vs /api/*)
App builds http:// URLsApp ignores X-Forwarded-Proto; configure its trusted proxy setting
Client IP is the LB’sAdd trusted_proxies in global servers options

Verification

  • caddy validate passes; caddy adapt output shows the expected routes.
  • curl -I http://example.com returns 308 to https; curl -I https://example.com returns expected headers and no Server.
  • curl -sI https://example.com/api/healthz reaches the backend with the prefix stripped.
  • Restart the container and confirm logs show certificates loaded from storage, not obtained anew.

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Caddy Server"
  short_description: "HTTPS by default, a Caddyfile you can read"
  default_prompt: "$caddy-server Serve my React build and proxy /api to port 8080 with Caddy and HTTPS"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc