Caddyfile Reference
Reference for the Caddyfile the Productify Proxy runs: the global options, the productify block, the productify_* directives and the caddy-security blocks they depend on. Use it to read a rendered configuration or to write one by hand for a development setup.
A production Caddyfile has two layers. The prelude (global options, the caddy-security portal and policy, the Manager's own vhost) is operator-owned and rendered by pfy platform render. The generated region holds one vhost per application domain and is rendered and pushed by the Manager. See Generated Artifacts.
Structure
{
# Global options: Caddy settings, the caddy-security portal, the productify block
order authenticate before respond
productify {
manager http://172.17.0.1:8080
token {env.PFY_MANAGER_TOKEN}
}
}
http://app.localhost {
# One site block per application domain
route /* {
productify_protect 123 with pocketpolicy
reverse_proxy backend:8080
}
}The example above is a fragment: productify_protect ... with pocketpolicy needs a security { authorization policy pocketpolicy } block, shown under Security.
Global options
Standard Caddy options the proxy relies on:
{
# REQUIRED: place caddy-security's "authenticate" in Caddy's handler order.
order authenticate before respond
# Admin API. The Manager pushes rendered config to POST /load here.
# Bind to loopback or an internal interface; never expose it publicly.
# "admin off" disables Manager-managed configuration.
admin {$PFY_ADMIN_LISTEN:127.0.0.1:2019}
# Contact email for the certificate authority (automatic HTTPS).
email admin@example.com
# Wildcard application domains need the DNS-01 challenge. Only the
# Cloudflare provider is compiled into this build.
# acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
# Development only: self-signed certificates instead of a public CA.
# local_certs
}Security (caddy-security)
The proxy uses caddy-security for the login portal and the per-route authorization check. The block below is the shape the Manager renders and the repository's Caddyfile uses. See the caddy-security documentation for the full directive set.
{
order authenticate before respond
security {
oauth identity provider generic {
# Fetch the provider's metadata in the background, with retries.
# Without these three lines Caddy fetches it while loading and
# exits if the provider is not reachable yet.
delay_start 1
retry_attempts 3
retry_interval 2
realm generic
driver generic
client_id ab098fe1-9bc0-4780-81c6-2ea17f49a3cb
client_secret {$PFY_OAUTH_CLIENT_SECRET}
scopes openid email profile
base_auth_url http://pocketid.localhost
metadata_url http://pocketid.localhost/.well-known/openid-configuration
}
authentication portal pocketportal {
# Absolute session lifetime in seconds. There is no idle timeout.
crypto default token lifetime 3600
enable identity provider generic
cookie insecure on # local HTTP only; remove for HTTPS
# Give every logged-in user the "user" role. Pocket ID issues no role
# claim, and a user with no role matches no policy.
transform user {
match realm generic
action add role user
}
}
authorization policy pocketpolicy {
set auth url /auth/oauth2/generic # where anonymous browsers are sent
allow roles user
validate bearer header
inject headers with claims # X-Token-Subject, -User-Email, -User-Name, -User-Roles
enable strip token
}
}
}Secrets: caddy-security resolves both {$VAR} (substituted when the Caddyfile is parsed) and {env.VAR} for client_secret. Use either; never write the secret into the file.
The productify global block
{
productify {
manager http://172.17.0.1:8080
token {env.PFY_MANAGER_TOKEN}
min_proxy_version 1.0.0
enable_access_log
enable_metrics
enable_size_metrics
}
}| Option | Type | Default | Description |
|---|---|---|---|
manager | URL | required | Base URL of the Manager API. Must be http:// or https:// with a host. May be given once only. |
token | string | required | Shared proxy secret, sent as X-Proxy-Auth on every Manager call and used to sign the tenant cookie. Must equal the Manager's PFY_PROXY_SECRET. {env.VAR} is expanded at start-up. |
manager_timeout | duration | 5s | Timeout for each outbound Manager call. |
auth_cache_ttl | duration | 45s | How long a successful PAT or OAuth validation is reused before the Manager is asked again. |
frontend_config_cache_ttl | duration | 60s | Cache for the frontend configuration injected into HTML. |
tenant_membership_cache_ttl | duration | 60s | Cache for the user's tenant membership set and the selector catalog. |
metrics_address | address | :2112 | Listen address for /metrics, /healthz, /readyz, /config-hash. The default binds every interface; use 127.0.0.1:2112 or an internal address in production. |
min_proxy_version | version | none | Stamped by the Manager renderer. The proxy rejects the config (and keeps the last good one) if this is newer than the plugin version it implements, currently 1.1.0. |
config_hash | string | none | Stamped by the Manager renderer. Reported back verbatim on /config-hash and as pfy_proxy_config_info{config_hash} so the Manager can detect drift. |
enable_access_log | flag | off | Write one structured log line per request that passes a productify directive. See Monitoring. |
enable_metrics | flag | off | Record pfy_total_requests and pfy_response_time_seconds. |
enable_size_metrics | flag | off | Also record pfy_request_size_bytes and pfy_response_size_bytes. Has no effect without enable_metrics. |
activate_timeout | duration | 30s | Scale-from-zero: how long an API request is held while the backend starts before it is shed with 503. |
activator_max_in_flight | integer | 100 | Scale-from-zero: maximum held requests per (application, environment); more are shed with 503 immediately. |
activator_max_wait | duration | 30s | Scale-from-zero: per-request hold ceiling; the effective hold is the smaller of this and activate_timeout. |
activator_debounce | duration | 2s | Scale-from-zero: a wake for the same (application, environment) is not re-issued within this window. |
deployment_cache_ttl | duration | 10s | Scale-from-zero: how long the cached "is this deployment scale-to-zero eligible, how many healthy backends" answer is trusted. |
wake_path | path | /api/proxy/wake | Manager path the activator calls to wake a deployment. |
ready_path | path | /api/proxy/ready | Manager path the activator polls for readiness. |
Durations use Caddy's format (500ms, 5s, 2m). An unknown option fails the load with '<name>' not expected.
There is one Manager per proxy: manager and token are global and cannot be overridden per site.
Directives
All productify_* directives are route handlers. Use them inside route blocks so their order is exactly what you wrote; Caddy's automatic directive ordering does not know about them.
productify_protect
productify_protect [<path>] <app_id> with <policy>
productify_protect [<path>] <app_id> with <policy> {
tenant <uuid> # pin this vhost to one tenant (dedicated host)
activator [<env>] { # scale-from-zero, opt-in
enable on|off # default on; off emits no activator at all
timeout <duration> # overrides activate_timeout for this route
max_in_flight <n> # overrides activator_max_in_flight
max_wait <duration> # overrides activator_max_wait
browser on|off # default on: HTML clients get a 202 waiting page
}
}Expands, in this fixed order, to productify_sanitize, productify_auth, authorize with <policy>, productify_tenant_selector, productify_oauth_validation, productify_html_inject, productify, and, only when an activator sub-block is present and enabled, productify_activator last so it wraps the following reverse_proxy. <policy> names an authorization policy from the security block. <app_id> may be a placeholder such as {vars.app_id}. tenant must be a literal, canonical 36-character UUID; a placeholder is rejected at load. Unknown sub-directives fail the load.
Prefer this directive over writing the chain by hand: it cannot drop the sanitizer or productify_oauth_validation, and it cannot reorder the steps.
productify_sanitize
productify_sanitize [<path>]Deletes X-User-ID, X-User-Email, X-User-Name, X-User-Username, X-Tenant-ID, X-Tenant-Name, X-User-Tenant-Role, X-User-Tenant-Permissions, X-User-Picture, X-Auth-Type, X-Proxy-Auth and every header starting with X-Token- from the incoming request. Must be the first handler on every route that carries identity. Its only argument is an optional path pattern (default *).
productify_auth
productify_auth [<path>] with <app_id>
productify_auth [<path>] with <app_id> {
tenant <uuid>
}Personal access tokens. Ignores requests without an Authorization: Bearer header (they continue to the OAuth check). Validates the token with POST /api/validate-proxy-auth, then sets X-User-*, X-Auth-Type: personal-token, X-Token-Subject, X-Token-User-Email and, when the Manager returns a tenant, X-Tenant-*. An invalid token, or a machine-user token, falls through to the OAuth check. A valid token with no access to this application is 403. Any Manager error is 503. This directive alone does not reject anonymous requests; the following authorize does.
productify_esb_auth
productify_esb_auth [<path>] with <app_id>Machine users only. Requires an Authorization header (Bearer or Basic), else 401. Validates with POST /api/validate-proxy-auth. A credential that is not a machine user is 403; a Manager error is 503. Sets X-User-*, X-Auth-Type: machine-user and X-Tenant-*. Has no tenant sub-directive: a machine user's tenant is a property of the credential.
productify_before_auth
productify_before_auth [<path>] with <app_id>Put it in front of authenticate on the /auth/* route. Increments pfy_authentication_awaiting_users{app} for every request except the authorization-code-callback, which decrements it.
productify_tenant_selector
productify_tenant_selector <app_id> [<selector_path>]
productify_tenant_selector <app_id> {
selector_path /_tenant # default
cookie_path / # default
logout_redirect /auth/logout # default; where <selector_path>/logout sends the browser
tenant <uuid> # pinned vhost
}Serves the tenant endpoints under selector_path and reads or writes the signed pfy_tenant_<app_id> cookie. A cookie_name argument or sub-directive is still parsed but ignored with a warning; the cookie name is always pfy_tenant_<app_id>. See Tenant resolution.
productify_oauth_validation
productify_oauth_validation <app_id>
productify_oauth_validation <app_id> {
tenant <uuid>
}For browser sessions. When the request carries X-Token-Subject (set by authorize) but no X-User-ID, it calls POST /api/validate-oauth-user and sets X-User-*, X-Auth-Type: oauth and X-Tenant-*. A Manager answer of "not valid" is 403; a Manager transport error lets the request continue without these headers. Must run after authorize and after the tenant selector. Without it a logged-in browser reaches the upstream with no X-User-* headers.
productify_html_inject
productify_html_inject <app_id>
productify_html_inject <app_id> {
enabled on|off # default on
script_id productify-config # id of the injected <script>
object_name window.__PRODUCTIFY__ # default
inject_position head|body # default head
logout_url /_tenant/logout # default; exposed as window.__PRODUCTIFY__.logout_url
tenant <uuid>
}Buffers text/html responses up to 8 MiB and injects a script that defines window.__PRODUCTIFY__ with the user, tenant, application, auth type, available tenants and the application's frontend configuration. The configuration is fetched from GET /api/frontend-config/<app_id>/<tenant_id> for the tenant the object names (the pin on a dedicated host, the resolved tenant on a shared host); a shared host with no tenant resolved falls back to GET /api/frontend-config/<app_id>. Other content types stream through untouched.
productify
productify [<path>] with <app_id>Access log line and metrics for one application, controlled by enable_access_log, enable_metrics and enable_size_metrics. Place it last before the upstream. With enable_size_metrics it wraps the response writer to measure the body.
Path argument
In the ... with <app_id> directives the optional first argument is a path pattern (/api*), not a named matcher. The usual way to scope a directive is a route block with a named matcher, as in the examples below.
Routing examples
One application
http://app.localhost {
vars app_id 64e163af-2f6b-4b2c-9a84-ec3c8dfbbe67
@auth path /auth/*
route @auth {
productify_sanitize
productify_before_auth with {vars.app_id}
authenticate with pocketportal
}
route /* {
productify_protect {vars.app_id} with pocketpolicy
reverse_proxy backend:8080
}
}Several applications on separate hosts
http://app1.localhost {
route /* {
productify_protect 100 with pocketpolicy
reverse_proxy app1:8080
}
}
http://app2.localhost {
route /* {
productify_protect 200 with pocketpolicy
reverse_proxy app2:8080
}
}Each host still needs its own /auth/* route for the login flow, as in the previous example.
Several applications under one host, by path
Caddy's path matcher matches the whole path, so /app1/* does not match /app1 itself. Give both patterns.
http://apps.localhost {
@app1 path /app1 /app1/*
route @app1 {
productify_protect 100 with pocketpolicy
uri strip_prefix /app1
reverse_proxy app1:8080
}
@app2 path /app2 /app2/*
route @app2 {
productify_protect 200 with pocketpolicy
uri strip_prefix /app2
reverse_proxy app2:8080
}
}The Manager's own vhost
The Manager's vhost runs only the sanitizer and the OAuth policy. Two paths get their own handle in front of the policy, because they are the anonymous browser bootstrap and must be servable with no identity at all: /pilet-feed/* and /language-packs/*. Everything else, /query and /pilets/* included, falls through the catch-all, where authorize with and the policy's inject headers with claims turn a portal cookie into the X-Token-* headers the Manager reads. This is the shape of the repository Caddyfile.
This host forwards both bootstrap paths verbatim, as an application vhost does. A language pack is public given a known application id, so serving one needs no identity and no scoping.
http://manager.localhost {
# Anonymous bootstrap: no identity, so no portal.
handle /pilet-feed/* {
route {
productify_sanitize
reverse_proxy 172.17.0.1:8080
}
}
handle /language-packs/* {
route {
productify_sanitize
reverse_proxy 172.17.0.1:8080
}
}
handle /auth/* {
route {
productify_sanitize
authenticate with pocketportal
}
}
handle {
route {
productify_sanitize
authorize with pocketpolicy
reverse_proxy 172.17.0.1:8080
}
}
}Do not add a pre-portal handle /pilets/*
It looks like it belongs next to /pilet-feed/* and it does not. The Manager's /pilets/* handlers demand a browser identity, which only the portal supplies, so a handle in front of the portal makes every pilet upload from the Manager UI answer 401. See the Pilet API.
ESB route (machine users)
http://app.localhost {
vars app_id 64e163af-2f6b-4b2c-9a84-ec3c8dfbbe67
@esb path /esb/*
route @esb {
productify_sanitize
productify_esb_auth with {vars.app_id}
productify with {vars.app_id}
uri strip_prefix /esb
reverse_proxy manager:8080 {
rewrite /esb/{vars.app_id}{uri}
}
}
}GET /esb/orders/42 on the application host reaches the Manager as GET /esb/<app_id>/orders/42. rewrite ...{uri} expands the decoded path; see Rewrites and percent-encoding.
Standard Caddy features
Everything below is plain Caddy and works unchanged in this build. See the Caddy documentation for details.
Load balancing and health checks
app.example.com {
reverse_proxy backend1:8080 backend2:8080 backend3:8080 {
lb_policy round_robin # or least_conn, ip_hash, ...
health_uri /health
health_interval 10s
health_timeout 5s
health_status 200
fail_duration 30s
max_fails 3
unhealthy_status 503
}
}TLS
{
email admin@example.com # automatic certificates for every public site address
}
example.com {
reverse_proxy backend:8080
}
custom.example.com {
tls /path/to/cert.pem /path/to/key.pem # bring your own certificate
reverse_proxy backend:8080
}
mtls.example.com {
tls {
client_auth {
mode require_and_verify
trust_pool file /path/to/ca.pem
}
}
reverse_proxy backend:8080
}Response headers
example.com {
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
-Server
}
reverse_proxy backend:8080
}Logging
The Productify access log goes to Caddy's default logger. Configure where that goes with the global log option:
{
log {
output file /var/log/caddy/access.log {
roll_size 100mb
roll_keep 10
roll_keep_for 30d
}
format json
level INFO
}
}Complete example
A full, loadable development configuration: identity provider, one application and the Manager vhost.
{
order authenticate before respond
admin {$PFY_ADMIN_LISTEN:127.0.0.1:2019}
security {
oauth identity provider generic {
delay_start 1
retry_attempts 3
retry_interval 2
realm generic
driver generic
client_id ab098fe1-9bc0-4780-81c6-2ea17f49a3cb
client_secret {$PFY_OAUTH_CLIENT_SECRET}
scopes openid email profile
base_auth_url http://pocketid.localhost
metadata_url http://pocketid.localhost/.well-known/openid-configuration
}
authentication portal pocketportal {
crypto default token lifetime 3600
enable identity provider generic
cookie insecure on
transform user {
match realm generic
action add role user
}
}
authorization policy pocketpolicy {
set auth url /auth/oauth2/generic
allow roles user
validate bearer header
inject headers with claims
enable strip token
}
}
productify {
manager http://172.17.0.1:8080
token {env.PFY_MANAGER_TOKEN}
min_proxy_version 1.0.0
enable_access_log
enable_metrics
enable_size_metrics
}
}
# Identity provider
http://pocketid.localhost {
reverse_proxy pocketid:1411
}
# One application
http://app.localhost {
vars app_id 64e163af-2f6b-4b2c-9a84-ec3c8dfbbe67
@auth path /auth/*
@esb path /esb/*
route @auth {
productify_sanitize
productify_before_auth with {vars.app_id}
authenticate with pocketportal
}
route @esb {
productify_sanitize
productify_esb_auth with {vars.app_id}
productify with {vars.app_id}
uri strip_prefix /esb
reverse_proxy 172.17.0.1:8080 {
rewrite /esb/{vars.app_id}{uri}
}
}
route /* {
productify_protect {vars.app_id} with pocketpolicy
file_server {
root /usr/share/caddy
browse
}
}
}
# Manager UI/API
http://manager.localhost {
handle /pilet-feed/* {
route {
productify_sanitize
reverse_proxy 172.17.0.1:8080
}
}
handle /language-packs/* {
route {
productify_sanitize
reverse_proxy 172.17.0.1:8080
}
}
handle /auth/* {
route {
productify_sanitize
authenticate with pocketportal
}
}
handle {
route {
productify_sanitize
authorize with pocketpolicy
reverse_proxy 172.17.0.1:8080
}
}
}Validating a Caddyfile
PFY_MANAGER_TOKEN=x PFY_OAUTH_CLIENT_SECRET=y \
caddy validate --adapter caddyfile --config Caddyfilecaddy validate provisions every module, so it also catches an empty token, a bad manager URL and a min_proxy_version the plugin does not support. Without delay_start it also tries to fetch the identity provider's metadata and fails if the provider is unreachable.
See also
- Application Routing
- Monitoring
- Proxy Vhost Sync Loop: how the Manager renders and pushes this file