Skip to content

Directives

📌 TLS examples below use ./certs/ in the working directory. Supply your own certificate, matching private key, or client CA file there; validation reads these files too.

Each entry opens with a fixed header: the syntax, the default when the directive is absent, and where the directive may appear. It then describes what the directive does, what it refuses, and where it differs from Caddy.

📌 This page describes v0.2.2. Behavior that changed from the 0.1.x line and the 0.2.0 release candidates is marked Changed in 0.2.0; Upgrading collects those changes in one place.

📖 The page covers a subset of the language. A directive that is not listed here is still checked by pingclair validate, and a directive the server does not implement is refused by name rather than accepted and ignored.

Six of Caddy’s standard directives are refused by name today, with map the one most configurations actually miss: it derives a value once and reuses it, and there is no mechanical rewrite into what this server accepts. Also refused are tracing (OpenTelemetry spans), push (HTTP/2 server push), invoke (calling a named route), and the two access-log directives log_append (adding a field to a record) and log_name (choosing the logger per request). Each refusal names the directive, so a migration meets a sentence rather than a silent no-op.

Syntax: basic_auth [<matcher>] [bcrypt|argon2id [<realm>]] {
<username> <hashed_password>
...
}
Default: no authentication
Context: site block, handle, route

Requires HTTP Basic credentials before the request goes further. Each block line is one account: a username and a password hash, never the password itself. pingclair hash-password produces the hash (Command line).

The algorithm on the directive line is the one every hash in the block is checked against, and it defaults to bcrypt. Any other algorithm name is refused, and so is a basic_auth without a block. A line whose hash is not a valid hash of the declared algorithm, including a plaintext password, is refused at load.

A basic_auth written with a matcher protects every route that answers the requests it matches, including a respond or reverse_proxy that the directive order ranks ahead of it. A redir still answers before it, as in Caddy.

http://:8080 {
basic_auth /admin/* {
alice $2b$04$aKz8E/FgvYZuyOZpoHXKJuenUlormXHm8m7WJff0S8hMu7ehuMY7i
}
respond "ok"
}
Syntax: bind <host>
Default: every interface ([::]), or the global default_bind
Context: site block

Restricts every listener of the site to one host address. The host replaces the host part of each address the site listens on, for TCP, TLS, and HTTP/3, and pingclair adapt shows the resulting addresses. An IPv6 host is written with or without brackets and is bracketed in the listener: bind ::1 listens on [::1]:443.

The automatic HTTP redirect listener of an HTTPS site also listens on the bind host, so it is not reachable through other interfaces.

Two site blocks may share a port when each binds a different interface. The bound interface is part of what makes a site distinct, so http://:8080 { bind 127.0.0.1 } and http://:8080 { bind [::1] } are two sites, each reachable only on its own interface.

Refusals:

  • More than one address is refused: `bind 127.0.0.1 ::1` names 2 addresses, and this build binds one. Use one address, [::] for every interface, or one site per interface.
  • A bound site that shares its port with a site listening on every interface is refused, because one port is one socket and the bound site would become reachable on interfaces bind excluded. Bind every site on that port to the same address, or move one of them to another port.

Differences from Caddy: Caddy binds every listed address. Pingclair puts each listener on one interface and refuses the second address instead of ignoring it.

Changed in 0.2.0: bind applies to a site with an explicit address or port, such as http://example.test:8080. Before, it applied only to a site without an address of its own, and the site listened on every interface.

http://example.test:8080 {
bind 127.0.0.1
respond "loopback only"
}
Syntax: reverse_proxy <upstream> {
cache {
ttl <duration>
max_size <bytes>
}
}
Default: disabled; max_size 134217728 when enabled
Context: reverse_proxy block

This Pingclair option enables the H1/H2 proxy response cache. ttl is required and supplies a fallback lifetime; max_size is a positive integer byte budget shared by all caching routes. Conflicting budgets, zero, and unknown options are refused. Reload resizes the store, evicts immediately when shrinking, and drains it when caching is removed.

Freshness includes upstream Age, apparent age from Date, and response delay. Expires is measured relative to Date. Every Vary field line and the request fields it names participate in variants; invalid Vary and Vary: * prevent storage. Request no-cache and no-store are recognized by directive name across all field lines. SSE and flush_interval -1 bypass admission.

http://:8080 {
reverse_proxy 127.0.0.1:3000 {
cache {
ttl 30s
max_size 134217728
}
}
}

Without a usable origin lifetime, ttl applies only to 200. Silent 404 and 410 responses live for at most ten seconds or the shorter TTL; silent server errors are not stored. 206, 428, 429, 431, and 511 are never stored. The cache keeps origin bytes and compresses per client afterward. Hits and misses both apply header_down. HTTP/3 does not use this response cache.

Syntax: encode [*] [<format> ...]
encode [*] {
gzip [<level>]
zstd
minimum_length <bytes>
match {
status <codes...>
header <field> [<value>]
}
}
encode off
Default: no compression
Context: site block

Compresses responses. Formats are listed in preference order: when a client accepts several with the same quality, the first listed format is selected. The supported formats are zstd and gzip, and a bare encode means gzip. encode off turns compression off for the site.

  • gzip <level> sets the gzip level, 1 to 9. The default level is 5. zstd uses level 3.
  • minimum_length is the smallest body that is compressed; the default is 512 bytes.
  • match limits compression to responses with these statuses or headers. An explicit match block replaces the default content-type list.

What compression does to a response:

  • Responses on a site with encode carry Vary: Accept-Encoding, including the ones sent uncompressed. Compression adds Accept-Encoding to an existing Vary field instead of replacing it.
  • A proxied response with a strong ETag receives a weak validator when it is re-encoded, because the encoded bytes differ from the origin’s.
  • Cache-Control: no-transform on the request or on the response disables compression for that response.
  • Accept-Encoding: * does not select a coding; the client receives the uncompressed body unless it names gzip or zstd.
  • A static file larger than 8 MiB is sent uncompressed. Use a precompressed sidecar (file_server { precompressed }) to serve a large file compressed.

Static and proxy encoding use the same default content-type allow-list. identity participates in quality negotiation. Partial and bodiless proxy responses are not compressed; transformed responses drop obsolete digest fields and H3 integrity trailers. A compression failure aborts the response rather than appending plaintext.

Refusals:

  • encode br is refused at load time. The proxy has no streaming Brotli encoder, and the server does not automatically substitute gzip: `encode br`: Brotli is not implemented for proxied responses; use `encode zstd gzip`.
  • An unknown format, or an unknown setting inside the block, is refused.
  • encode off with a block is refused.
  • A matcher is refused: encode is configured per server here, not per route, so encode @compressed gzip has no meaning to honour. Move those paths into their own site block, or drop the matcher.
  • A path or named matcher is refused, because compression is set per site, not per route. The * matcher, which matches everything, is accepted.

Changed in 0.2.0: a site compresses responses only when encode is configured, as in Caddy. A site that relied on the earlier gzip default must add encode gzip or encode zstd gzip. The block settings take effect; earlier releases compiled every block as plain gzip.

example.com {
encode {
zstd
gzip 6
minimum_length 1024
}
file_server
}
Syntax: file_server [<matcher>] [<root>] [browse]
file_server [<matcher>] [<root>] {
root <path>
index <filenames...>
browse {
file_limit <n>
}
compress [off|false]
precompressed [br|zstd|gzip ...]
hide <paths...>
status <code>
pass_thru
disable_canonical_uris
etag_file_extensions <extensions...>
}
Default: disabled
Context: site block, handle, route

Serves files from disk. It detects the MIME type, answers byte-range and conditional requests, and sends ETag and Last-Modified. The files come from the site root set by root, or from a root given to this directive alone.

The ETag is Caddy’s: "<mtime in base36>-<size in base36>", from the file’s nanosecond modification time and its size, so a site moved between the two servers keeps the validators its clients and CDN already hold. Each representation carries its own tag — a gzip sidecar’s ends -sidecar-gzip, a live-compressed body’s -gzip-<level> — because their bytes differ. A file with an .etag sidecar (see etag_file_extensions in the file-server options) is validated by that value instead.

  • index names the files tried for a directory; the default is index.html. An index must be a relative filename.
  • browse renders a listing for a directory that has no index file. file_limit caps the entries shown; the default ceiling is 10,000.
  • compress off exempts this file server on a site that otherwise compresses.
  • precompressed serves a sidecar such as app.js.gz when the client accepts that coding. With no arguments, the order is br zstd gzip. Sidecars are served only when this option is present. While the client accepts the coding, a range request is served from the sidecar’s bytes, so Content-Range and ETag describe the compressed representation.
  • hide keeps the named paths from being served or listed. A pattern without a / hides any path component of that name (.git hides /a/.git/b); a pattern with a / is a path under the root. Repeated lines are combined.
  • status answers every file with this status, for a maintenance page.
  • pass_thru passes a missing file request to the next handler instead of answering 404.
  • disable_canonical_uris stops the redirect that adds a trailing slash to a directory.

Conditional requests follow RFC 9110: a matching If-None-Match or a current If-Modified-Since receives 304, a failed If-Match or If-Unmodified-Since receives 412, and a Range with an If-Range that no longer matches receives the whole file with 200. Methods other than GET and HEAD are answered 405 with Allow: GET, HEAD. Static responses carry Vary: Accept-Encoding whether or not the site compresses.

Refusals: fs is refused, because only the local file system is supported. A status outside 100–599, an unknown subdirective, an index that is absolute or contains .., a browse template, reveal_symlinks, sort, and a hide pattern whose [ set never closes are refused.

Differences from Caddy: the positional <root> argument is a Pingclair addition. In Caddy, a bare path after file_server is a path matcher. Prefer root when the configuration must also load in Caddy.

Changed in 0.2.0: ETag values are built from the nanosecond modification time and differ per content coding, so every static ETag changes once on upgrade and caches revalidate each file one time.

localhost:8080 {
file_server ./public
}

Precompressed sidecars use their own size and modification time for ETags and take precedence over cached live compression; gzip validators include quality. Ranges stream in bounded identity chunks. Static body caches share byte budgets and a 16,384-entry ceiling, including empty files. Canonical redirects clean the path, escape backslashes, and preserve the query rather than forming a reference to another host. A configured ETag header is the validator revalidation compares against.

Syntax: forward_auth <upstream> {
uri <path>
copy_headers <fields...>
transport http {
tls
tls_server_name <name>
tls_trusted_ca_certs <files...>
tls_client_auth <cert> <key>
tls_insecure_skip_verify
}
}
Default: no authentication subrequest
Context: site block, handle, route

Makes a bodyless GET subrequest to the authentication service with the original method and URI. A 2xx copies the configured identity headers before continuing; other responses stream to the client. Each destination header is removed before copying, even when renamed. transport http accepts only the listed TLS options. Unsupported options, missing client key pairs, and combining custom CAs with skipped verification are refused. Keep certificate verification enabled unless disabling it is an explicit requirement.

http://:8080 {
forward_auth https://auth.example.com {
uri /check
copy_headers Remote-User
transport http {
tls_server_name auth.example.com
}
}
reverse_proxy 127.0.0.1:3000
}
Syntax: handle [<matcher>] {
<directives...>
}
Default: none
Context: site block, handle, route, handle_errors

Groups directives into one route. Sibling handle blocks are mutually exclusive: only the first matching block runs, even when that block writes no response. Blocks with a path sort the way routes do (see Which route answers), and a handle with no matcher is the fallback. Every directive inside one block runs, in directive order.

The matcher token is *, a path that starts with /, or a named matcher (@name). Anything else is refused: expected at most one matcher (`*`, a path starting with `/`, or `@name`) before the block, got `*.php` .

Changed in 0.2.0: an unrecognized token used to be dropped, so handle *.php { … } answered every request on the site. Write @php path *.php and handle @php { … }.

example.com {
@php path *.php
handle @php {
respond "PHP" 200
}
handle {
respond "Not PHP" 200
}
}
Syntax: handle_errors [<status|Nxx> ...] {
<directives...>
}
Default: the built-in error text, or the site's error_page
Context: site block

Runs a route when the request ended in an error status. The arguments are exact three-digit statuses or Nxx ranges, combined with OR; with no arguments the block catches every error. Inside the block, {err.status_code} and the other {err.*} placeholders describe the error.

Errors reach the block when a handler raises them (error, or file_server with a missing file) and when the server produces them itself: a reverse_proxy that cannot reach its upstream (502, 503, 504), a body over the request_body limit (413), and a body that stops arriving (408). A request refused before routing — an oversized header block, today — never reaches the block; it is answered with the built-in refusal, which names the field that was too large. error_page <status> … still supplies that body.

  • root inside the block sets the error route’s own document root; it may appear anywhere in the block. A matcher-scoped root @name … is refused.
  • A file_server inside the block serves from the error route’s configuration. The page is returned with the error status, and the failed request’s Range and validators are ignored, so an error never becomes a 206 or a 304.
  • An error raised inside an error route is answered directly instead of running the error routes again.
  • A reverse_proxy inside the block is refused at load. The upstream exchange runs outside the handler chain here, so the handler would compile and then answer nothing; proxy in the site route and render its errors here with respond or file_server. Everything else in the block is an ordinary route body: directives run in Caddy’s order, @name matchers work, and rewrite uses the block’s own compiled patterns.

Changed in 0.2.0: gateway and body-size errors reach handle_errors, and a file_server in an error route serves its page instead of the error text. A catch-all handle_errors { … } therefore also answers 502, 504, and 413; Specify status codes to restrict the errors handled. Its gateway answers carry no Proxy-Status field, which the built-in gateway error does.

example.com {
reverse_proxy 127.0.0.1:3000
handle_errors 502 503 504 {
root * /srv/errors
rewrite * /{err.status_code}.html
file_server
}
}
Syntax: handle_path <path-matcher> {
<directives...>
}
Default: none
Context: site block, handle, route

Works like handle, and also removes the matched path prefix before the directives inside run: handle_path /api/* forwards /api/users as /users. The prefix comparison ignores ASCII letter case, like the route that chose the block, so handle_path /API/* also strips /api from /api/users. The same matcher-token rule as handle applies.

example.com {
handle_path /api/* {
reverse_proxy 127.0.0.1:3000
}
}
Syntax: header [<matcher>] <field> [<value> [<replacement>]]
header [<matcher>] {
<field> <value> # set
+<field> <value> # append
-<field> # remove
?<field> <value> # set only if absent
<field> <search> <replacement> # regular-expression replace
match {
status <codes...>
header <field> [<value>]
}
defer
}
Default: none
Context: site block, handle, route

Changes response headers. A bare field name sets the header, a + prefix appends a value, and a - prefix removes the field. A ? prefix sets the value only when the response does not already carry the field. With three arguments, the second is a regular expression and the third replaces what it matches.

Headers are always applied to the finished response, so defer and the > prefix are accepted and change nothing. A match block inside a header block makes the whole block conditional on the finished response’s status (404, 2xx) or headers.

Refusals:

  • A directive that has both arguments and a block is refused.
  • header X-Name with no value is refused. Caddy would set an empty value, but an empty response header is ambiguous with an intended header removal.
  • A field value containing CR, LF, or NUL, or a field name that is not a valid token, is refused at load (RFC 9110 §5.5).
  • A match block inside handle_response { header { … } } is refused.

⚠️ There is no set keyword. A block line set X-Name value is read as a regular-expression replace on a header named set.

Differences from Caddy: a bare field name replaces the value the response already carries, while Caddy’s header X-Name value without defer adds a field line beside it. nginx does the same — add_header pushes a field onto the response without touching the upstream’s — and has no general replace directive either: replacing an upstream field there is proxy_hide_header plus add_header. An upstream that answered with X-Name: from-upstream therefore keeps that line under both servers and loses it here. Write +X-Name to keep both field lines, or ?X-Name to set the value only when the response has none.

Strict-Transport-Security is sent only on encrypted responses and is removed from every plaintext one, as RFC 6797 requires. Writing header Strict-Transport-Security "max-age=…" is the way to turn HSTS on.

example.com {
header {
X-Frame-Options "DENY"
X-Content-Type-Options "nosniff"
Strict-Transport-Security "max-age=31536000; includeSubDomains"
-X-Powered-By
}
}
Syntax: limits {
header_timeout <duration>
body_timeout <duration>
idle_timeout <duration>
request_timeout <duration>
max_headers <count>
max_header_bytes <bytes>
max_connections <count>
upload_bytes_per_sec <bytes>
download_bytes_per_sec <bytes>
long_connections {
idle_timeout <duration|off>
request_timeout <duration|off>
}
}
Default: header_timeout 60s; a body may pause 60s between reads
Context: site block

Sets the resource limits of a site’s connections and requests. limits is a Pingclair directive; Caddy has no equivalent.

  • header_timeout bounds the whole request header, from the moment the connection is accepted, or the previous keepalive request ends, to its last byte. The default is 60 seconds. An idle HTTP/1 keepalive connection is therefore closed after 60 seconds without a request, and an HTTP/3 request stream whose header is incomplete after that time is reset with H3_REQUEST_INCOMPLETE.
  • body_timeout is the longest pause allowed between two reads of a request body. Without it, the pause is bounded at 60 seconds. A client that stops sending is answered 408; an HTTP/2 upload through reverse_proxy has its stream reset instead. WebSocket and immediate-flush routes keep only a configured value.
  • long_connections overrides idle_timeout and request_timeout for WebSocket and streaming routes; off removes the deadline.

Changed in 0.2.0: both 60-second defaults are new. A client that is quiet for longer on purpose, such as a gRPC client stream, needs an explicit body_timeout, or flush_interval -1 on its route.

example.com {
limits {
header_timeout 30s
body_timeout 2m
}
reverse_proxy 127.0.0.1:3000
}
Syntax: listen [http://|https://]<address> [proxy_protocol]
Default: the listeners named by the site address
Context: site block

Adds a listener to the site. listen is a Pingclair directive, read the way nginx reads its own: listen 127.0.0.1:8080 and listen [::1]:8080 bind that address; listen :8080, listen 8080, and listen *:8080 bind every interface; and an address without a port takes the HTTP port, or the HTTPS port with https://. proxy_protocol requires a PROXY protocol header on that listener. A site whose listen names an address does not inherit default_bind.

Refusals: a hostname (listen binds and never resolves), an IPv6 address without brackets, a port that is not a number from 0 to 65535, an unknown flag, and an address that disagrees with the site’s bind.

Changed in 0.2.0: listen keeps the address it names. Earlier releases kept only the port, so listen 127.0.0.1:8080 listened on every interface. Write listen :<port> to keep listening everywhere.

http://:8080 {
listen 127.0.0.1:9090
respond "two listeners"
}
Syntax: log [<name>] [{ <options> }]
Default: no access log
Context: site block; global options

Writes an access log. The site-level forms mean different things:

  • log enables the default access log for the site, on standard output.
  • log { … } configures the site’s access log.
  • log <name> { … } adds a named logger to the site, with its own output.
  • log <name> sends the site’s records to a channel of that name declared in the global options with log <name> { … }.

In the global options block, an unnamed log { … } configures the server’s own process log instead: output file <path>, output stdout, output stderr, format json|text, and level. A file sink is created with mode 0600 if it is missing. RUST_LOG takes precedence over a configured level, and the startup banner stays on standard output.

Access logging is a property of the listener, as it is in Caddy: one site’s log turns records on for every request that listener serves, and a listener whose sites never mention log writes none. A request whose Host matches no site — or one refused before routing — is written to the process log, which is where the default access logger goes.

Block options include output (stdout, stderr, or file <path>), format (json or console), level, the hostnames selector, include and exclude filters, sampling, and file rotation (roll_size, roll_keep, roll_keep_for, mode, dir_mode, and the other roll_* options). Each JSON access record carries a ts field: seconds since the Unix epoch at which the request began.

The JSON record’s shape is this server’s own, not Caddy’s envelope, so a pipeline written against a Caddy deployment reads different keys. The two are one table:

Caddy Here Note
ts ts the same value and shape: Unix seconds with a fraction, at request start
.request.uri .path the same value, hoisted
.request.method .method hoisted
.request.host .host hoisted
.request.proto .protocol renamed
.request.client_ip .client_ip hoisted; the trusted-proxy policy decides it
.request.headers .request_headers hoisted; field names are lower-cased
.resp_headers .response_headers renamed
.size .bytes the same quantity: response body bytes
.duration .duration_ms this one is milliseconds, the unit is in the name, and sub-millisecond requests keep their fraction
.status .status the only key the two schemas already share
— .ttfb_ms time to first byte in milliseconds; no Caddy equivalent
.bytes_read — request-body bytes are not logged
.level, .logger, .msg — Caddy’s log envelope; here the record is the whole object

Refusals: a global channel may not use hostnames, because it is not attached to a site. A channel declared twice is refused.

Records are batched before they are written. A sink that cannot keep up drops records and counts them in pingclair_access_log_dropped_total.

example.com {
log {
output file /var/log/pingclair/access.log
}
}
Syntax: metrics [<matcher>] [{ disable_openmetrics }]
Default: no metrics route
Context: site block, handle, route

Serves the Prometheus scrape endpoint from a site route, so a scraper can read the numbers without access to the Admin API. The route has the same access restrictions as the site; on a public site, restrict it with a matcher or basic_auth.

The route serves the numbers only while collection is on, which the global metrics option controls (see Global options). With collection off, it answers 200 with an empty body.

The exposition is Prometheus text (text/plain; version=0.0.4; charset=utf-8) and stays that whatever the client’s Accept header says: this build does not negotiate OpenMetrics, so disable_openmetrics is accepted and describes the behaviour already in force rather than turning something off. A scraper that asks for OpenMetrics receives the Prometheus text, which such scrapers read.

{
metrics
}
http://:9180 {
bind 127.0.0.1
metrics /metrics
}
Syntax: php_fastcgi [<matcher>] <upstream...> {
root <path>
split <suffix...>
index <filename|off>
try_files <candidates...>
env <name> <value>
resolve_root_symlink
dial_timeout <duration>
read_timeout <duration>
write_timeout <duration>
capture_stderr
}
Default: disabled; split .php; index index.php
Context: site block, handle, route

Expands file matching and rewriting into a FastCGI proxy. The upstream is a FastCGI service such as PHP-FPM. Reverse-proxy options are also accepted where supported. Request-body buffering policy reaches the FastCGI transport; script paths retain non-UTF-8 filename bytes, and repeated Cookie lines are combined. HEAD sends no body, download pacing applies, parameters too large for a FastCGI record return 431, and truncated or malformed bodies abort the response.

A request that declares no body length — a chunked upload, or a bodyless POST — is read and measured here, up to the route’s request_buffers and this server’s own buffering ceiling when the route set none; a lengthless body above that ceiling receives 413.

http://:8080 {
root * /srv/php
php_fastcgi 127.0.0.1:9000 {
read_timeout 30s
write_timeout 30s
}
file_server
}
Syntax: request_body [<matcher>] {
max_size <size>
read_timeout <duration>
write_timeout <duration>
set <body>
}
Default: no size limit
Context: site block, handle, route

Bounds or replaces the request body.

  • max_size refuses a larger body with 413, whether its length was declared or it streamed past the limit. Sizes follow the SI/IEC split: 10MB is 10,000,000 bytes and 10MiB is 10,485,760.
  • read_timeout and write_timeout bound reading the body and writing the response for this route. A stalled upload is answered 408.
  • set replaces the body with the given text, after placeholders are expanded. The client’s own bytes are discarded as they arrive rather than buffered.

A site-level request_body without a matcher applies to every request in the site, including requests answered inside handle blocks. A request_body inside a handle overrides it for that route.

Changed in 0.2.0: there is no request-body limit unless one is configured. Earlier releases refused proxied bodies over 1 MiB by default.

example.com {
request_body {
max_size 10MB
}
reverse_proxy 127.0.0.1:3000
}
Syntax: reverse_proxy [<matcher>] <upstream> [<upstream> ...]
reverse_proxy [<matcher>] [<upstream> ...] { ... }
Default: none
Context: site block, handle, route

Forwards requests to one or more upstreams. lb_policy chooses how requests are spread over them; the default is random, which is Caddy’s default. lb_policy first sends every request to the first available upstream.

A hostname upstream is resolved again on the interval set by the global dns_refresh, so a backend that restarts on a new address is followed without a reload. A failed lookup keeps the previous address in rotation.

header_up and header_down edit the request on its way to the upstream and the response on its way back. Both take the same four shapes as the header directive, with the same meaning: X-Name value sets, +X-Name value appends, -X-Name removes, ?X-Name value sets only when the field is absent, and >X-Name find replacement rewrites an existing value by regular expression. Values are templates, so header_up X-Real-IP {client_ip} forwards the address the trusted-proxy policy resolved.

The origin’s own Server field line reaches the client unchanged: Caddy sets its own before the handler chain and the proxy’s copy of the upstream headers replaces it, and this server follows that rule, adding Server: Pingclair only to responses it generates itself. Via is appended, not replaced, and names this intermediary (1.1 Pingclair) after whatever chain the response already crossed. Responses also carry X-Request-Id, which the server generates for every request it handles.

Active health checks probe each upstream out of band. A failed upstream leaves rotation before a user request reaches it, and rejoins after the configured number of successful probes. A backup upstream is used only when every primary upstream is unavailable. An upstream weight of 0 drains it: it receives no requests.

Configure timeouts in a transport http block: connect_timeout (Caddy’s dial_timeout), first_byte_timeout (Caddy’s response_header_timeout), read_timeout, and write_timeout. lb_try_duration limits how long after the request arrived a new attempt may start; it does not terminate an active response.

An upstream 103 Early Hints reaches an HTTP/1.1 client before the final response. The HTTP/2 path drops interim responses inside the proxy library (pingora-core 0.9.0), and the HTTP/3 path skips them by design.

A 502 or 504 that Pingclair generates itself carries Proxy-Status: pingclair; error=…, on the built-in error path. Custom handle_errors responses omit it too; absence alone does not identify a backend response. Once the upstream may have seen a request, an automatic retry repeats only idempotent methods.

Refusals:

  • An unknown option is refused with its full name, such as Unknown directive 'reverse_proxy: dial_timeout'.
  • A weight above 100 is refused, and so is a pool in which every primary upstream has weight 0.
  • The transport http options without an equivalent in this build (read_buffer, write_buffer, max_conns_per_host, keepalive_interval, and the others Caddy inherits from Go’s HTTP client) are refused by name.

Changed in 0.2.0:

  • The default lb_policy is random; write lb_policy round_robin to keep the earlier alternation.
  • lb_try_duration no longer terminates a slow response or a long event stream. Bound a slow backend with first_byte_timeout or read_timeout instead.
  • Behind trusted_proxies, {remote_host} is the connection’s peer and {client_ip} is the client. header_up X-Real-IP {client_ip} forwards the client.
:80 :8080 {
reverse_proxy {
lb_policy least_conn
to 10.0.0.1:8080 {
weight 3
}
to 10.0.0.2:8080
to 10.0.0.3:8080 {
backup
}
health_check {
path /health
interval 5s
timeout 2s
status 200 204
consecutive_failure 3
consecutive_success 2
}
}
}

The reverse proxy guide explains each option.

request_buffers <size|unlimited> and response_buffers <size|unlimited> buffer before forwarding, then stream the remainder after the ceiling. Here unlimited still has an 8 MiB memory ceiling. Sizes use SI/IEC units, so 1MB and 1MiB differ. Request buffering also reaches FastCGI. A FastCGI request that never declared a Content-Length — a chunked upload, or an HTTP/2 or HTTP/3 body — is read so its length can be measured before PHP-FPM sees it, which makes the ceiling a hard limit for that shape: a body past it is answered 413 instead of being forwarded as a body PHP-FPM would read as empty. flush_interval -1 flushes immediately and bypasses response-cache admission.

Syntax: root [<matcher>] <path>
Default: none
Context: site block, handle_errors

Sets the site root: the directory that file_server, try_files, and the other file-handling directives resolve paths against. file_server can take a root of its own, but setting it here keeps every directive pointed at one location. Inside handle_errors, root sets the error route’s own root.

example.com {
root * /srv/public
file_server
}

Refusals: root inside an ordinary handle or route is not supported. Path-scoped and named-matcher roots are also refused. Use root * <path> at site level or inside handle_errors.

Syntax: route [<matcher>] {
<directives...>
}
Default: none
Context: site block, handle, route

Runs the directives inside in the order they are written, instead of the directive order. Use it when a narrower directive must run ahead of a broader one that the directive order ranks first. The same matcher-token rule as handle applies.

example.com {
route {
file_server /assets/*
respond "fallback" 200
}
}
Syntax: tls internal
tls <cert_file> <key_file>
tls <email>
tls { <options> }
Default: automatic HTTPS for public names
Context: site block

Controls where the site’s certificate comes from. Without a tls line, a public hostname receives a certificate from Let’s Encrypt automatically.

Form Behavior
tls internal Issues from a persistent local certificate authority: a root, and an intermediate that signs the 90-day leaves. Clients must trust its root; pingclair trust installs it.
tls <cert> <key>, or cert and key in the block Uses certificate and key files issued elsewhere. validate reads both files and refuses a pair that does not match.
tls <email> Sets the ACME account email and keeps automatic issuance.
tls { auto } Obtains a public certificate over ACME and renews it, which is also the default for a public name.

The block also accepts acme_email (or email), http3, default_sni, client_auth, renewal_window_ratio, and the DNS-01 options (dns, resolvers, dns_ttl, propagation_delay, propagation_timeout, dns_challenge_override_domain).

Every address of a site has a certificate: tls internal issues one leaf per name, and a tls <cert> <key> pair answers for each of the site’s names. A *.example.com site orders one wildcard certificate, which covers exactly one label.

http3 off takes this site out of HTTP/3: its QUIC handshake is refused and its responses do not advertise HTTP/3 in Alt-Svc, while other sites on the port keep it. It does not create or remove the QUIC listener; the global servers { protocols … } list decides that (TLS: what you can tune).

client_auth follows the most specific site for the name the client sent, as the certificate does: an exact site without client_auth does not request a client certificate even when a wildcard site on the same port does. A client certificate whose usage extensions exclude client authentication is refused.

client_auth also accepts verifier leaf file <paths...>, verifier leaf folder <directory>, or a block containing leaf loaders. It pins the presented leaf after chain verification. Folders are scanned recursively for .pem files and rescanned on reload; other verifier modules are refused.

Refusals:

  • A bare tls — no argument and no block — is refused, as Caddy refuses it: it names nothing, and a site address with a hostname already gets automatic HTTPS. Write tls internal, tls <cert> <key>, tls <email> or a tls { … } block.
  • dns accepts only cloudflare. Any other provider is refused: DNS provider `route53` is not implemented; this build ships `cloudflare` only.
  • protocols, ciphers, curves, alpn, on_demand, key_type, issuer, and the other Caddy options not listed above are refused by name.
  • tls internal cannot be combined with auto, an ACME email, or certificate files.
  • Certificate files on a site with no name, or on the _ site, are refused.

Changed in 0.2.0: the internal authority’s files moved to <store>/pki/authorities/local/, the layout Caddy uses. The old <store>/internal/ tree is not migrated: a new authority is created, and clients must trust its root again.

example.com {
tls {
cert ./certs/example.com.pem
key ./certs/example.com.key
}
reverse_proxy localhost:3000
}
Syntax: try_files <candidates...> {
policy first_exist|first_exist_fallback|smallest_size|largest_size|most_recently_modified
}
Default: no rewrite; policy first_exist
Context: site block, handle, route

Selects a file candidate and rewrites the request to it. Every positional argument is a candidate, including the first path; this directive takes no matcher token. Candidates use the configured root, support placeholders and globs, and retain non-UTF-8 filename bytes. =404 is an error fallback. Unknown policies and unsafe paths are refused.

http://:8080 {
root * /srv/site
try_files {path} /index.html
file_server
}
Syntax: uri [<matcher>] strip_prefix <prefix>
uri [<matcher>] strip_suffix <suffix>
uri [<matcher>] path_regexp <pattern> <replacement>
Default: unchanged URI
Context: site block, handle, route

Changes the request path. Prefix and suffix removal ignore ASCII letter case, matching path routing and handle_path. Operands resolve placeholders before the operation; regular-expression replacements use $1 for a capture. ${1} is read as the placeholder {1}, so it does not preserve the capture. Unknown operations, wrong argument counts, blocks, and invalid regular expressions are refused.

Two Caddy operations are refused by name rather than approximated: replace substitutes a substring of the path while the rewrite here replaces the whole path, and query edits the query string, which nothing here rewrites yet. The message names the operation and the reason, so the refusal is not read as a typo.

http://:8080 {
uri path_regexp ^/old/(.*)$ /new/$1
reverse_proxy 127.0.0.1:3000
}

Global options go in the unnamed block at the top of the file. Options that Caddy nests under servers { … } are accepted there.

Option Syntax Notes
acme_dns acme_dns cloudflare <token> Global Cloudflare DNS-01 credentials; other providers are refused.
client_ip_headers client_ip_headers <field> ... Ordered client-identity sources, also supported inside servers; only trusted peers may supply them.
default_sni default_sni <name> Certificate name for a client without SNI, including HTTP/3; an explicit unknown name is still refused.
ocsp_stapling ocsp_stapling off Accepted because this build does not staple OCSP. Bare and on forms are refused.
renewal_window_ratio renewal_window_ratio <ratio> Renewal window as a fraction of certificate lifetime; a site may override it in tls.
admin admin [<address> [<token>]] [{ origins …; enforce_origin }] | off Enables the Admin API; the default address is 127.0.0.1:2019. With a token, requests must send Authorization: Bearer <token>; without one, only loopback clients are admitted. Without this option, there is no Admin API (Admin API).
auto_https auto_https on | off | disable_redirects | ignore_loaded_certs Controls automatic HTTPS and the port 80 redirect. disable_redirects leaves the automatic HTTP port unbound. disable_certs is refused by name.
default_bind default_bind <host> The bind host for every site that names none and whose listen entries name no address. One address only.
dns_refresh dns_refresh <duration> | off Interval for resolving hostname upstreams again. The default is 30s. off keeps the addresses resolved at startup. A bare number is refused.
email email <address> ACME account email.
grace_period grace_period <duration> How long a graceful stop lets running requests finish. The default is 30s. The process exits as soon as the last request ends.
http_port, https_port http_port <port> The ports that scheme-only addresses (http://example.com, https://example.com) and automatic HTTPS use. The defaults are 80 and 443.
log log [<name>] { … } An unnamed block configures the process log; a named block declares an access-log channel (log).
metrics metrics [{ per_host; observe_catchall_hosts }] Turns metrics collection on. Without it, nothing is collected and the scrape endpoints answer empty. per_host adds a host label for the hosts the configuration serves.
order order <directive> first|last|before <d>|after <d> Moves a directive in the directive order.
servers servers [<address>] { … } Listener options: protocols, trusted_proxies static …, client_ip_headers, expected_underscore_headers, listener_wrappers { proxy_protocol }, and metrics. An addressed block applies to that one listener and may set only those options.
storage storage file_system <path> The directory of the TLS store. Takes precedence over PINGCLAIR_TLS_STORE. Other storage modules are refused.
trusted_proxies trusted_proxies <cidr> ... Peers allowed to state the client address in forwarding headers. Inside servers { … }, write Caddy’s spelling, trusted_proxies static <cidr | private_ranges> .... One line per scope.

Inside servers, client_ip_headers <field> ... lists the headers that may name the client, in order. Without it, the client comes from X-Forwarded-For and Forwarded, with X-Real-IP when neither was sent.

Inside servers, expected_underscore_headers <name> ... allowlists request fields whose names contain an underscore; a trailing * matches a prefix. Without the option, nothing with an underscore reaches a handler, which is Caddy’s default. An addressed servers <address> { … } block replaces the unnamed list for that one listener.

A few of Caddy’s global options are refused by name rather than accepted and ignored: acme_ca and acme_ca_root (a custom ACME directory and the CA that signs its responses), on_demand_tls (issuance driven by a client handshake), filesystem (named file systems; the local one is the only one registered) and preferred_chains (choosing which issuer chain to prefer — the ACME client takes the one the authority returns first). Each refusal names the option; preferred_chains is the one whose refusal arrives at validate rather than adapt, because the document itself converts and only the provisioning step cannot honour it. servers { timeouts { … } } is refused as well: the spelling for the same limits here is the site-level limits block.

Changed in 0.2.0:

  • CF-Connecting-IP names the client only when client_ip_headers lists it.
  • Metrics are collected only when metrics is set.
  • trusted_proxies inside servers requires the static module name, and a second trusted_proxies line in the same scope is refused.
  • bind and default_bind with more than one address are refused.
{
email admin@example.com
admin 127.0.0.1:2019
dns_refresh 30s
servers {
trusted_proxies static 173.245.48.0/20
client_ip_headers CF-Connecting-IP
}
}

📚 The CHANGELOG records the evidence and complete 0.2.0 upgrade list.

⚠️ Restart the service after changing global metrics. Applying this change through /load returns 409 restart_required. See runtime_listeners.rs.