Proxy

Traffic shaping

Rate limits, IP lists, per-path deadlines, read routing, session affinity, and scale to zero — at the proxy, not in your app.

First: who the client is#

Rate limiting and IP lists are only as correct as the address they key on, so configure client_ip 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. Once you declare trusted_proxies (and only when the connecting address is one of them), the proxy walks the forwarded chain backwards past every proxy you declared; the first address none of them wrote is the client. List every hop — a chain the proxy cannot resolve denies the request rather than falling back.

config/deploy.yml
proxy:
  client_ip:
    header: CF-Connecting-IP    # only honoured with trusted_proxies set
    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.

config/deploy.yml
proxy:
  rate_limit:
    requests: 100    # per second, may be fractional (0.5 = one per 2s)
    burst: 20        # back-to-back allowance (default: the rate, rounded up)
    exempt:          # addresses the limit skips — monitors, health probes
      - 10.0.0.0/8

Allow, deny, and user-agent lists#

config/deploy.yml
proxy:
  allow_ips:            # serve only these; everything else gets a 403
    - 10.0.0.0/8
  deny_ips:             # refuse these — checked before allow_ips
    - 203.0.113.0/24
  deny_user_agents:     # RE2 patterns against the full User-Agent
    - 'BadBot/.*'

An address on both lists is denied, and denied clients never spend rate-limit budget. User-agent patterns are checked after the IP rules; a missing User-Agent only matches an explicit ^$ pattern.

Two enforcement notes, straight from the config validation:

  • The health check path is served without an address check or a rate limit, so it stays reachable during a deploy — which is why dash rejects healthcheck: path: / while either feature is on.
  • With a load balancer, all of this moves to the load balancer: an allow list on the per-host proxies would refuse every request (they only ever see the load balancer), and one rate limiter would count the whole fleet as a single client.

Two deadlines, per path#

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. They are not interchangeable: 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.

config/deploy.yml
proxy:
  response_timeout: 10   # default 30s
  request_timeout: 30    # default 0 = no limit

  # Override either deadline below a path prefix; 0 removes the limit,
  # which suits streaming and SSE endpoints.
  path_response_timeouts:
    "/api/reports": "5m"
    "/stream": 0
  path_request_timeouts:
    "/uploads": "10m"
    "/stream": 0

Read routing & session affinity#

read_routing splits traffic between the deployed (writer) targets and read-only targets — app instances backed by database replicas. Read requests go to the read targets; writes always go to the writers, and writer_affinity_timeout keeps a client's reads on the writer briefly after it writes, so clients always read their own writes.

config/deploy.yml
proxy:
  read_routing:
    targets:
      - 192.168.0.2:3000
      - 192.168.0.3:3000
    websockets: true             # route WebSockets to readers too (default false)
    writer_affinity_timeout: 10  # seconds; default 1

  session_affinity:
    enabled: true                # pin each client to the target that first served it
    cookie: _kamal_affinity      # rename the (HttpOnly, opaque) pin cookie

Affinity is off by default, rightly — it exists for apps holding session state in the instance. When a pinned target leaves the pool, the next request falls through and re-pins, so a deploy strands nobody. With a load balancer, both features are decided there — the only layer that sees the whole fleet.

Scale to zero#

Stop the 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.

config/deploy.yml
proxy:
  sleep:
    after: 300         # idle seconds before stopping
    wake_timeout: 30   # how long a request waits before a 503
  run:
    docker_socket: /var/run/docker.sock
Stopping and starting containers means talking to the runtime, so run/docker_socket must be set — and reaching that socket is root-equivalent on the host, which is why it is a separate, explicit setting. Not compatible with on-demand TLS: a sleeping target cannot answer the ask endpoint.