Routing & Ingress (/api + /mcp)
Routing & Ingress (/api + /mcp)
In one sentence. The deployer turns a single declared set of routes into concrete reverse-proxy configuration for two distinct hops — a browser-facing edge (Caddy on the compose target, nginx-ingress on the k8s target) and a server-side internal aggregator (the wip-router component, Caddy on both targets) — so the two deployment targets and the two hops can't disagree about what's exposed.
Why it exists. WIP ships two deployment targets (compose, k8s) and needs two different request paths: one for browsers hitting the edge (TLS, auth delegation to the gateway) and one for apps that want to call WIP's stores server-side without going back out through the public edge. wip-router exists to replace an older, compose-only hardcoded internal address with an explicit component visible to both renderers. On the edge side, both Caddy (compose) and nginx-ingress (k8s) are built to consume the same route list so the two renderers can't drift on what a given path does.
The model
Route fields, as read by the renderers in scope. The route/rule objects themselves are constructed elsewhere (wip_deploy.config_gen, outside this doc's scope — see the gap note below); this table lists only the attributes the renderers in scope actually consume.
| Field | Meaning | Read by |
|---|---|---|
path | The route's mount point (e.g. an /api/<service> prefix, or /mcp) | router_caddy.py, compose_caddy.py, k8s.py |
backend_host / backend_port | Internal host:port the router forwards to | router_caddy.py |
backend_component / backend_port | Component name (rendered as wip-<component>) + port for the edge reverse_proxy | compose_caddy.py |
backend_service / backend_port | k8s Service name + port for an Ingress backend | k8s.py |
streaming | If true: Caddy sets flush_interval -1; nginx-ingress sets proxy-buffering off | all three renderers |
auth_protected | If true: the route is wrapped in forward_auth (edge Caddy) or gets auth-url/auth-signin/auth-response-headers annotations (nginx-ingress). Not read by the internal router — it never auth-gates. | compose_caddy.py, k8s.py |
redirect_bare_path | If true (default posture): a bare path 301-redirects to <path>/. If false: no redirect is emitted, and the renderer instead emits an explicit sibling handle for the bare path alongside the <path>/* wildcard. | router_caddy.py, compose_caddy.py |
strip_prefix | If true: the route's own path prefix is stripped before the backend sees the request (handle_path in Caddy; a regex path + rewrite-target annotation in nginx-ingress) | compose_caddy.py, k8s.py |
preserve_prefix_subpaths | A list of subpaths under a strip_prefix route that keep their prefix unstripped and are served from their own, earlier-matching block | compose_caddy.py, k8s.py |
Config objects per renderer.
| Config object | Carries (as read in scope) | Rendered by |
|---|---|---|
RouterConfig | listen_port, routes | render_router_caddyfile (router_caddy.py) — internal router, both targets |
CaddyConfig | hostname, https_port, tls_mode, root_redirect, gateway_service, gateway_port, routes | render_caddyfile (compose_caddy.py) — edge, compose target |
IngressConfig | hostname, tls_secret_name, ingress_class, proxy_body_size, gateway_auth_url, root_redirect_target, rules | _render_ingress (k8s.py) — edge, k8s target |
⚠ GAP:
RouterConfig,CaddyConfig,IngressConfigand the route/rule objects they carry are constructed indeployer/src/wip_deploy/config_gen/{router,caddy,nginx_ingress}.py. None of those files are in this doc'ssource_scope, so the tables above list only the fields the in-scope renderers actually touch — not necessarily the complete shape those objects carry.
How it works
Edge, compose target (compose_caddy.render_caddyfile): one Caddy site block on <hostname>:<https_port> (or localhost:<https_port> when hostname == "localhost").
- TLS is configured per
tls_mode:internalemits a self-signed issuer with a 720h lifetime;externalemitstls off(TLS is terminated upstream);letsencryptemits nothing beyond the globalauto_httpsdirective. - If no component declares its own
/route, a bare-host redirect sends/tocfg.root_redirect. - Routes are sorted by descending path length and rendered as
handle/handle_pathblocks: an optional bare-path redirect, an optionalforward_authwrapper,strip_prefixunwrapping (withpreserve_prefix_subpathscarved out into their own blocks first), and areverse_proxytowip-<backend_component>:<backend_port>(withflush_interval -1whenstreaming). - A terminal
handle /api/* { respond 404 }block is written before the catch-all. - The catch-all (
path == "/"route, if declared) is rendered last, wrapping the same auth logic.
Internal router, both targets (router_caddy.render_router_caddyfile): a second, separate Caddy instance. auto_https off (it only ever serves internal plain HTTP — without the directive Caddy probes upstream ACME on startup even with no HTTPS site declared). For each route: a handle <path>/* block reverse-proxying to <backend_host>:<backend_port> (flush_interval -1 when streaming); if redirect_bare_path is false, a sibling handle <path> block is emitted first for the same backend. No forward_auth anywhere — callers present X-API-Key themselves. Same terminal /api/* 404 guard as the edge Caddyfile.
Edge, k8s target (k8s._render_ingress): the same logical route set is classified into buckets and rendered as separate nginx-ingress Ingress resources rather than ordered handle blocks:
- Non-
strip_prefixroutes split into: the auth-gateway's own route, unauthenticated API routes, and auth-protected app routes. - The auth-gateway route(s) and unauthenticated API routes share one
Ingress(wip-ingress), all pathspathType: Prefix. - Each auth-protected app route gets its own
Ingress(named<service-without-wip->-ingress); ifcfg.gateway_auth_urlis set,auth-url/auth-signin/auth-response-headersannotations are added. - Each
strip_prefixroute gets its ownIngressusing nginx-ingress's regex-path +rewrite-targetidiom (pathType: ImplementationSpecific); if it declarespreserve_prefix_subpaths, the strip regex excludes those subpaths via a negative lookahead, and the subpaths get a second, unrewrittenIngress(<name>-admin,pathType: Prefix). - If
cfg.root_redirect_targetis set, a standaloneIngress(wip-root-redirect) uses thepermanent-redirectannotation to 301/— the annotation fires before any proxying, so its declared backend (chosen by longest path-prefix match against the redirect target) is never actually contacted. - All Ingresses share a base annotation set:
ssl-redirect: true,proxy-body-sizefromcfg.proxy_body_size, andproxy-read/send-timeout: 3600(matching Caddy's default idle timeout, against nginx-ingress's 60s stock default — needed for long uploads).
Internal router, k8s target: no separate rendering path — the same RouterConfig/Caddyfile produced by router_caddy.render_router_caddyfile is written into a wip-router-config ConfigMap (key Caddyfile) by k8s._render_configmaps, gated on whether the router component is active, and mounted at /etc/caddy/Caddyfile via subPath by k8s._render_component. The router component's own descriptor supplies the image (caddy:2), the listen port (8080, named http), and the run command (caddy run --config /etc/caddy/Caddyfile --adapter caddyfile) — identical on both targets; only the mount mechanism differs (bind-mount on compose, ConfigMap on k8s).
Router activation. The router component declares requires_core: true: in an apps-only install (no core services deployed) there is nothing internal to route to, so the router doesn't render. Apps that need a remote WIP base URL in that mode point at it through a spec-level external base URL instead of through the router component.
Rules & invariants
- The
/api/*404 guard is Caddy-only, and shared verbatim between the edge Caddyfile and the router Caddyfile (caddy_common.write_api_fallthrough_404). Both renderers emit it as a terminal, least-specifichandle /api/*block, so Caddy's longest-match never lets it shadow a live route. nginx-ingress needs no equivalent — an unmatched path there already 404s by default. redirect_bare_pathis a per-route opt-out, not a global switch. The default is a 301 redirect from the bare path to a trailing slash. A route that mounts at its own bare path and does its own canonicalization opts out; the renderer then emits an explicit sibling handle for the bare path, because Caddy's<path>/*wildcard glob does not match the bare path on its own.- Caddyfile's
redirdirective parses a leading/argument as a path matcher, not a literal destination. Every redirect destination in these renderers (the bare-host/redirect, the per-route bare-path redirect, and theforward_auth401→login redirect) is therefore written with an explicit*matcher ahead of the destination to disambiguate. - Auth delegation is expressed consistently across both edge implementations. Caddy's
forward_authand nginx-ingress'sauth-url/auth-signin/auth-response-headersannotations both call the gateway at/auth/verify, both turn its 401 into a redirect to/auth/login?return_to=..., and both carryX-WIP-User,X-WIP-Groups,X-API-Keyback onto the request. See Auth & Access for what the gateway itself does with those headers. strip_prefixexists for backends that compute signatures over an unprefixed path (the renderers' own comments name MinIO's S3 API as the motivating case): the public prefix is removed before the backend sees the request.preserve_prefix_subpathscarves a component's own URL space (e.g. an admin or health path) out of that stripping so it's served unmodified, from a block/Ingress that wins the match ahead of the stripped one.- Route ordering matters more defensively than functionally in Caddy: routes are sorted by descending path length before emission even though Caddy's own
handlematching is order-independent for non-overlapping prefixes. nginx-ingress instead relies on separate, purpose-specificIngressresources (main / per-app / per-strip-rule) rather than intra-file ordering. - The router only exists when core services do.
requires_core: truemeans the router — and anyWIP_BASE_URLwiring that depends on it — is absent in apps-only deployments by construction, not by a runtime check.
Gotchas
- A request to a stale or misspelled
/api/*path does not 404 by Caddy's own default — it gets an empty 200 (content-length 0). A caller that treats a 200 as success and an empty body as "nothing here" gets fooled rather than told. The explicit/api/*guard exists specifically to turn that into a loud 404, on both the edge Caddyfile and the router Caddyfile. - The same silent-empty-200 trap is why a route that mounts at its own bare path (rather than accepting the SPA-style redirect) needs two things to work, not one: opting out of the bare-path redirect and getting an explicit sibling bare-path handle. Skipping either leaves the bare path unmatched, and a client expecting JSON gets an empty 200 instead of a 404 or the real response.
- The router's healthcheck (
curl ... http://localhost:8080/accepting any 3-digit status code) only proves the Caddy process is listening — the router has no/handler of its own, so a healthy router with zero configured routes still passes. tls_mode: letsencryptcurrently emits nothing beyond the globalauto_httpsdirective in the compose Caddy renderer — see the code finding below.
See also
⚠ GAP: the canonical reading list's fixed cross-reference map states what points to Routing & Ingress (every API doc, and the Deployment doc) but does not enumerate Routing & Ingress's own outbound "See also" targets. The entries below are inferred from that same map's directionality, not discovered by reading sibling docs.
- Auth & Access — the gateway this doc's edge routes delegate to (
forward_auth/auth-urlannotations); this doc covers only how the delegation is wired into the route configuration, not the gateway's own session/header semantics. - Deployment (wip-deploy) — the CLI/renderer surface that produces the artifacts this doc describes.
