Configuration

Proxy

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

Kamal uses 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.

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.

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.

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:

reboot_on_deploy: false

App port

The port the application container is exposed on.

Defaults to 80:

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:

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.

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:

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:

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.

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.

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.

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

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.

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.

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.

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.

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

Canonical host

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

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.

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"] %>
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.

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.

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.

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

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

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.

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.

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.

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.

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.

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.

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.

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.

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:

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.

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.

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.

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

6. Proxy container (run)#

Run configuration

These options are used when booting the proxy container.

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.

acme:
  email: [email protected]
  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.

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.

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:

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.

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.

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.

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:

ignore_restore_errors: true

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

Defaults to false:

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.

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:

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.

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.

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.

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.

servers:
  web:
    hosts:
     - ...
  web2:
    hosts:
     - ...
    proxy: true
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.