Configuration

# Proxy

dash-proxy deploy options — hosts, SSL, load balancing, caching, traffic shaping, and more.

Kamal uses [dash-proxy](https://github.com/basecamp/dash-proxy) to provide gapless deployments. It runs on ports 80 and 443 and forwards requests to the application container.

The proxy is configured in the root configuration under `proxy`. These are options that are set when deploying the application, not when booting the proxy.

They are application-specific, so they are not shared when multiple applications run on the same proxy.

```yaml
proxy:
```

#### Where each option lives when a loadbalancer fronts the fleet

Every deploy option has exactly one home (see Dash::Configuration::Proxy::DEPLOY_OPTION_DISPOSITIONS - the layering contract that enforces this):

edge     - applied only by the loadbalancer, stripped from the per-host proxies: host/hosts, ssl (certificates, on-demand, mTLS), ssl_redirect, ssl_staging, ssl_domains, basic_auth, allow_ips, client_ip, rate_limit, session_affinity, canonical_host, redirects, cache, read_routing

per-app  - applied only by the per-host proxies, next to the app: headers, rewrites, intercept_errors, sleep, compress

both     - each layer runs its own copy, deliberately: healthcheck, response/request timeouts, path timeouts, target pool tuning, buffering, path_prefix, strip_path_prefix, forward_headers, logging, exclude_metrics_paths

Without a loadbalancer the single proxy is every layer at once and the whole surface applies to it. `loadbalancer`, `reboot_on_deploy` and `run` configure the containers themselves rather than a deployment, so they have no layer.

## 1. Essentials — enough for most apps

#### Hosts

The hosts that will be used to serve the app. The proxy will only route requests to this host to your app.

If no hosts are set, then all requests will be forwarded, except for matching requests for other apps deployed on that server that do have a host set.

Specify one of `host` or `hosts`.

```yaml
host: foo.example.com

hosts:
  - foo.example.com
  - bar.example.com
```

#### Loadbalancer

Specify a host to run the loadbalancer on. The loadbalancer will distribute requests to all web hosts. If not specified but multiple web hosts are configured, the first web host will be used as the loadbalancer host.

The host may be a dedicated machine outside of `servers:`, or one of the web hosts — in which case the loadbalancer takes over that host's proxy container.

Set `true` to always run the loadbalancer on the first host of the primary role, even when that role has a single host, or `false` to opt out of the automatic activation for multi-host primary roles.

```yaml
loadbalancer: lb.example.com
```

#### Sharing one loadbalancer between several dash apps

More than one dash app may point `loadbalancer:` at the same host. Each app registers its own service on the shared dash-proxy and keeps its own deploy.yml; the load balancer multiplexes them by hostname.

The rules dash enforces for that topology:

- Service state survives a reboot. dash-proxy persists its services in the `dash-loadbalancer-config` volume (or `dash-proxy-config` when the loadbalancer shares a proxy host), and `dash proxy reboot` only replaces the container. Rebooting from app A does not drop app B's routes — the surviving service list is printed after the restart.
- Service names must be unique across apps. The loadbalancer registers a service under the bare `service:` name, so two apps — or two destinations of one app — sharing a name would take over each other's routes. The first app to deploy claims the name in `.dash/loadbalancer/services/<service>` on the loadbalancer host; a second app deploying the same name fails at deploy time instead of silently winning.
- Apps sharing a loadbalancer must agree on `proxy/run`. All of them boot the same container, so the first app to boot records its run configuration in `.dash/loadbalancer/run_config`. Another app booting with a different `proxy/run` fails; the same app changing its own gets the usual drift warning and applies it on `dash proxy reboot`.
- `dash proxy remove` refuses while other apps are installed on the loadbalancer host, exactly as it does for a proxy host. Use `--force` to override, which removes the shared loadbalancer for every app on it.

#### Automatic proxy reboot on deploy

When `dash deploy` detects that the running dash-proxy container was started with a different image, version or run options than the current configuration, it reboots the proxy automatically — one host at a time — before booting the app.

Set to false to opt out. Kamal will then print a warning when drift is detected and leave the proxy untouched until you run `dash proxy reboot`.

Root-level `proxy` setting only; ignored inside role-specific proxy blocks.

Defaults to true:

```yaml
reboot_on_deploy: false
```

#### App port

The port the application container is exposed on.

Defaults to 80:

```yaml
app_port: 3000
```

#### SSL

dash-proxy can provide automatic HTTPS for your application via Let's Encrypt.

This requires that we are deploying to one server and the host option is set. The host value must point to the server we are deploying to, and port 443 must be open for the Let's Encrypt challenge to succeed.

If you set `ssl` to `true`, `dash-proxy` will stop forwarding headers to your app, unless you explicitly set `forward_headers: true`

Defaults to `false`:

```yaml
ssl: true
```

#### Custom SSL certificate

In some cases, using Let's Encrypt for automatic certificate management is not an option, for example if you are running from more than one host.

Or you may already have SSL certificates issued by a different Certificate Authority (CA).

Kamal supports loading custom SSL certificates directly from secrets. You should pass a hash mapping the `certificate_pem` and `private_key_pem` to the secret names.

The hash is also home to the rest of the TLS surface:

`on_demand_url` turns on on-demand TLS: instead of a fixed `host` list, the proxy asks this endpoint whether it may issue a certificate for the hostname in an incoming handshake. A path is resolved against a healthy app target; an absolute http(s) URL is called directly. Answer 2xx to approve. On-demand TLS replaces the static hostname list, so it cannot be combined with `host`/`hosts`, with `certificate_pem`, or with `ssl_domains` — dash rejects those at config time, because dash-proxy would reject the deploy and there is no sensible winner to pick.

`client_ca_pem` requires mutual TLS: clients must present a certificate signed by this CA bundle. Like `certificate_pem` it names a secret in `.dash/secrets`; the content is uploaded next to the app's TLS material under `.dash/proxy/apps-config`, and the proxy is given the path it sees inside its own container. An empty secret fails the deploy rather than silently turning mTLS off.

```yaml
ssl:
  certificate_pem: CERTIFICATE_PEM
  private_key_pem: PRIVATE_KEY_PEM
  on_demand_url: https://app.example.com/api/v1/tls/ask
  client_ca_pem: CLIENT_CA_PEM
```

#### Notes

- If the certificate or key is missing or invalid, deployments will fail.
- Always handle SSL certificates and private keys securely. Avoid hard-coding them in source control.

#### SSL redirect

By default, dash-proxy will redirect all HTTP requests to HTTPS when SSL is enabled. If you prefer that HTTP traffic is passed through to your application (along with HTTPS traffic), you can disable this redirect by setting `ssl_redirect: false`:

```yaml
ssl_redirect: false
```

#### SSL staging

When automatic SSL is enabled, use the Let's Encrypt staging environment for certificate provisioning, so you can test your SSL configuration without running into Let's Encrypt's production rate limits. Certificates issued by the staging environment are not trusted by browsers.

Defaults to `false`:

```yaml
ssl_staging: true
```

#### Healthcheck

When deploying, the proxy will by default hit `/up` once every second until we hit the deploy timeout, with a 5-second timeout for each request.

Once the app is up, the proxy will stop hitting the healthcheck endpoint.

By default, the healthcheck is sent to the app port. Set `port` to check a different port on the container, and `host` to set the Host header sent with healthcheck requests.

```yaml
healthcheck:
  interval: 3
  path: /health
  timeout: 3
  port: 3001
  host: health.example.com
```

## 2. Traffic & routing

#### Path-based routing

For applications that split their traffic to different services based on the request path, you can use path-based routing to mount services under different path prefixes. Usage sample: path_prefix: '/api'

You can also specify multiple paths in two ways.

When using path_prefix you can supply multiple routes separated by commas.

```yaml
path_prefix: "/api,/oauth_callback"
```

You can also specify paths as a list of paths, the configuration will be rolled together into a comma separated string.

```yaml
path_prefixes:
  - "/api"
  - "/oauth_callback"
```

By default, the path prefix will be stripped from the request before it is forwarded upstream.

So in the example above, a request to /api/users/123 will be forwarded to web-1 as /users/123.

To instead forward the request with the original path (including the prefix), specify --strip-path-prefix=false

```yaml
strip_path_prefix: false
```

#### Forward headers

Whether to forward the `X-Forwarded-For` and `X-Forwarded-Proto` headers.

If you are behind a trusted proxy, you can set this to `true` to forward the headers.

By default, dash-proxy will not forward the headers if the `ssl` option is set to `true`, and will forward them if it is set to `false`.

```yaml
forward_headers: true
```

#### Header rules

Rewrite headers on their way to the app and on their way back out, without the app knowing. `set` replaces whatever was there, `add` appends and keeps it, `remove` strips it.

Names are canonicalised, and values have their leading and trailing whitespace trimmed. A value may contain colons — a CSP naming a scheme or a port survives intact.

Two things dash rejects rather than letting them fail late: a value with a newline or carriage return in it (that is response splitting, and shell escaping would silently turn it into a literal backslash-n), and a *request* rule naming `Host` — Go carries the host outside the header map, so the rule would do nothing at all.

Response rules apply to what the app returned. Error pages, redirects, and auth or rate-limit rejections come from the proxy itself and are unaffected.

```yaml
headers:
  request:
    set:
      X-Forwarded-Host: app.example.com
    add:
      X-Request-Source: kamal
    remove:
      - X-Internal-Token
  response:
    set:
      Strict-Transport-Security: max-age=31536000
    add:
      X-Served-By: dash-proxy
    remove:
      - Server
```

#### Redirects and rewrites

A redirect answers the client with a `Location`; a rewrite changes the path the app receives while the client's URL stays as it was — which is what an SPA serving its own routes out of `/index.html` needs.

`from` is a regular expression **anchored to the whole path**, so `/old` does not fire on `/not-old-either`. `to` is a path on this host, and for a redirect may also be a full http(s) URL. Captures are available as `$1`, `$2`, … Rules are tried in order and the first match wins.

#### Watch the path prefix

Both match the path **the client asked for, before `path_prefix` stripping**. An app mounted at `path_prefix: /api` with `strip_path_prefix` on sees `/users`, but a rule here still has to be written against `/api/users`. This is the part that is easy to get wrong.

`status` applies to redirects only and must be 301, 302, 303, 307 or 308. It defaults to 301, so say 302 explicitly for anything you may want back.

```yaml
redirects:
  - from: /old
    to: /new
  - from: /gone/(.*)
    to: https://elsewhere.example.com/$1
    status: 302

rewrites:
  - from: /api/(.*)
    to: /v2/$1
```

#### Dynamic redirect map

Fetch a host-scoped redirect map from the application itself, so redirects ship with a content change instead of a deploy. `source` is a path resolved against this service, or an absolute http(s) URL. The app publishes `{"hosts": {...}}` entries — per-host `redirect_to`, path rules and trailing-slash policy — and the proxy answers matching requests before they reach the app.

The map composes with the static `redirects` above: the map is consulted first, and the static rules run when it misses.

`interval` is the poll interval in seconds (proxy default 300, minimum 10).

Authentication tokens live in the PROXY's environment, not the app's: polls send `DASH_PROXY_REDIRECTS_TOKEN` as a bearer token when set, and `POST /.dash-proxy/redirects/refresh` nudges an immediate re-poll when authenticated with `DASH_PROXY_REFRESH_TOKEN`. Set both via `proxy.run.options.env`, next to `DASH_PROXY_DOMAINS_TOKEN` — never as deploy flags, which leak into process listings and audit logs. The `KAMAL_PROXY_`-prefixed names are still read as a fallback, so a proxy container that already carries them keeps working.

When a loadbalancer is configured the map answers at the loadbalancer, same as `ssl_domains` and `canonical_host` — the per-host proxies never see it.

```yaml
redirects_source:
  source: /api/v1/proxy/redirects
  interval: 300
```

#### Canonical host

Redirect every request to this host, to force apex or www one way.

```yaml
canonical_host: www.example.com
```

#### Intercept error statuses

Replace these statuses coming from the app with the proxy's own error pages, discarding whatever body the app sent. 4xx and 5xx codes only.

This pairs with the root-level `error_pages_path`. It works without it, but not usefully: with no pages to render, the proxy falls back to a bare plaintext status line, so the app's own error page is thrown away and replaced by the words "Bad Gateway". Kamal warns when you do that.

```yaml
intercept_errors:
  - 502
  - 503
```

#### Note

Everything in this section applies on the per-host proxy, including when a loadbalancer is configured — unlike TLS and access control, which move to the loadbalancer. Applying them at both layers would append an `add` header twice and run a rewrite over its own output.

## 3. Security & access

#### Basic auth

Require HTTP Basic credentials on every request to this service. Requests without valid credentials get a 401 with a `WWW-Authenticate` challenge. The health check path stays open, so deploys are unaffected.

Set exactly one of `password_secret` or `password` — never both.

Prefer `password_secret`: it names an entry in `.dash/secrets`, so the password never lives in this file.

```
basic_auth:
  username: admin
  password_secret: WEB_BASIC_AUTH_PASSWORD
```

`password` sets the value directly. If you use it, interpolate rather than committing a literal:

```
basic_auth:
  username: admin
  password: <%= ENV["WEB_BASIC_AUTH_PASSWORD"] %>
```

```yaml
basic_auth:
  username: admin
  password: <%= ENV["WEB_BASIC_AUTH_PASSWORD"] %>
  password_secret: WEB_BASIC_AUTH_PASSWORD
```

#### Notes

- Requires a dash-proxy that supports `--basic-auth`. This is newer than the current `MINIMUM_VERSION`, so make sure your proxy is up to date before enabling it — an older proxy fails the deploy on an unknown flag.
- Basic credentials are replayable and are sent on every request. Use this with `ssl: true`, or terminate TLS in front of the proxy.
- dash-proxy removes the `Authorization` header before forwarding, so a service behind basic auth cannot also pass credentials through to its target. When load balancing, the credentials are enforced by the load balancer only.

#### Dynamic TLS domains

dash-proxy can learn TLS hostnames from your application at runtime instead of fixing them at deploy time. It polls `source` — a path (resolved against a healthy app target) or an absolute http(s) URL — for the domain list and manages Let's Encrypt certificates for it automatically. When `source` is set, `ssl: true` is allowed without `host`/`hosts`.

`interval` is the poll interval in seconds (proxy default: 300).

`batch_size` controls how many domains share a certificate: 1 (the proxy default) issues per-domain certs; 2-25 enables stable SAN batching.

Authentication tokens for the poll endpoint and the refresh nudge are read by the proxy from the `DASH_PROXY_DOMAINS_TOKEN` and `DASH_PROXY_REFRESH_TOKEN` environment variables (the `KAMAL_PROXY_` names are still read as a fallback). Set them on the proxy container via `proxy.run.options.env` — never as deploy flags, which leak into process listings and audit logs.

```yaml
ssl_domains:
  source: /api/v1/kamal/domains
  interval: 300
  batch_size: 1
```

#### Who the client is

Rate limiting and IP allow lists are both only as correct as the address they key on, so configure this first if anything sits in front of dash-proxy.

With no `trusted_proxies`, the client is always the address that opened the connection — nothing a client sends can influence it, which is what makes the allow list meaningful.

Once you declare `trusted_proxies`, and only when the connecting address is one of them, dash-proxy reads the forwarded chain instead: it walks the chain from the nearest hop backwards past every proxy you declared, and the first address none of your proxies wrote is the client. **List every hop**, not only the one that connects to dash-proxy — a chain it cannot resolve denies the request rather than falling back to the connecting address.

`header` names the header carrying the original client IP (`CF-Connecting-IP` behind Cloudflare, `True-Client-IP` behind some others); dash-proxy reads it instead of `X-Forwarded-For`. It is only honoured when `trusted_proxies` is set, because otherwise it is just something the client wrote — dash rejects that combination rather than appearing to honour it.

Addresses and ranges are plain IPv4/IPv6 or CIDR. Write IPv4 as IPv4, not as an IPv4-mapped IPv6 range, and leave IPv6 zones off — neither matches anything. If clients reach you over IPv6, list IPv6 ranges too, or they are denied.

```yaml
client_ip:
  header: CF-Connecting-IP
  trusted_proxies:
    - 173.245.48.0/20
    - 2400:cb00::/32
```

#### Rate limiting

A per-client token bucket. Requests over the limit get a 429. IPv6 clients are counted per /64, since one client can pick any address inside its own.

`requests` is requests per second and may be fractional — 0.5 is one request every two seconds. `burst` is how many requests a client may make back to back before the limit applies (default: the rate, rounded up). `exempt` lists addresses and ranges the limit skips, for monitoring and health probes.

```yaml
rate_limit:
  requests: 100
  burst: 20
  exempt:
    - 10.0.0.0/8
```

#### IP allow list

Serve this service only to these addresses and ranges; everything else gets a 403. Combine with `client_ip` above when you are behind a CDN, or the list is matched against the CDN's addresses rather than your visitors'.

```yaml
allow_ips:
  - 10.0.0.0/8
  - 192.168.0.0/16
```

#### IP deny list

Refuse this service to these addresses and ranges with a 403. Checked before `allow_ips`: an address on both lists is denied. Denied clients never spend rate-limit budget. Combine with `client_ip` above when behind a CDN, or the list is matched against the CDN's addresses rather than your visitors'.

```yaml
deny_ips:
  - 203.0.113.0/24
  - 198.51.100.7
```

#### User-agent deny list

Refuse requests whose full User-Agent matches one of these RE2 patterns, checked after the IP rules. A missing User-Agent only matches an explicit '^$' pattern. Patterns are matched by dash-proxy (Go RE2), so dash checks only their shape, not their syntax.

```yaml
deny_user_agents:
  - 'BadBot/.*'
```

#### Notes

- The health check path is served without an address check and without a rate limit, so it stays reachable during a deploy. That means it cannot be `/` — dash rejects `healthcheck: path: /` while either feature is on, because it would leave the whole service open. The default `/up` is fine.
- When a loadbalancer is configured, all of this moves to the loadbalancer: the per-host proxies see the loadbalancer as their peer, so an allow list there would refuse every request and one rate limiter would count the whole fleet as a single client.

## 4. Performance & observability

#### Two different deadlines

`response_timeout` bounds how long the app may take to *start* answering — its clock stops once response headers arrive. `request_timeout` bounds the whole request, including streaming the body back to the client.

They are not interchangeable, and the usual confusion is setting the first and still seeing requests hang: a slow trickle of body bytes never trips `response_timeout`, because the app answered promptly. WebSocket and event-stream responses are exempt from `request_timeout`.

`response_timeout` defaults to 30 seconds; `request_timeout` defaults to 0, meaning no limit.

```yaml
response_timeout: 10

request_timeout: 30
```

#### Per-path timeouts

Override either deadline below a path prefix. Values are Go duration strings ("90s", "5m") or plain seconds; 0 removes the limit for that prefix, which suits streaming and SSE endpoints.

```yaml
path_response_timeouts:
  "/api/reports": "5m"
  "/stream": 0

path_request_timeouts:
  "/uploads": "10m"
  "/stream": 0
```

#### Target connections and retries

The connection pool between the proxy and this app's containers, and how hard the proxy tries to place a request on a healthy one.

`max_conns` caps simultaneous connections per target, idle ones included. Requests over the cap queue rather than failing, and a streaming response holds its connection until the body closes — so pair a low cap with `request_timeout`. `max_idle_conns` caps the idle connections kept open. Both apply *per pool*, and every entry in `path_response_timeouts` adds another pool.

`idle_conn_timeout` is how long an idle connection is kept; set it below the app's own keep-alive timeout, or the proxy will hand a request to a socket the app has already closed. `dial_timeout` bounds establishing a connection — `response_timeout` cannot cover that, since its clock starts only once the request has been written.

`try_duration` keeps looking for a healthy target for that long before giving up, with `try_interval` between attempts. Only idempotent requests without a body are re-sent to another target.

#### What 0 means here

Not the same thing for every key, because the proxy resolves its own defaults from a zero:

- `max_conns: 0` — unlimited
- `try_duration: 0` — a single attempt, no retrying
- `request_timeout: 0` — no limit
- `max_idle_conns: 0` — the proxy's default of 100, **not** "keep none"
- `idle_conn_timeout: 0` — the proxy's default of 90s
- `dial_timeout: 0` — the proxy's default of 30s
- `try_interval: 0` — the proxy's default of 250ms

So there is no way to ask for zero idle connections; leave the key unset unless you mean to change it.

```yaml
target:
  max_conns: 100
  max_idle_conns: 10
  idle_conn_timeout: 90
  dial_timeout: 5
  disable_keep_alives: false
  try_duration: 30
  try_interval: 1
```

#### Buffering

Whether to buffer request and response bodies in the proxy.

By default, buffering is enabled with a max request body size of 1GB and no limit for response size.

You can also set the memory limit for buffering, which defaults to 1MB; anything larger than that is written to disk.

```yaml
buffering:
  requests: true
  responses: true
  max_request_body: 40_000_000
  max_response_body: 0
  memory: 2_000_000
```

#### Response compression

Serve gzip, brotli or zstd without the app knowing about it. Responses the app already encoded, event streams, and media types that do not shrink are passed through untouched.

`compress: true` is the common case: it offers zstd, br and gzip in that order — best ratio first, and the client's own `Accept-Encoding` preference still wins — and leaves the length and media-type defaults to the proxy.

```
compress: true
```

The block form is for when you want something narrower. `encodings` names what to offer, most preferred first, from `gzip`, `br` (or `brotli`) and `zstd`; naming it is enough to switch compression on. `min_length` is the response size in bytes below which compressing costs more than it saves (proxy default 1024; set 1 to compress everything).

An explicit `enabled: false` switches compression off even when `encodings` are named — it is the off switch for a block whose tuning you want to keep.

`content_types` **replaces** the proxy's built-in list of compressible media types rather than adding to it, so name every type you want compressed, not just the extra ones. Entries are exact types or type wildcards. Note that `text/event-stream` is never compressed unless you name it here — holding bytes back to fill the encoder's window is exactly what an event stream's client asked you not to do.

```yaml
compress:
  enabled: true
  encodings:
    - zstd
    - br
    - gzip
  content_types:
    - text/html
    - application/json
  min_length: 1024
```

#### Response cache

An RFC 9111 shared cache in front of this service. Nothing is stored unless the app marks a response `public` with an `s-maxage` or `max-age`, and a response carrying `Set-Cookie` is refused unless `allow_set_cookie` says otherwise — a shared cache replaying one client's cookie to the next is the worst thing it could do.

This block is the *policy*, and it is per service. Where the entries live is proxy-wide and set under `run/cache` below.

Only `enabled` is required. Everything else keeps dash-proxy's own default until you set it.

`max_ttl` caps the lifetime the app asks for, in seconds, so one mistaken directive cannot pin content until the next deploy. `max_body` is the largest response body to store, in bytes — the same plain byte counts `buffering` uses. Bigger responses still reach the client, they are just not kept.

`max_variants` is how many representations one URL may hold when the app negotiates with `Vary` (negative switches automatic variants off and refuses a varying response outright).

`vary_headers` and `vary_cookies` add request headers and cookie names to the cache key for EVERY response this service stores. Headers the app already names in `Vary` are keyed automatically per URL and need no entry here — naming one moves it into the key for every path in the service, which is usually not what you want.

#### Administering it

`dash proxy cache stats` reports what the cache is holding (add `--count` to measure entries and bytes per service, `--json` for the raw report), and `dash proxy cache purge` drops this app's cached responses (`--path-prefix /assets` to narrow it). Both run on the layer that owns the cache - the loadbalancer when load balancing, else each proxy host.

#### When it is not caching

A cache that quietly stores nothing is the usual first surprise. Start with `dash proxy cache stats`; dash-proxy also explains every refusal — check `dash proxy logs` for the reason, and the `cache_refusals_total` metric (by `reason`) if you run with `metrics_port`. The common reasons are a missing `Cache-Control: public, max-age=...` on the app's response, a `Set-Cookie` header, a body over `max_body`, and `variant_limit` from `max_variants`.

```yaml
cache:
  enabled: true
  max_ttl: 300
  max_body: 1_048_576
  max_variants: 8
  vary_headers:
    - Accept-Encoding
    - Accept-Language
  vary_cookies:
    - locale
  allow_set_cookie: false
```

#### Paths to leave out of the Prometheus metrics

Request paths that should not be counted, typically health and readiness endpoints that would otherwise dominate the histograms.

This is a per-service deploy setting even though it reads like metrics configuration — where the metrics are served and who may read them are proxy-wide and live under `run/metrics_port` and `run/metrics_allow_ips`.

```yaml
exclude_metrics_paths:
  - /up
```

#### Logging

Configure request logging for the proxy. You can specify request and response headers to log. By default, `Cache-Control`, `Last-Modified`, and `User-Agent` request headers are logged:

```yaml
logging:
  request_headers:
    - Cache-Control
    - X-Forwarded-Proto
  response_headers:
    - X-Request-ID
    - X-Request-Start
```

## 5. Fleet — multi-host behaviour

#### Read-only targets

dash-proxy can split traffic between the deployed (writer) targets and a set of read-only targets, e.g. app instances backed by database replicas. Read requests are routed to the read targets; write requests always go to the writers.

Targets are host:port addresses reachable from the proxy.

`websockets` routes WebSocket traffic to the read targets too (default `false`). `writer_affinity_timeout` is how long, in seconds, a client's reads stick to the writer after it makes a write, so clients always read their own writes (default 1 second).

When a loadbalancer fronts the fleet, read routing is decided there — at the only layer that sees the whole fleet.

```yaml
read_routing:
  targets:
    - 192.168.0.2:3000
    - 192.168.0.3:3000
  websockets: true
  writer_affinity_timeout: 10
```

#### Session affinity

Keep each client on the target that first served it, for apps holding session state in the instance. Off by default, and rightly so — every request is otherwise free to go to whichever target is best placed to serve it.

The client carries an opaque HttpOnly cookie naming its target. When that target leaves the pool the next request falls through to another one and is re-pinned, so a deploy does not strand anybody. Reads served by a `read_targets` replica are never pinned.

`cookie` renames the pin cookie; dash-proxy picks a sensible default.

```yaml
session_affinity:
  enabled: true
  cookie: _kamal_affinity
```

#### Scale to zero

Stop this service's containers after `after` seconds with no traffic, and start them again on the next request, which is held until they are healthy. Health checks and the proxy's own TLS probes are not traffic and never wake a sleeping service.

`wake_timeout` is how long a request waits for the containers to come back before giving up with a 503. `containers` names the containers to stop and start, replacing what the proxy infers from the target address — needed when a target names a network alias rather than a container.

#### This needs the container runtime socket

Stopping and starting containers means talking to the runtime, so the proxy must have been booted with `run/docker_socket` set. That is a boot-time prerequisite for a deploy-time setting, so dash checks it while reading this file rather than letting the first request hang.

Setting `run/docker_socket` also mounts that socket into the proxy container — the flag alone only says where to look. **Reaching the container runtime socket is root-equivalent on the host**, which is why it is a separate, explicit setting and not something enabling sleep does for you.

Not compatible with `tls/on_demand_url`: a sleeping target cannot answer the ask endpoint, and waking one would let any hostname on the internet start a container.

```yaml
sleep:
  after: 300
  wake_timeout: 30
  containers:
    - app-web
```

## 6. Proxy container (run)

#### Run configuration

These options are used when booting the proxy container.

```yaml
run:
  http_port: 8080                # HTTP port to use (default 80)
  https_port: 8443               # HTTPS port to use (default 443)
  metrics_port: 9090             # Port for Prometheus metrics
  debug: true                    # Debug logging (default: false)
  log_max_size: "30m"            # Maximum log file size (default: "10m")
  publish: false                 # Publish ports to the host (default: true)
  bind_ips:                      # List of IPs to bind to when publishing ports
    - 0.0.0.0
  registry: registry:4443        # Extra registry prefix for the dash-proxy image
                                 # (default: none). If you set this, also override
                                 # `repository` to a host-less path (e.g.
                                 # myfork/dash-proxy) - the default repository
                                 # below already embeds its ghcr.io host
  repository: ghcr.io/zoolutions/dash-proxy # Container repository for the
                                 # dash-proxy image (this is the default)
  version: v1.1.0.3              # Version tag of the dash-proxy image to use.
                                 # Defaults to the minimum version this gem
                                 # requires - only pin it to roll forward early,
                                 # never below the default
  port_holder: true              # Zero-downtime proxy reboots (default: false).
                                 # Runs a minimal long-lived dash-proxy-net container that
                                 # owns the published ports; proxy generations join its
                                 # network namespace and overlap during a reboot, so config
                                 # and version changes apply without dropping requests.
                                 # Adopting (or leaving) this mode takes one final
                                 # reboot with a brief gap.
```

#### ACME / Let's Encrypt

Certificate issuance for the whole proxy, including DNS-01 challenges. These are proxy-wide, not per-app: every service on this proxy shares the ACME account and the DNS provider.

`dns_provider` names the provider that answers DNS-01 challenges. DNS-01 activates only when this is set (proxy v1.0.0.5+): unset means HTTP-01 only, so credentials visible in the proxy's environment can never arm DNS-01 on their own. `auto` is an explicit opt-in that picks the provider from the credentials it can see and logs at boot which one it armed; `none` says "no DNS-01" explicitly. An unsupported name is rejected here, at config time - dash-proxy would only log a warning and then never issue a certificate.

A plain string (`dns_provider: cloudflare`) uses one provider for every zone. The hash form pins zones to the DNS host that actually serves them, with `default` covering unmatched zones - for estates whose domains are spread across registrars. Each provider's API credentials must be present under `credentials` for its challenges to succeed.

dash-proxy also accepts short aliases for the canonical names - `cf` (cloudflare), `do` (digitalocean), `gcp`/`google`/`googledns` (gcloud), `gd` (godaddy), `hz` (hetzner), `nc` (namecheap), `aws`/`r53` (route53) and `vr` (vultr). Prefer the canonical name; the aliases exist so a config written for the proxy's own CLI is not rejected here.

`prefer_wildcard` asks for a wildcard certificate when the DNS provider supports one, and `http_fallback` falls back to an HTTP-01 challenge when DNS-01 fails. Both default to true in the proxy.

For `host`/`hosts` names the proxy infers the zone from the name itself. Names learned from `ssl_domains` are tenant-owned and may sit in a zone you do not run DNS for, so from proxy v1.1.0.2 they collapse into a wildcard only where the hash form of `dns_provider` maps their zone explicitly - mapping a zone is you asserting DNS control over it. Under a mapped zone every single-label name is ordered as that zone's wildcard, while the apex and deeper names ride the same order as concrete identifiers. `default` and `auto` assert nothing about a specific zone, so they never collapse a dynamic name.

`directory` overrides the ACME directory URL - point it at Let's Encrypt's staging environment while you are working out a DNS setup, so you do not burn production rate limits on failed attempts.

`release_probe_interval` is how often, in seconds, the proxy re-checks a domain whose issuance is currently held - which is what decides how long after a DNS cutover the certificate appears (proxy v1.0.0.7+).

You can point a host at dash before its DNS is repointed. No certificate is ordered until the first HTTPS request arrives, and the proxy first checks that the domain actually routes back to it - because until it does, an HTTP-01 challenge cannot succeed, and a failed attempt burns Let's Encrypt's limit of five failed authorizations per hostname per hour. Tripping that limit is what would otherwise delay the certificate at the moment of the cutover. Held domains are re-checked on this interval and issued as soon as the domain arrives, so the cutover costs one interval rather than a backoff step.

The default is 60. A negative value switches release probing off, leaving a held domain to wait out its backoff. Zones covered by a DNS-01 provider skip all of this: DNS-01 never depends on where a domain points, so their certificates can be issued well before a cutover.

`dash proxy domains list` shows what is held and why; `dash proxy domains retry HOST` clears a hold when you already know the cause is fixed.

`credentials` names entries in `.dash/secrets` to pass to the proxy container as environment variables - the API credentials your DNS provider needs (`CF_DNS_API_TOKEN`, `LOOPIA_API_USER`, ...). Name them exactly as the provider expects them, since the name is what reaches the container.

They are written to a 0600 env file on the proxy host and passed with `--env-file`, never on the command line: a DNS API token can rewrite your zone, and `docker run --env` would leave it in process listings and audit logs. Adding or renaming one changes the proxy's config digest, so the next deploy reboots the proxy to pick it up; rotating a value does not, so run `dash proxy reboot` yourself after a rotation.

```yaml
acme:
  email: admin@example.com
  dns_provider:
    platform.example: cloudflare
    legacy.example: hetzner
    default: route53
  prefer_wildcard: true
  http_fallback: false
  release_probe_interval: 60
  directory: https://acme-staging-v02.api.letsencrypt.org/directory
  credentials:
    - CF_DNS_API_TOKEN
```

#### Logging and tracing

`log_format` is the shape of every line the proxy writes, the access log included: `json` (the default) or `text`. `logfmt` is accepted as a name for `text`.

`trace_context` decides what happens to the W3C `traceparent` header: `off` ignores it, `propagate` (the default) logs the trace an incoming request carries and forwards the header untouched, and `generate` also starts a trace when a request arrives without one.

```yaml
log_format: json
trace_context: propagate
```

#### TLS floor

The lowest TLS version the HTTPS listener will negotiate: `1.2` (the default) or `1.3`. Quote it — unquoted YAML reads it as a number.

This narrows what the listener accepts and cannot widen it. TLS 1.0 and 1.1 are refused outright, and HTTP/3 is always 1.3 regardless.

```yaml
min_tls: "1.2"
```

#### HTTP/3

Offer HTTP/3 (QUIC) alongside HTTP/1.1 and HTTP/2. Needs UDP on the HTTPS port reachable as well as TCP.

Defaults to false:

```yaml
http3: true
```

#### PROXY protocol

Accept PROXY protocol v1/v2 headers on the HTTP and HTTPS listeners, so client addresses survive an L4 load balancer that cannot set `X-Forwarded-For`.

**Set `proxy_protocol_allow_ips` with it.** Left empty, the proxy honours a PROXY header from any peer that can reach the port — and that header rewrites the connecting address every other feature keys on, including `allow_ips` and `rate_limit`. Kamal warns when you enable one without the other.

```yaml
proxy_protocol: true
proxy_protocol_allow_ips:
  - 10.0.0.0/8
```

#### Metrics access

Serve the metrics endpoint (see `metrics_port` above) only to these addresses or CIDR ranges. Empty means everyone who can reach the port.

The metrics listener has no service behind it and matches the connecting address only — there is no forwarded chain to trust here, so `proxy/client_ip` does not apply.

To leave paths out of the metrics themselves, see `proxy/exclude_metrics_paths`, which is a per-service deploy setting.

```yaml
metrics_allow_ips:
  - 10.0.0.0/8
```

#### Server timeouts

Connection-level deadlines for the proxy's own listeners, in seconds. These are proxy-wide; the per-service deadlines are `response_timeout` and `request_timeout` above.

`read_header_timeout` bounds how long a client may take to send request headers. `read_timeout` bounds reading a whole request including its body, so a non-zero value truncates slow uploads. `write_timeout` bounds writing a whole response, so a non-zero value truncates SSE and streaming. `idle_timeout` is how long an idle keep-alive connection is kept.

Zero disables each of them, and is a real value rather than "unset".

`shutdown_timeout` is how long in-flight requests get to drain when the proxy stops.

```yaml
read_header_timeout: 10
read_timeout: 30
write_timeout: 30
idle_timeout: 60
shutdown_timeout: 30
```

Boot with an empty routing state when restoring the saved state fails, rather than refusing to start.

Defaults to false:

```yaml
ignore_restore_errors: true
```

Bind listeners with SO_REUSEPORT, so an overlapping proxy generation can share the ports during a handoff.

Defaults to false:

```yaml
reuse_port: true
```

#### Escape hatch for `dash-proxy run`

Anything dash-proxy accepts that has no key above. Note the difference from `options` below, which many people expect to do this: `flags` goes to `dash-proxy run`, `options` goes to `docker run`.

`true` renders a bare flag; anything else renders `--flag value`. No translation is applied, so write Go durations and lists the way dash-proxy wants them.

A key here that a named setting already emits is rejected rather than passed twice — set one or the other.

```yaml
flags:
  some-new-flag: value
```

#### Container runtime socket

Path to the container runtime socket, which is what `proxy/sleep` needs in order to stop and start containers. Setting it does two things: it passes the path to dash-proxy, and it mounts that path into the proxy container, since the flag alone only says where to look.

**Reaching this socket is root-equivalent on the host.** It is a separate, explicit setting for exactly that reason — nothing else in deploy.yml turns it on for you.

Defaults to none, with scale-to-zero disabled:

```yaml
docker_socket: /var/run/docker.sock
```

#### Response cache storage

Where the entries cached by `proxy/cache` are kept. Proxy-wide: one store serves every service on this proxy, so unlike the policy above it belongs here, in the run configuration. Adding or removing it reboots the proxy on the next deploy.

`store` is `memory` (the default — a per-node cache) or a `redis://` / `rediss://` URL that every proxy pointed at it shares, so one fetch warms the whole fleet.

The URL never appears on the proxy's command line: dash delivers it as `CACHE_STORE` in a 0600 env file on the host (alongside any ACME credentials), so a password in the URL stays out of process listings and kamal's audit log. The trade-off, same as the ACME credentials: changing only the URL's *value* does not move the drift digest — run `dash proxy reboot` after rotating it.

`store_timeout` only matters with a shared store, in seconds: how long the store may take to answer before the request goes to the app instead — a store that is slow or down then costs a cache, never a failed request.

```yaml
cache:
  store: redis://cache.example.com:6379/0
  store_timeout: 2
  memory_size: 134_217_728
```

`options` are `docker run` options for the proxy container - resource limits, labels, extra mounts - NOT dash-proxy flags. A dash-proxy flag the gem has no key for goes in `flags` above instead.

```yaml
options:                       # Additional options to pass to `docker run`
  label:
    - custom.label=dash-proxy
  memory: 512m
  cpus: 0.5
```

#### Enabling/disabling the proxy on roles

The proxy is enabled by default on the primary role but can be disabled by setting `proxy: false` in the primary role's configuration.

```yaml
servers:
  web:
    hosts:
     - ...
    proxy: false
```

It is disabled by default on all other roles but can be enabled by setting `proxy: true` or providing a proxy configuration for that role.

```yaml
servers:
  web:
    hosts:
     - ...
  web2:
    hosts:
     - ...
    proxy: true
```

> **Note:** Generated from the gem's `lib/dash/configuration/docs/proxy.yml` — the same reference `dash docs proxy` prints in your terminal, so this page always matches your installed version.