Architecture
Meerkat is one process. It listens on three planes, and they are three different jobs, for three different audiences, on three different ports.
| Data plane | Control plane | Internal plane | |
|---|---|---|---|
| Default port | :8080 |
:9090 |
:7070 |
| HTTPS port | :8443 |
:9443 |
:7443, once a certificate is placed on it |
| Serves | your users: your applications, and the pages the gateway serves itself | your operators and their agents: the admin API and the console | the services behind the gateway: the JWKS, the scheduler's API |
| Belongs on | the network your users reach | an internal network, never the public one | inside the cluster, never behind an ingress |
Where it sits
One binary in front of the services your application is made of. What reaches it is a browser or an API client; what it forwards is a request that has already been decided on.
The line down the middle of the services is the one that matters. A UI route is a page a person looks at, so the gateway dresses it: the navigation portal, the account button, the theme, the light and dark scheme, all injected into the HTML on the way out. The application ships no library for any of it and does not know it is being dressed.
An API route is called by a program, so there is nothing to dress. What it gets instead is a signed token in a header - who is calling, their roles, their organisation - and it authenticates nobody. That is the whole point: your services stop carrying a login page and a user table.
A service can be both, and often is: the same application serving its pages and its own API behind two routes.
Why they are separate
Because a mistake on one must not be a mistake on the other.
- The console is never reachable on the data plane. No route can expose it, because it is not mounted there at all.
- Each plane has its own session cookie -
MEERKAT_SESSIONandMEERKAT_ADMIN_SESSION- and every stored session is stamped with the plane it belongs to. A token copied from one cookie into the other is refused: the two ports never share a browser session. - API tokens carry a plane too. A data-plane token never opens the admin port, and the other way round.
- The internal plane answers tokens only. A browser session is refused there, and "inside the cluster" is never taken as a credential: any pod reaches a ClusterIP, so nothing on that port gives any power without a token. The JWKS is the one exception, because public keys give none.
- Publishing the console does not publish the internal plane. An operator who opens the console on the company network has not opened, along with it, the API services write their schedules with.
- One consequence is worth knowing before you need it: the control plane's plain HTTP port stays plain, for good. It is what a broken certificate gets repaired from. Only the data plane's plain port redirects to HTTPS.
What lives on the data plane
Your routes, and the pages the gateway serves in its own name:
| Path | What |
|---|---|
/login, /logout |
sign-in, and the steps after it: /update-password, /totp, /totp-enroll, /select-tenant, /select-group |
/register, /confirm, /forgot-password, /reset-password |
account entry points, each governed by a setting - self-registration ships closed |
/profile/... |
what a person may do about their own account: password, MFA, passkeys, API tokens, sign-in history |
/refused, /account-pending |
why a request was turned away, and what to do next |
/meerkat/... |
the assets the gateway injects into proxied pages, served by the engine rather than by a route |
/healthz, /readyz |
liveness and readiness |
/login or /meerkat/ will not be reached for them: the engine's own handlers are registered first, and everything else falls through to the router.What lives on the control plane
/api/...- the admin API, about two hundred endpoints./- the console, an Angular application embedded in the binary. English only, with no locale segment: it is an operator's tool./login,/logout- the console's own sign-in, so that origin is self-sufficient./mcp- the endpoint an agent connects to./healthz,/readyz.
The console reads the schedules and acts on them - pause, resume, run now, replay, delete - and writes none: creating or changing one is the service's, on the internal plane.
What lives on the internal plane
/.well-known/jwks.json- the public keys the identity JWT verifies against. No token needed./.well-known/openid-configuration- the discovery document: the issuer, and where those keys are. No token needed./api/schedules/...- the scheduled calls, written by the service that needs them, with a token whose perimeter isschedules./healthz.
Plain HTTP by default, since a service reaches it by a name inside the cluster that no public certificate carries; HTTPS on :7443 once a certificate from the cluster's own PKI is placed on it (TLS). The data plane does not serve the JWKS: there, the path is an ordinary one.
One binary, no dependency
The storage is embedded - a file in the directory given by -data. The console is compiled into the binary. There is no CGO, no sidecar, no message broker and no cache to run beside it.
An external PostgreSQL database is an option, and it buys one thing: several gateways serving one installation. It was chosen for what it carries beyond storage - LISTEN/NOTIFY, which is how one gateway tells the others that something changed, and advisory locks, which is the one exclusion the product needs.
How a change takes effect
Routes are stored, not configured in a file. Saving one recompiles the whole routing table into a snapshot and swaps it atomically - in-flight requests finish on the table they started with. Nothing restarts, and nothing is reloaded from disk.
In a cluster the write is announced on the change bus, and every node recompiles. SIGHUP does the same thing locally, for the cases where something outside the console wrote to the database.
A route whose vault references resolve to nothing is left out of the compiled table, with a warning line, rather than failing the whole reload. That is the normal state of a gateway just seeded from a configuration file whose vault is not filled in yet.
Probes
/healthz is liveness and answers UP unconditionally. That is the correct answer, not a lazy one: liveness decides whether to kill the process, and a probe failing on an unreachable database would restart every node at once for a fault none of them can fix by dying.
/readyz is readiness and answers 503 with a reason when the store is not answering, or when the routing table has not been compiled yet. Point your load balancer at this one.