Path-based routing on one hostname. SPA assets move to /_app/, Logto gets /assets/ + /oidc/ + /interaction/. Server-ui at /server/. Includes requirements for server team (split JWK/issuer, BASE_PATH). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
4.8 KiB
Single-Domain Routing Design
Problem
Customers cannot always provision subdomains (auth.domain.com, server.domain.com). The current architecture requires 3 DNS records. Some enterprise environments only allow a single hostname pointing at the server.
Root Cause
Logto's sign-in UI serves assets at /assets/*. The SPA (Vite-built) also outputs assets to /assets/*. With path-based Traefik routing on a single domain, both conflict on the same prefix.
Solution
Move the SPA's asset output directory from /assets/ to /_app/. Then Traefik can route /assets/* to Logto while the SPA's assets are served from /_app/* via the catch-all. Single domain, single DNS record, no subdomains.
Architecture
Routing (all on ${PUBLIC_HOST})
| Path | Target | Priority |
|---|---|---|
/oidc/* |
logto:3001 | default |
/interaction/* |
logto:3001 | default |
/assets/* |
logto:3001 | default |
/server/* |
cameleer3-server-ui:80 (prefix stripped) | default |
/api/* |
cameleer-saas:8080 | default |
/* (catch-all) |
cameleer-saas:8080 | 1 (lowest) |
SPA assets at /_app/* are served by cameleer-saas through the catch-all route.
Logto Configuration
ENDPOINT=${PUBLIC_PROTOCOL}://${PUBLIC_HOST}(same domain as SPA)- OIDC issuer =
${PUBLIC_PROTOCOL}://${PUBLIC_HOST}/oidc - Same origin for browser — no CORS, cookies work
- Logto Management API (
/api/*on port 3001) is only accessed via Docker-internal networking, never through Traefik — no conflict with cameleer-saas/api/*
TLS
- Self-signed cert init container generates wildcard cert on first boot (dev)
- Traefik ACME (Let's Encrypt) for production
- HTTP→HTTPS redirect
Customer Bootstrap
# .env
PUBLIC_HOST=cameleer.mycompany.com
PUBLIC_PROTOCOL=https
# DNS: 1 record
cameleer.mycompany.com → server IP
# Start
docker compose up -d
Changes by Repository
cameleer-saas (this repo)
ui/vite.config.ts— Setbuild.assetsDir: '_app'docker-compose.yml— Revert to path-based routing:- Logto:
PathPrefix(/oidc) || PathPrefix(/interaction) || PathPrefix(/assets) - Server-UI:
PathPrefix(/server)with strip prefix middleware - SPA:
PathPrefix(/)priority 1 (catch-all) — same as original - Remove Host-based routing labels
- Logto
ENDPOINT=${PUBLIC_PROTOCOL}://${PUBLIC_HOST}(noauth.prefix) - All
LOGTO_PUBLIC_ENDPOINT,LOGTO_ISSUER_URIuse${PUBLIC_PROTOCOL}://${PUBLIC_HOST}(noauth.)
- Logto:
docker/logto-bootstrap.sh— Host header =${HOST}(matches ENDPOINT, noauth.prefix). Redirect URIs use${PROTO}://${HOST}/callback.ui/src/config.ts— FallbacklogtoEndpoint=${window.location.origin}(same origin)ui/src/components/Layout.tsx— Dashboard link =/server/
cameleer3-server-ui (server team)
Add configurable BASE_PATH env var so the SPA can be served from a subpath.
Build changes:
- Accept
BASE_PATHenv var (default:/) - At build time or container start, set Vite's
basetoBASE_PATHvalue - If using runtime config: nginx rewrites
<base href="/">to<base href="/server/">at container startup
nginx changes:
- Serve SPA at the configured base path
- API proxy remains internal (
CAMELEER_API_URL→http://cameleer3-server:8081)
In cameleer-saas docker-compose.yml:
cameleer3-server-ui:
environment:
CAMELEER_API_URL: http://cameleer3-server:8081
BASE_PATH: /server/
cameleer3-server (server team)
Add split JWK/issuer OIDC configuration so token validation works in Docker environments where the public issuer URL is not directly reachable from containers.
What to add:
- New config property:
cameleer.oidc.jwk-set-uri(or equivalent) - When set, fetch JWKs from this URL instead of deriving from the OIDC discovery document
- Validate token
issclaim againstcameleer.oidc.issuer-uri(string comparison) - Accept
at+jwttoken type header (Logto uses this instead of plainjwt) - Algorithm: ES384 (Logto's signing algorithm)
Reference implementation: cameleer-saas/src/main/java/.../config/SecurityConfig.java — custom JwtDecoder bean that does exactly this.
In cameleer-saas docker-compose.yml:
cameleer3-server:
environment:
CAMELEER_OIDC_ISSUER_URI: ${PUBLIC_PROTOCOL}://${PUBLIC_HOST}/oidc
CAMELEER_OIDC_JWK_SET_URI: http://logto:3001/oidc/jwks
Future: Custom Sign-In UI (Roadmap)
For full auth UX control (branding, custom flows), build a custom sign-in experience using Logto's Experience API. This eliminates Logto's interaction pages entirely — Logto becomes a pure OIDC/API backend. The SPA renders all auth screens (login, register, forgot password, MFA, social login).
This is the right long-term play for a SaaS product but is a separate project.