| Header rules (req/resp add/remove/set) |
rejected #62/#25 |
DONE — --{set,add,remove}-{request,response}-header on deploy (internal/server/header_rules.go), applied remove → set → add. Request rules run last in Target.rewrite, so a rule outranks the X-Forwarded headers the proxy set; they do not reach health checks, which use their own client (health_check.go:82). Response rules run in ReverseProxy.ModifyResponse, which only sees what the target produced — error pages, the TLS/canonical-host 301, and 401/429 rejections come from the proxy and keep its headers. Host is rejected on the request side: Go carries it in Request.Host, so a rule naming it would silently do nothing. CORS/HSTS/CSP presets not shipped; generic set rules express all three |
Weighted canary (--target=b;weight=5) |
kamal#941, #8 |
LoadBalancer.nextTarget (load_balancer.go:216) — currently pure round-robin |
| Cookie session affinity |
#26 |
DONE — --session-affinity, --session-affinity-cookie on deploy (internal/server/session_affinity.go). The pin sits in front of LoadBalancer.nextTarget and only ever replaces a selection, so an unpinned deployment runs the rotation it always ran. The cookie carries an HMAC of the target address under a key minted per load balancer, not the address: a cookie tells its holder nothing about the topology, and a guessed address cannot be confirmed by recomputing the digest. That key is per-NewLoadBalancer, so pins reset on deploy and on restart — the targets a pin named are gone by then anyway. A pin naming a target no longer in the healthy pool falls through to the rotation and is re-issued, so a dead pin can never 502. Reads served by a --read-target are not pinned (a replica holds no per-instance session state); writes are, and the existing kamal-writer cookie then keeps that client's reads on its pinned writer. Honoured on the first attempt only — --target-try-duration retries rotate past a pinned target that cannot serve |
| Redirect/rewrite rules |
#35; kamal discussions #1214/#97 |
DONE — --redirect '<pattern>=<replacement>[;status=<code>]' and --rewrite '<pattern>=<replacement>' on deploy (internal/server/redirect_rules.go). Both pieces of evidence only ask for www→apex, which --canonical-host already answers; what shipped is the path-level gap the issue names. Patterns are RE2 anchored to the whole path with $1 expansion. Redirects fold into redirectURLIfNeeded alongside the TLS/canonical hop, so a path move on a TLS service costs the client one redirect, not two; a rule resolving to the request's own URL is dropped so a catch-all cannot loop. A relative replacement is always rebuilt as scheme://host/..., which is what stops a captured //evil.com from becoming a scheme-relative Location. Rewrites apply last in serviceRequestWithTarget, after the health-check exemptions and the allow list, and skip isInternalRequest — the TLS on-demand probe runs the same chain, and a catch-all pointing it at the app's index would approve a certificate for any host |
| Compression (gzip/zstd/brotli) |
rejected #19 |
DONE — --compress, --compress-min-length, --compress-content-type on deploy (internal/server/compression.go, compression_middleware.go), wrapped outermost in Service.createMiddleware. Per-service, not per-target, because that is the only chain that sees proxy-written responses too. Encoding is chosen by the client's q values, ties by the order given. The decision is held until the body's size and type are known, which means a flush before any body must not settle it — ReverseProxy schedules exactly that after the headers of every unknown-length response (flushInterval returns -1), and it races the first write. text/event-stream is excluded outright, so the streaming bypass (response_buffer_middleware.go:86) never sees a compressed stream. The built-in error pages render above the router (server.go:338) and stay uncompressed; --error-pages ones do not |
| Shared response cache (RFC 9111 + stale-while-revalidate) |
#27 |
DONE — --cache and --cache-{max-body,max-ttl,vary-header,vary-cookie,allow-set-cookie} on deploy, --cache-store{,-timeout} and --cache-memory-size on run, kamal-proxy cache purge over a CachePurge RPC (internal/server/cache_*.go). Store is pluggable: in-process LRU or a Redis every proxy shares, always failing open — an unreachable store reads as a miss, never a 5xx. The cache is wired inside serviceRequestWithTarget, not in createMiddleware, so basic auth, the allow list, the rate limit and redirects all run before it; a hit is only ever handed to a client the target would have been asked for. Storing needs an explicit public plus a lifetime — freshness alone is not consent — and Set-Cookie needs --cache-allow-set-cookie on top. Vary is handled by refusing to guess: dimensions named in --cache-vary-header/--cache-vary-cookie are in the key, and a response varying on anything else is passed through uncached rather than risking one client's variant answering another's request. Accept-Encoding is the one exception — the cache sits inside --compress and stores what the target produced, so one entry serves every encoding, and a target-encoded body (Content-Encoding set) is refused instead. Concurrent misses coalesce through inflightGroup, which also caps a burst of stale hits at one background revalidation; the leader settles early with nil the moment a response proves unstorable, so followers are never held behind a stream they cannot be given a copy of. Rollout traffic keys separately — a canary is running different code |
| Scale-to-zero |
port PR #197 (open) |
PauseController states (pause_controller.go) are the natural base |
| Observability batch — log format selection, OTel traceparent, metrics path excludes |
#213 counter-proposal |
DONE — all three. Metrics path excludes shipped first as --exclude-metrics-path on deploy (service.go:175); the "counter-proposal" won and is merged upstream as #213, so nothing was left to port. --log-format json|text (internal/server/log_format.go) swaps the handler for the whole process, not just the access log, because both go through slog.Default(). --trace-context off|propagate|generate (internal/server/trace_context.go) reads the W3C traceparent and logs trace_id/span_id/trace_flags; the middleware sits inside WithLoggingMiddleware in Server.buildHandler, which is what creates the request context it writes to. The logged span_id is the caller's, never one the proxy minted — kamal-proxy exports no spans, so an invented id would parent the app to a span no backend has. generate marks new traces sampled (01) because an unsampled parent silently switches off tracing the app would otherwise have done itself, and drops a tracestate whose traceparent it replaced. The flag is --trace-context, not --traceparent, because OTel already defines TRACEPARENT as an env var carrying a real trace context and every flag here claims the matching env name |
| Liveness endpoint for external monitors |
#25 |
DONE — GET /.kamal-proxy/ping → 200 (internal/server/ping_handler.go), mounted outermost and unconditionally in Server.buildHandler. It reuses the /.kamal-proxy/ namespace but not DynamicDomainManager.WrapHandler, which only mounts when dynamic domains are configured — the endpoint has to answer on a proxy with zero services, which is the whole point. Outermost is what keeps it out of the access log: the logging middleware never sees it, at the cost of no request ID and no error page on that path. No readiness variant: RestoreLastSavedState runs before Start opens a listener and BeginDrain closes the listeners, so readiness here could only ever be 200 — the TCP state is the real signal. Not mounted on the metrics port, which is opt-in and IP-restricted |
| Scale-to-zero |
port PR #228 (open; supersedes the closed #197 the issue names) |
PauseController states (pause_controller.go) are the natural base. Upstream maintainer blessed the architecture in discussion #222: opt-in direct Docker socket behind a ContainerLifecycle interface, so a restricted host-side start/stop service can replace it later. Mounting docker.sock into the internet-facing proxy is root-equivalent on the host — opt-in only, and only when the feature is enabled |
| Observability batch — log format selection, OTel traceparent, metrics path excludes |
#213 counter-proposal |
logging_middleware.go:81 (fixed JSON today); request_id_middleware.go |