Skip to content

Service Routing Rules ​

A stack's services share one hostname. The rules below decide which service receives a given request: what a service may claim, what it may not, and what happens to a service that claims nothing. Anatomy of a Stack defines primary service, backend service and stack.

The model ​

Each application (per environment, per tenancy) renders as one vhost:

shop.example.com {
    # platform routes, always first
    /auth/*                          → the security portal
    /esb/*                           → the Manager
    /pilet-feed, /pilet-feed/*       → the Manager
    /pilets/*                        → the Manager (pilet bundles)
    /language-packs/*                → the Manager
    /comms/api/*                     → the Manager
    /comms/ws                        → the Manager

    # backend services, most specific prefix first
    /api, /api/*                     → shop--api

    # everything else
    /*                               → shop          ← the PRIMARY's upstream
}

Three consequences follow from that shape, and they are the whole model:

  1. The catch-all is the primary's upstream. Anything not claimed by a platform route or a backend prefix goes to the primary.
  2. A group with no primary row emits nothing. There is no honest catch-all to invent. Promoting a backend into that slot would route a whole hostname's unmatched traffic to one service.
  3. Prefix routes are rendered after the platform routes and before the catch-all. A prefix that collides with a platform route would not error; it would simply never fire. That is why collisions are rejected at write time: the write is the only place you can be told.

Claiming a prefix ​

bash
pfy service create api --app shop --route-prefix /api
pfy service update api --app shop --route-prefix /api --route-prefix /v2   # replaces the whole list
pfy service update api --app shop --clear-route-prefixes                    # makes it internal

Prefixes are absolute paths on the application's own vhost. A prefix must:

  • start with /;
  • contain no whitespace and no control characters;
  • contain none of { } ; # " ' \ \ * %(a single trailing*is stripped first, since that is an ordinary way to write a prefix) and no?`;
  • already be normalized: no .. or . segments and no repeated slashes (/api/../auth and //api are refused, with the normalized spelling in the error);
  • not be / (or /*, or //): nothing may claim the whole path space, which is the primary's;
  • not be reserved, in either direction;
  • not overlap another prefix in the same application.

Matching is on segment boundaries. The renderer emits two patterns per prefix, <p> and <p>/*, which is exactly how the tenant-selector middleware matches too. /api matches /api and /api/orders; it does not match /apifoo.

Prefixes on a primary service are refused. The primary owns everything that is not claimed by a backend, so a prefix on it would route the default target to itself.

Why % is banned ​

Caddy's path matcher normally compares the cleaned, unescaped request path. When the pattern contains a %, it also compares the raw, still-escaped path, so operators can match on escaped input. That turns percent escapes into an alias channel straight through every check on this page: /%5Ftenant is not the string /_tenant, so it passes the reserved-path check, but rendered as path /%5Ftenant* it captures escaped requests for the reserved tenant-selector route. A prefix has no legitimate need to be written escaped, so % is rejected outright.

Overlaps ​

Two prefixes in the same application may not overlap, in either direction: /api and /api/v2 are refused together. One path may be routed to only one service, so an overlap would leave the winner up to render order. The check is case-insensitive (Caddy's path matcher is), and it runs both within a service's own list and across every service of the application.

Reserved paths ​

These prefixes are served by the platform on every application vhost and can never be claimed. A service may claim neither a path under them nor a prefix that would swallow one.

ReservedServed by
/auth/*The caddy-security portal (login, callback, logout)
/_tenant, /_tenant/*The tenant-selector middleware, from inside the catch-all
/pilet-feed, /pilet-feed/*The Manager: pilet delivery
/comms, /comms/api/*, /comms/wsThe Manager: notifications and live data
/esb/*The Manager: Enterprise Service Bus
/_pfy/readyThe activator middleware, from inside the catch-all

/comms is reserved as a whole

Not just /comms/api/* and /comms/ws. Reserving only the two rendered matchers would let a service wedge itself between them.

/_tenant and /_pfy/ready are intercepted, not rendered

Both are handled by proxy plugin middleware from inside the application's catch-all route, so neither appears as a matcher in the rendered Caddyfile. A service claiming one would be shadowed just as thoroughly as one claiming /auth/*, with nothing in the rendered config to show why.

/pilets/* and /language-packs/* are rendered but not reserved

The renderer emits platform routes for /pilets/* (pilet bundle bytes) and /language-packs/* ahead of every service route, but the write-time reserved list does not include them. A service that claims /pilets or /language-packs is accepted and then silently never receives traffic on that prefix. Do not use those two prefixes.

Segment matching applies here too: /authenticator is a legal service prefix, /auth/callback is not.

Internal services ​

A backend service with no route prefixes is internal: it gets no route, and no hostname.

This is the render-side half of "internal services are unreachable from outside", and it is the half that matters. It would have been easy to emit the service's domain and simply give it no route, but that is not "internal": it is a published name that terminates TLS and black-holes every request to it. So an address that would resolve into a block with no route to that service is not emitted at all.

An internal service is still deployed, still scaled, still receives its data-store credentials, and is still reachable inside the cluster by its Nomad service name (shop--optimizer). What it does not have is a way in from the internet.

To publish an internal service later, give it a prefix:

bash
pfy service update optimizer --app shop --route-prefix /optimizer

Domains ​

Domains are configured on the deployment, and the environment's base_domain derives one more (<app>.<base_domain>). All rows of one (application, environment, tenancy) group render into one site block. A routed backend's explicit domain is treated as an alias of the application's block: the domains merge into one site address, derived names first, deduplicated case-insensitively.

Rows are sorted (primary, service slug, id) before merging, because the site address is rendered verbatim from the merged domain list. A row-order-dependent merge would make every push look like configuration drift.

Where the rules are enforced ​

LayerWhat it catches
Write (Manager: ent schema hook, internal/routeprefix)Malformed, reserved, un-normalized, self-overlapping prefixes; prefixes on a primary service
Write (Manager: deployspec.EnsureRoutePrefixesAvailable)A prefix already claimed by another service of the same application
Render (Manager: proxyconfig.GroupVHosts)The grouping rules above: catch-all selection, internal-service suppression, domain merge
PushCaddy applies atomically and keeps the last good config on rejection

The write-time checks are the ones you see. By render time a bad prefix is not an error, just a route that never fires, which is why the rules are enforced where they can still be explained.