The operator console
Everything an operator touches lives behind one hostname, admin.example.com, and one Logto role.
It is a second Caddy site on port 8082 inside the same container that serves the public origin,
described on Deployment topology.
One host, several paths
Section titled “One host, several paths”Paths rather than a name per tool, and the reason is money. Cloudflare’s free Universal SSL
certificate covers the apex and one level of subdomain, so admin.example.com is covered while
observability.admin.example.com would need Advanced Certificate Manager at ten dollars a month.
| Match | Goes to |
|---|---|
/console, /console/* |
302 to the Logto console origin |
/oauth2/* |
oauth2-proxy:4180, the sign-in, callback and sign-out endpoints |
/grafana/* |
grafana:3000, prefix intact |
/traces/* |
phoenix:6006, reserved for a later tracing phase |
| everything else | frontend:3000, which owns the operator pages under /app/admin |
The convenience redirect to Logto is /console and deliberately not /auth, because the SvelteKit
app owns /auth/* for its own Logto sign-in and sends every unauthenticated /app/* request
there. Redirecting /auth away from the frontend would swallow that flow and the operator pages
could never finish signing anyone in.
Grafana keeps its prefix because GF_SERVER_SERVE_FROM_SUB_PATH makes it serve /grafana itself,
so the rule is a plain handle rather than the handle_path that would strip it. It also keeps
its loopback host port on purpose, so a broken gate cannot lock an operator out of the dashboards.
One gate, one role
Section titled “One gate, one role”Every path except /oauth2/* passes through a Caddy forward_auth call to
oauth2-proxy:4180/oauth2/auth first. A session carrying the operator role comes back 202 and the
request proceeds with X-Auth-Request-User, -Email and -Groups copied onto it. Anything else
comes back 401 and Caddy redirects to /oauth2/sign_in with the original URL as the return
address. That return address is spelled https by hand rather than taken from {scheme}, because
the site only ever hears plain HTTP once TLS has terminated at Cloudflare, and an http return
would drop the Secure session cookie and loop the sign-in. The site strips those three headers off
every inbound request before the gate runs, so a client can never arrive already claiming to be
someone.
Authorization is the aizk-admin role from src/deploy/logto.conf, read out of Logto’s roles
claim, which the roles scope adds to the token. Granting console access is a role assignment in
Logto and nothing else, and aizk admin auth roles prints who currently holds it. The AIZK Admin
application in Logto carries Mandatory MFA set to passkey, so operators answer a second factor
without changing how anyone signs in to the memory itself.
The Cloudflare hostnames
Section titled “The Cloudflare hostnames”The tunnel is externally managed, so these public hostnames are added in the Cloudflare dashboard rather than in this repository.
| Public hostname | Service |
|---|---|
aizk.example.com |
web:8081 |
auth.example.com |
logto:3001 |
admin.example.com |
web:8082 |
console.example.com |
logto:3002 |
All four are first-level subdomains, which is what keeps the certificate free.
Cloudflare Access can sit in front of admin.example.com as a second, independent layer. Its free tier
covers 50 users and its Independent MFA policy asks for a WebAuthn key at the edge, before a
request ever reaches the tunnel. It is worth adding because it fails differently than the Logto
gate does, so one of them being wrong does not open the console. It stays optional, and the
oauth2-proxy gate is what the deployment actually depends on.
- Deployment topology has every service and the public routing table.
- First start walks the Logto applications and hostname cutover.
- The security model explains why the process split looks like this.