Skip to content

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 ​

nginx
{
  # 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:

nginx
{
  # 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.

nginx
{
  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 ​

nginx
{
  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
  }
}
OptionTypeDefaultDescription
managerURLrequiredBase URL of the Manager API. Must be http:// or https:// with a host. May be given once only.
tokenstringrequiredShared 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_timeoutduration5sTimeout for each outbound Manager call.
auth_cache_ttlduration45sHow long a successful PAT or OAuth validation is reused before the Manager is asked again.
frontend_config_cache_ttlduration60sCache for the frontend configuration injected into HTML.
tenant_membership_cache_ttlduration60sCache for the user's tenant membership set and the selector catalog.
metrics_addressaddress:2112Listen 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_versionversionnoneStamped 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_hashstringnoneStamped 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_logflagoffWrite one structured log line per request that passes a productify directive. See Monitoring.
enable_metricsflagoffRecord pfy_total_requests and pfy_response_time_seconds.
enable_size_metricsflagoffAlso record pfy_request_size_bytes and pfy_response_size_bytes. Has no effect without enable_metrics.
activate_timeoutduration30sScale-from-zero: how long an API request is held while the backend starts before it is shed with 503.
activator_max_in_flightinteger100Scale-from-zero: maximum held requests per (application, environment); more are shed with 503 immediately.
activator_max_waitduration30sScale-from-zero: per-request hold ceiling; the effective hold is the smaller of this and activate_timeout.
activator_debounceduration2sScale-from-zero: a wake for the same (application, environment) is not re-issued within this window.
deployment_cache_ttlduration10sScale-from-zero: how long the cached "is this deployment scale-to-zero eligible, how many healthy backends" answer is trusted.
wake_pathpath/api/proxy/wakeManager path the activator calls to wake a deployment.
ready_pathpath/api/proxy/readyManager 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 ​

nginx
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 ​

nginx
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 ​

nginx
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 ​

nginx
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 ​

nginx
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 ​

nginx
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 ​

nginx
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 ​

nginx
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 ​

nginx
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 ​

nginx
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 ​

nginx
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.

nginx
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.

nginx
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) ​

nginx
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 ​

nginx
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 ​

nginx
{
  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 ​

nginx
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:

nginx
{
  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.

nginx
{
  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 ​

bash
PFY_MANAGER_TOKEN=x PFY_OAUTH_CLIENT_SECRET=y \
  caddy validate --adapter caddyfile --config Caddyfile

caddy 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 ​