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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- caddy-server Installs to ~/.agents/skills/caddy-server/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install caddy-server The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project caddy-server Installs to .agents/skills/caddy-server/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/caddy-server.zipunzip -o caddy-server.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) caddy-server PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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
- The stack is staying on Nginx: use nginx-config
- Certificates are issued by cert-manager inside Kubernetes: use tls-certificates
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.
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
caddy versionandcaddy list-modules | grep -E 'dns|rate'to see which plugins the binary has. Standard builds include no DNS providers and no rate limiter.- Find the config:
/etc/caddy/Caddyfilefor packages, a bind mount in Docker, or JSON via the admin API onlocalhost:2019. - 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.
- Read
AGENTS.mdfor 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-ProtoandX-Forwarded-Hoston proxied requests automatically; do not add them by hand. - Use tabs or spaces consistently; run
caddy fmt --overwrite Caddyfileto normalize.
Routing: handle vs handle_path vs route
| Directive | Behaviour |
|---|---|
handle /api/* | Mutually exclusive block, path passed unchanged (/api/users) |
handle_path /api/* | Same, but strips the matched prefix (/users) |
route | Runs directives in written order, not Caddy’s sorted order |
| bare directives in a site | Sorted 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
| Need | Config |
|---|---|
| Public site, ports 80/443 open | Nothing; automatic |
| Wildcard cert or host not reachable from internet | DNS challenge plugin |
| Internal-only service | tls internal (Caddy’s local CA; clients must trust its root) |
| Customer custom domains (SaaS) | On-demand TLS with an ask endpoint |
| Bring your own cert | tls /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 fromcaddy hash-password(directive wasbasicauthbefore 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
| Nginx | Caddy |
|---|---|
proxy_pass http://app; + header lines | reverse_proxy app:port |
location /api/ { proxy_pass http://app/; } | handle_path /api/* { reverse_proxy app:port } |
try_files $uri /index.html | try_files {path} /index.html + file_server |
return 301 https://... | automatic, or redir |
| certbot cron | nothing |
limit_req | plugin or app-level |
gzip on | encode zstd gzip |
Run both side by side on a staging host and compare responses with curl -sI for each
route before switching DNS.
Debugging
| Symptom | Check |
|---|---|
| Certificate not issued | journalctl -u caddy for ACME errors; DNS A/AAAA records; port 80 blocked; a stale AAAA record pointing elsewhere |
| Rate-limited by Let’s Encrypt | Switch to the staging CA while testing; check /data persistence |
| Route never matches | handle ordering or a path matcher missing * (/api vs /api/*) |
| App builds http:// URLs | App ignores X-Forwarded-Proto; configure its trusted proxy setting |
| Client IP is the LB’s | Add trusted_proxies in global servers options |
Verification
caddy validatepasses;caddy adaptoutput shows the expected routes.curl -I http://example.comreturns 308 to https;curl -I https://example.comreturns expected headers and noServer.curl -sI https://example.com/api/healthzreaches 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.yaml
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.