Caddy Web Server: Production Best Practices & Config Guide
Caddy is a web server and reverse proxy that can manage HTTPS certificates automatically. A reliable production setup needs a clear configuration path, persistent certificate storage, a reload procedure, and checks that distinguish a running proxy from a healthy application. This guide starts with those essentials, then covers Docker, reverse proxy routing, monitoring, and high availability.
The examples use Caddy 2 and Docker Compose V2 (docker compose). Replace example.com with a domain you control and replace backend names with services reachable from Caddy. Use a maintained Caddy release and validate every change with the same binary or container image that will run it.
Where is the Caddy configuration file?
The Caddyfile location depends on how Caddy starts:
- With the standard Linux systemd service, the usual path is
/etc/caddy/Caddyfile. Checksystemctl cat caddyif the service was customized. - In the official Docker image, the configuration path is
/etc/caddy/Caddyfileinside the container. In the Compose example below, edit./conf/Caddyfileon the host. - When starting Caddy manually, pass
--configexplicitly. ACaddyfilein the current working directory can be loaded automatically, but a file elsewhere is not discovered just because it has that name. - With an API-managed deployment, the active configuration can differ from a saved Caddyfile. Decide which configuration source your deployment owns before reloading it.
For a standard Linux installation, check the version and validate the file before reloading:
caddy version
sudo caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile &&
sudo systemctl reload caddy &&
sudo systemctl status caddy --no-pagerValidation checks configuration loading and module provisioning. It does not prove that a backend is reachable, that DNS is correct, or that certificate issuance will succeed. Run an HTTP check after each deployment. The Caddy command reference explains the difference between adapting, validating, and reloading a configuration.
Start with a local Caddyfile example
For an installed Caddy binary, this example serves a local HTML file over plain HTTP. Use a new working directory, create site/index.html, and save the following as Caddyfile:
mkdir -p site
printf '%s\n' '<h1>Hello from Caddy</h1>' > site/index.htmlhttp://localhost:8080 {
bind 127.0.0.1
root * ./site
file_server
}Run from the directory containing that file:
caddy validate --config ./Caddyfile --adapter caddyfile &&
caddy run --config ./Caddyfile --adapter caddyfileIn another terminal, run curl --fail http://localhost:8080/. Stop this foreground example with Ctrl+C when finished. The explicit http:// matters: a hostname such as localhost can otherwise activate local HTTPS. This test binds only to the local machine and does not obtain a public certificate.
Deploy Caddy with Docker Compose
Prepare a project with conf/Caddyfile, site/index.html, and compose.yaml. Install Docker with the Compose V2 plugin, and confirm docker compose version works. For a public HTTPS site, point the domain's A record and any AAAA record to working addresses for this server. Allow inbound TCP 80 and 443 through the network path; UDP 443 is used for HTTP/3.
Do not open port 2019 to the internet. It is Caddy's administration API, not a public dashboard. The configuration below keeps it on the container's loopback interface and does not publish it.
Save this as compose.yaml:
services:
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./conf:/etc/caddy:ro
- ./site:/srv:ro
- caddy_data:/data
- caddy_config:/config
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8081/healthz"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
volumes:
caddy_data:
caddy_config:The 2-alpine tag is a moving example tag. Pin the exact version or image digest you have tested for a reproducible production release. Mounting the configuration directory lets Caddy see edits made by editors that replace files atomically. Compose creates the named volumes; keep the same project identity when redeploying so you reuse them. The official Docker image documentation describes these mounts.
Save this complete configuration as conf/Caddyfile:
example.com {
root * /srv
encode zstd gzip
file_server
log {
output stdout
format json
}
}
http://127.0.0.1:8081 {
bind 127.0.0.1
respond /healthz "ok" 200
respond "not found" 404
}The health endpoint is private to the container. Its HTTP response proves that Caddy can serve a request; it does not test your public DNS, certificate, application, or database. Docker's unhealthy status also does not restart a container by itself. The restart policy handles process exits. Use an external check for the actual public service and a separate application readiness check where appropriate.
Validate and start the stack from its project directory:
docker compose config --quiet &&
docker compose run --rm --no-deps caddy \
caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile &&
docker compose up -d
docker compose ps
docker compose logs --tail=100 caddy
curl --fail --show-error --head https://example.com/If the application deliberately rejects HEAD requests, use a GET request instead. A caddy version command is useful for recording a version, but it is not an HTTP health check.
Fivenines provides per-container Docker monitoring for container state and resource use when Docker monitoring is enabled on the host. Combine it with an external HTTP check to cover failures outside the container.
Validate and reload configuration changes
Edit conf/Caddyfile, validate it in the running container, then reload:
docker compose exec -T caddy \
caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile &&
docker compose exec -T caddy \
caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
docker compose logs --tail=100 caddyReload applies configuration without a routine container restart. It is not a command to force certificate renewal. If a reload fails, inspect the error and confirm the active service still responds. Keep a known working Caddyfile so you can restore, validate, and reload it. See Caddy's deployment guidance for systemd and container workflows.
Automatic HTTPS and certificate troubleshooting
For eligible public hostnames, Caddy handles certificate issuance, renewal, and HTTP-to-HTTPS redirects. The domain must validate through the chosen ACME challenge. Check both IPv4 and IPv6 DNS, port forwarding, firewall rules, and any CDN or load balancer in front of Caddy. A stale AAAA record can break a deployment even when the A record is correct.
Keep /data persistent and writable. It contains certificate material and private keys. Back it up using a consistent volume backup procedure, protect access to the backup, and test restoration into a separate environment. /config stores configuration-related state; keep your source Caddyfile under version control as well. Avoid deleting volumes during a routine redeployment.
To inspect the certificate currently served to a client, replace the domain in this diagnostic command:
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
| openssl x509 -noout -subject -issuer -datesThis prints the presented certificate's identity and dates; it is not a complete trust or hostname validation. Use a normal HTTPS client such as curl to check trusted access. The standard Caddy CLI has no list-certificates command.
For local services, tls internal uses Caddy's local CA and clients need to trust its root. Public ACME staging certificates are also untrusted by normal clients, but they belong to a different testing workflow. Wildcard certificates require DNS validation and a build containing the appropriate DNS provider module. Do not assume that provider is in the standard image. Consult the automatic HTTPS guide before changing certificate policy.
Reverse proxy configuration for applications
For a single application, the core configuration is small. When adapting the Compose stack above, replace its public site block and retain the separate private health endpoint used by the container health check:
app.example.com {
reverse_proxy backend:8080
log {
output stdout
format json
}
}Here, backend is a placeholder for a resolvable backend service. In Docker, both services must share a suitable network. localhost inside the Caddy container refers to that container, not another container or the Docker host.
Caddy handles WebSocket upgrades and common forwarding headers. Avoid copying manual X-Forwarded-For overrides into every proxy. If a CDN or another proxy is in front, configure trusted proxies using its current, complete address ranges and restrict direct origin access as required. Returning an X-Real-IP response header does not configure client IP trust. Review the reverse proxy reference for your upstream protocol and trust boundary.
Serve static files and proxy API requests
Use mutually exclusive routes so an API request is not handled by the static file server:
example.com {
encode zstd gzip
@api path /api /api/*
handle @api {
reverse_proxy backend:8080
}
handle {
root * /srv
file_server
}
}This preserves /api in the upstream request. If the backend expects the prefix removed, use an appropriate rewrite or handle_path /api/*, and handle bare /api explicitly. Test both /api and /api/health, plus a normal static URL. The handle documentation explains route exclusivity, while handle_path documents prefix stripping.
Load balance multiple backends
For two application instances with a readiness endpoint that returns HTTP 200 only when ready:
app.example.com {
reverse_proxy backend1:8080 backend2:8080 {
lb_policy round_robin
health_uri /ready
health_interval 15s
health_timeout 3s
}
}Those addresses and /ready must exist in your deployment. Test a backend failure before relying on failover. A static respond /healthz "ok" 200 in Caddy proves nothing about backend readiness. Also test session handling and shared application state when requests can reach different instances.
Serve a PHP application
Caddy can forward PHP requests to PHP-FPM; it does not execute PHP itself. A basic site looks like this:
php.example.com {
root * /srv/public
php_fastcgi php:9000
file_server
}The PHP-FPM service must exist, be reachable privately, and see the application files at compatible paths. Do not expose its FastCGI port publicly. Caddy does not process Apache .htaccess files: translate required rewrite and access rules into Caddy configuration, then test them. See the php_fastcgi documentation for application-specific routing.
Production security and performance choices
Start with the smallest configuration your application needs, then add settings whose behavior you can verify:
- Keep the admin API private. The default endpoint is
localhost:2019; disabling it also prevents the normal API-based reload workflow. - Serve only the intended public directory. Leave directory browsing off unless listing files is a deliberate feature, and keep secrets outside the document root.
- Enable HTTP request logging deliberately and set retention at the container or log collector layer. Review URLs and application logs for sensitive data.
- Add response headers according to the application. Introduce HSTS only after reliable HTTPS is established; do not blindly add
includeSubDomainsorpreloadto every domain. Design Content Security Policy around the resources the app actually needs. - Apply request body limits to the routes that need them, including upload and API behavior in your tests.
- Rate limiting and DNS provider integrations may require additional modules. Check
caddy list-moduleson the actual deployed build before using a plugin's configuration syntax.
For example, this complete proxy configuration limits request bodies to 10 MB. Adjust it to your application's upload requirements:
api.example.com {
request_body {
max_size 10MB
}
reverse_proxy backend:8080
}A limit is not authentication, a WAF, or a substitute for application validation. Check the request_body reference and your application behavior on oversized requests. The documented rate_limit module is a non-standard module, not a directive you can assume exists in the stock image.
Compression with encode zstd gzip is a reasonable starting point, then measure CPU cost and response size for your own workload. Long-lived immutable browser caching fits content-hashed asset filenames; it can leave users with stale files when URLs are reused. Do not apply a one-year policy to every JavaScript or CSS file without a versioning strategy. Consult the encoding, file serving, and header references when adding these policies.
There is no useful universal RAM or CPU requirement for all Caddy deployments. Measure resident memory, CPU, concurrent connections, traffic, compression cost, and upstream latency under representative load. Tune timeouts to the application, especially for streaming, uploads, and WebSockets. Timeout settings belong under servers { timeouts { ... } } in the global block, not in an arbitrary top-level timeouts block. See the global options reference.
A slow Caddy response can originate in the host as well as the web server. FiveNines’ Linux server monitoring tracks CPU, memory, disk I/O and process resource use, giving you host-level context to compare with your Caddy logs.
Monitor Caddy, the application, and the host
Use three complementary checks: an external request to the public service, Caddy's own logs or metrics, and host/container resource monitoring. A green process indicator cannot establish that users can complete requests.
Read access and runtime logs
The Compose configuration above writes JSON access logs to standard output and rotates container logs. Read recent output with:
docker compose logs --tail=100 caddy
docker compose logs --follow caddyFor the standard systemd service, use sudo journalctl -u caddy --since "15 minutes ago" --no-pager. Check request status, duration, upstream errors, and TLS-related runtime messages. Access logging is enabled by the site's log directive; it is distinct from Caddy's runtime logging. See the log directive reference.
Enable real Prometheus metrics
To collect HTTP server metrics, add metrics to the single global block before your site blocks. This minimal proxy example shows its placement; retain the private health site when using the Compose health check:
{
metrics
}
example.com {
reverse_proxy backend:8080
}Caddy exposes metrics at /metrics on the admin endpoint. With the Compose example's default admin binding, inspect them from inside the container:
docker compose exec -T caddy wget -q -O - http://localhost:2019/metricsA Prometheus container cannot reach that endpoint through its own localhost. For a separate collector, configure a dedicated metrics listener on a controlled private network and scrape it there; do not publish the administration API just to get metrics. A placeholder HTTP response saying "metrics data here" is not a metrics exporter. The Caddy metrics guide and metrics handler reference explain the available endpoints.
Track request rate, error ratio, latency, and metric freshness. Generate representative requests before expecting request-series data, and alert on failed collection separately from application errors.
Connect the service to Fivenines
Fivenines has a Caddy monitoring option in the host settings, with an Admin API URL. Enable it for the relevant host and verify that the agent can reach the configured endpoint. This integration reads Caddy's administration API for configuration and upstream information; it is distinct from scraping the Prometheus HTTP metrics above.
For a host-installed Caddy, a local endpoint can be suitable. With Docker, the host and container have different loopback interfaces, so the default container-only endpoint is not automatically reachable by a host agent. Arrange a controlled private access path before enabling the integration. Keep the administration endpoint off the public internet and verify that the data appears after setup.
Track uptime across your Caddy fleet with Fivenines today.
Troubleshoot common Caddy failures
Configuration errors or changes not appearing
Confirm that you edited the mounted file and validated with the deployed build. Run caddy list-modules when a directive requires a plugin. For Docker, mount the configuration directory, then inspect reload logs. Keep file-managed and API-managed configuration changes coordinated so one does not overwrite the other.
HTTPS or certificate issuance failures
Check A and AAAA records, external reachability, ACME errors in runtime logs, and persistent storage permissions. A CDN may terminate TLS before the request reaches your origin, so distinguish the certificate at each hop. Use an ACME staging environment for repeated issuance tests, not as a public production certificate.
Reverse proxy returns 502
Check backend DNS, the target port, the application's listening address, and its logs. Test connectivity from Caddy's network environment. A service listening only on its own loopback address may be unreachable from another container. Validate the upstream HTTP or HTTPS scheme and, for TLS upstreams, the expected certificate name and trust chain.
Container is healthy but the application is down
The private Caddy liveness endpoint checks only Caddy. Test an actual application URL or readiness endpoint that reflects the dependencies required to serve users. Monitor the public path independently so DNS, TLS, routing, and upstream failures are visible.
High availability and migration checklist
Two backends behind one Caddy instance protect against some application failures; the Caddy instance and its host remain a single point of failure. High availability needs multiple proxy instances behind a load balancer or another tested traffic distribution mechanism, working health checks, and consistent configuration.
Plan certificate management across the instances. A local Docker volume on each host is not shared storage. If you use a shared storage module, confirm that it supports the coordination Caddy requires, test permissions and failure recovery, and account for certificate authority limits. Test losing an entire proxy host, not just a backend process.
For an Apache or NGINX migration, inventory redirects, access rules, upstream headers, uploads, WebSockets, and cache behavior before translating configuration. Test representative URLs and error cases on the new stack, keep the previous configuration available for rollback, then watch errors and latency during the cutover. Add capacity only after you can measure what is limiting the current deployment.