Files
cameleer-saas/docs/superpowers/specs/2026-04-05-single-domain-routing-design.md
hsiegeln 9a8881c4cc docs: single-domain routing design spec
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>
2026-04-05 20:46:00 +02:00

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)

  1. ui/vite.config.ts — Set build.assetsDir: '_app'
  2. 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} (no auth. prefix)
    • All LOGTO_PUBLIC_ENDPOINT, LOGTO_ISSUER_URI use ${PUBLIC_PROTOCOL}://${PUBLIC_HOST} (no auth.)
  3. docker/logto-bootstrap.sh — Host header = ${HOST} (matches ENDPOINT, no auth. prefix). Redirect URIs use ${PROTO}://${HOST}/callback.
  4. ui/src/config.ts — Fallback logtoEndpoint = ${window.location.origin} (same origin)
  5. 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_PATH env var (default: /)
  • At build time or container start, set Vite's base to BASE_PATH value
  • 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_URLhttp://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 iss claim against cameleer.oidc.issuer-uri (string comparison)
  • Accept at+jwt token type header (Logto uses this instead of plain jwt)
  • 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.