Minimal, production-ready backend architecture.
- NestJS + DynamoDB + Docker + CodeBuild + IaC
- multi-tenant data layer
- append-only analytics instrumentation
- no domain logic
- no async/background systems
Live demos:
- reference-architecture.603.nz
- reference-architecture-auth0.603.nz — Auth0/OIDC backend demo
/src/backend → NestJS API
/src/frontend → React (Vite)
/src/mobile → React Native
Dockerfile → runtime
buildspec.yml → CI/CD (CodeBuild)
/infrastructure → deployment (Terraform + Cloudflare)
- stateless API
- tenant-aware data model
- configurable tenant resolution
- minimal and explicit
- no overengineering
- append-only analytics
This architecture follows the core ideas of the Twelve-Factor App where they still hold, with deliberate trade-offs for simplicity and cost.
-
Stateless compute
Services run as disposable containers. No in-memory state is required for correctness. -
Externalised state
All persistence lives in DynamoDB or external services. -
Config via environment
Runtime behaviour is controlled through environment variables. -
Build / release / run separation
Docker images are built once and promoted across environments.
-
DynamoDB single-table design
Data model is coupled to access patterns. Not portable by design. -
AWS-native infrastructure
API Gateway, ECS, and Cloud Map are used directly. Portability is not a goal. -
No async systems
This reference avoids queues and background workers. These are added at the application layer when needed. -
Multi-tenancy as a first-class constraint
Tenant isolation is enforced at the data and service layers. -
Tenant resolution by deployment Single-product apps can use a fixed tenant id, while SaaS/workspace-style apps can keep subdomain tenant resolution.
Any service instance can be terminated and replaced without data loss.
GET /api/healthGET /api/exampleGET /api/example/:idPOST /api/examplePATCH /api/example/:idDELETE /api/example/:idGET /api/auth/checkGET /api/me
Docker + CodeBuild + Terraform. Cloudflare for DNS.
Reusable AWS and Cloudflare infrastructure primitives are composed from jch254/terraform-modules, while app-specific configuration stays local in this repo. The AWS and Cloudflare Terraform roots remain separate so DNS can continue to read AWS outputs through remote state.
See infrastructure/README.md for details.
Set TENANT_RESOLUTION_MODE for each deployment:
| Mode | Required config | Use when |
|---|---|---|
fixed |
APP_TENANT_ID |
One product/deployment maps to one internal tenant |
subdomain |
BASE_DOMAIN |
Tenants are derived from workspace subdomains |
Tenant resolution is runtime logic only. DynamoDB table name and Terraform deployment control physical isolation. APP_TENANT_ID is not a substitute for a product/environment-specific table.
Fixed mode examples:
| Host | Table | TENANT_RESOLUTION_MODE |
APP_TENANT_ID |
Keys |
|---|---|---|---|---|
app.example.com |
product-prod |
fixed |
product-prod |
TENANT#product-prod |
test.example.com |
product-test |
fixed |
product-test |
TENANT#product-test |
In fixed mode, use one DynamoDB table per product/environment deployment. The tenant id represents the deployment/environment, not a user-facing workspace. Single-product deployments do not need to expose tenant, workspace, or organization concepts to users.
Subdomain mode preserves the original Reference Architecture pattern: acme.yourdomain.com resolves to tenant acme, while the apex domain and localhost resolve to default. In subdomain mode, a product/environment can intentionally share one table across many runtime tenants:
| Host pattern | Table | TENANT_RESOLUTION_MODE |
Keys |
|---|---|---|---|
*.example.com |
product-prod |
subdomain |
TENANT#acme, TENANT#demo, TENANT#jordan |
Existing DynamoDB keys remain tenant-aware in both modes: PK = TENANT#<tenantId>. Persisted tenant fields such as tenantSlug should remain on records that store them. Global app content can live under the configured tenant. Private resources should scope ownership from the local user returned by /api/me.
Backend authentication is provider-neutral in code, but each deployment chooses one primary provider with AUTH_PROVIDER=none|internal_magic_link|oidc. Dual-provider magic-link plus OIDC mode is intentionally not supported yet.
| Env var | Required | Default | Notes |
|---|---|---|---|
AUTH_PROVIDER |
no | internal_magic_link |
none, internal_magic_link, or oidc |
OIDC_ISSUER |
when AUTH_PROVIDER=oidc |
— | Auth0-compatible issuer, e.g. https://example.auth0.com/ |
OIDC_AUDIENCE |
when AUTH_PROVIDER=oidc |
— | Expected API audience, e.g. https://api.example.com or api://reference |
OIDC_JWKS_URI |
no | derived | Optional explicit JWKS URI, e.g. https://example.auth0.com/.well-known/jwks.json |
AUTH0_SPA_CLIENT_ID |
no | — | Public Auth0 SPA client id, surfaced to the browser via GET /api/config for the frontend Auth0 login flow. Not a secret; never the M2M client id or any client secret |
Tenant resolution and auth provider selection are separate axes. For example, single-product apps should normally use TENANT_RESOLUTION_MODE=fixed with AUTH_PROVIDER=oidc. Magic-link apps may use AUTH_PROVIDER=internal_magic_link with either tenant mode. Auth0-hosted passwordless, social, or passkey choices are external to this backend; the backend only validates OIDC JWTs.
Protected backend routes use the global AuthGuard. Mark public endpoints with @Public(). Controllers can read the normalized principal with @CurrentPrincipal(), and services can use AuthContext.getPrincipal() or AuthContext.requirePrincipal().
The active provider normalizes into:
{
provider: 'internal_magic_link' | 'oidc',
subject: string,
email?: string,
name?: string,
picture?: string,
}AUTH_PROVIDER=internal_magic_link accepts the existing opaque magic-link API bearer tokens and signed session cookies. AUTH_PROVIDER=oidc accepts compact OIDC JWT bearer tokens only. AUTH_PROVIDER=none leaves protected routes closed unless the route is explicitly public.
Authentication does not control tenancy. Tenant id still comes only from TenantResolver according to TENANT_RESOLUTION_MODE; JWT tenant, organization, or custom claims are ignored for tenant resolution. Auth0 Organizations are not used by default.
AuthPrincipal is provider identity, not the local app user. GET /api/auth/check stays a lightweight protected route that returns the normalized principal and does not persist users. GET /api/me finds or creates a tenant-scoped User for the current tenant plus provider + subject, updates lastSeenAt and safe profile fields, and returns that local user. Email is profile data only: it is not the primary identity key, the same email across providers is not merged, and account linking is out of scope.
User records use the existing single-table layout without a GSI:
PK = TENANT#<tenantId> SK = USER#<userId>
PK = TENANT#<tenantId> SK = USER_IDENTITY#<provider>#<sha256(providerSubject)>
The identity lookup item makes /api/me deterministic without scanning a tenant. Single-product apps should use /api/me as the starting point for user-owned data, then store future resources under the resolved tenant and local userId.
The example CRUD resource demonstrates the canonical pattern for authenticated, user-owned domain data. GET/POST/PATCH/DELETE /api/example routes are protected; GET /api/health stays public, /api/auth/check remains principal-only, and /api/me remains the local-user lookup/creation endpoint.
Example records are owned by the resolved tenant plus the local User.userId. Clients can send only domain fields such as name; the server ignores client-supplied tenantId or userId and attaches both owner fields itself:
TenantResolverstorestenantIdin request context.AuthGuardstores the normalizedAuthPrincipal.ExampleServicecallsUsersService.findOrCreateFromPrincipal(tenantId, principal).- The example item is written under that local
userId, not the raw provider subject.
The DynamoDB access pattern keeps tenant partitioning and scopes list queries to the current user:
PK = TENANT#<tenantId> SK = USER#<userId>#EXAMPLE#<exampleId>
Listing uses PK = TENANT#<tenantId> plus begins_with(SK, USER#<userId>#EXAMPLE#), so it does not scan all tenant examples. Read, update, and delete build the same key from the authenticated local user, so another user in the same tenant receives 404 for someone else's example. The same external provider subject in another tenant maps to a different local user and cannot access the first tenant's data.
This is the same pattern single-product apps should use for saved domain records, favourites, notes, and history: tenant id comes from the deployment/request, ownership comes from the local user model, and email/provider subject remain identity inputs rather than domain owner ids.
The frontend is a single Vite bundle served same-origin by the backend, and the
same bundle ships to every deployment. It does not use build-time VITE_* auth
env. Instead it calls a public runtime endpoint at boot:
curl https://reference-architecture.603.nz/api/config
# { "data": { "authProvider": "internal_magic_link", "auth0": null } }
curl https://reference-architecture-auth0.603.nz/api/config
# { "data": { "authProvider": "oidc",
# "auth0": { "domain": "...", "clientId": "...", "audience": ".../api" } } }GET /api/config is public, tenant-independent, and returns only public values
(the Auth0 SPA client id is public; no secrets are exposed). The frontend then:
internal_magic_link/none→ renders the existing magic-link demo UI unchanged. No Auth0 config is required or loaded.oidcwith a populatedauth0block → wraps the app inAuth0Provider(@auth0/auth0-react), shows Auth0 Universal Login (login/logout), and requests an access token for the configured APIaudience. The API client attachesAuthorization: Bearer <access_token>to/api/me,/api/auth/check, and example CRUD. After login the app bootstraps viaGET /api/me(the local tenant-scoped user);/api/auth/checkstays available for auth debugging.oidcwith noauth0block (missingAUTH0_SPA_CLIENT_ID) → the frontend shows a clear configuration message instead of a broken login.
Because the auth provider is resolved at runtime, the Auth0 demo build receives
no special frontend env: setting auth_provider = "oidc" and
auth0_spa_client_id in the deployment's Terraform var file is sufficient. The
existing reference-architecture.603.nz demo is unaffected — its
/api/config reports internal_magic_link and the magic-link UI is preserved.
401s are handled predictably (signed-out state, no retry loop) and access tokens are never logged.
Current deployment matrix:
| Host | Purpose | Terraform var file | State key | TENANT_RESOLUTION_MODE |
APP_TENANT_ID |
AUTH_PROVIDER |
|---|---|---|---|---|---|---|
reference-architecture.603.nz |
Existing/default demo | infrastructure/terraform/environments/prod/terraform.tfvars |
reference-architecture |
subdomain |
— | internal_magic_link |
reference-architecture-auth0.603.nz |
Auth0/OIDC backend demo | infrastructure/terraform/environments/prod-auth0/terraform.tfvars |
reference-architecture-auth0 |
fixed |
refarch-auth0-demo |
oidc |
The Terraform root still models one deployment identity at a time. The Auth0 demo is a second state/var-file deployment using the same modules and app code, so reference-architecture.603.nz keeps its existing resource names and state while reference-architecture-auth0.603.nz gets its own ECS service, task definition, API custom domain, certificate, CodeBuild project, SSM placeholders, and DynamoDB table named from reference-architecture-auth0.
The Auth0 demo now includes a frontend Auth0 login/logout flow (see Frontend authentication). CodeBuild system validation remains disabled for that deployment until a secure bearer-token injection path is configured; the browser flow is verified manually after Auth0 SPA dashboard setup. Public routing can be checked with:
curl https://reference-architecture-auth0.603.nz/api/healthExpected success returns {"data":{"status":"ok"}}.
To verify OIDC auth manually, fetch a real Auth0 access token for the configured API audience and call:
curl -H "Authorization: Bearer $AUTH0_ACCESS_TOKEN" \
https://reference-architecture-auth0.603.nz/api/auth/checkExpected success returns authenticated: true with an OIDC principal. Magic-link bearer tokens and session cookies should be rejected by this deployment.
To verify local user persistence for the same token, call:
curl -H "Authorization: Bearer $AUTH_BEARER_TOKEN" \
https://reference-architecture-auth0.603.nz/api/meExpected success returns data.user with provider: "oidc" and the fixed deployment tenant id. The smoke validator intentionally remains focused on /api/auth/check so CI does not create persistent users unless a future validation step explicitly opts into that.
The automated smoke validator can run the same OIDC check when a bearer token is supplied:
BASE_URL=https://reference-architecture-auth0.603.nz \
VALIDATION_AUTH_PROVIDER=oidc \
AUTH_BEARER_TOKEN="$AUTH0_ACCESS_TOKEN" \
pnpm run validateGET /api/auth/check without a bearer token should return 401 for AUTH_PROVIDER=oidc, and the validator checks that negative case. If AUTH_BEARER_TOKEN is omitted, OIDC validation runs as a partial smoke check and skips the authenticated request; set VALIDATION_REQUIRE_AUTH=true to fail instead. Token acquisition happens outside the validation script, for example with an Auth0 M2M client_credentials flow. Do not commit Auth0 client secrets or bearer tokens.
The two public demos use the same buildspec.yml, but not the same build execution. After the Auth0 deployment is bootstrapped, pushes to main can trigger two independent CodeBuild projects: one with TF_VAR_FILE=environments/prod/terraform.tfvars and one with TF_VAR_FILE=environments/prod-auth0/terraform.tfvars.
Manual Auth0 dashboard setup for reference-architecture-auth0.603.nz:
API (backend token validation):
- Create or choose an Auth0 API and set its identifier to the
oidc_audiencevalue used in Terraform (https://reference-architecture-auth0.603.nz/api). - Set
oidc_issuerto the Auth0 tenant issuer, for examplehttps://your-tenant.region.auth0.com/. - Leave
oidc_jwks_uriunset unless Auth0 requires an explicit JWKS URI override; the backend derives/.well-known/jwks.json.
SPA application (frontend browser login):
- Create an Auth0 Single Page Application (not the M2M client). The M2M client is only for backend
curltesting with aclient_credentialstoken; the browser login flow must use the SPA application. - Put the SPA application's client id in
auth0_spa_client_idininfrastructure/terraform/environments/prod-auth0/terraform.tfvars. It is a public value (safe to commit/expose), but deployment-specific. Never put the client secret in Terraform or the frontend. - Configure the SPA application URLs:
- Allowed Callback URLs:
https://reference-architecture-auth0.603.nz - Allowed Logout URLs:
https://reference-architecture-auth0.603.nz - Allowed Web Origins:
https://reference-architecture-auth0.603.nz - Allowed Origins (CORS), if prompted:
https://reference-architecture-auth0.603.nz
- Allowed Callback URLs:
- For local development against an OIDC backend, also add
http://localhost:3000(andhttp://localhost:5173if using the Vite dev server) to the same URL lists.
For the full end-to-end sequence to stand up an Auth0/OIDC deployment — Auth0 dashboard, new deployment identity, staged ACM/DNS bootstrap, runtime SSM secrets (including the always-required cookie-secret), first build, and verification — follow the Provisioning a new Auth0/OIDC deployment runbook. Apps scaffolded off this repo should follow that runbook end to end; it is written generically (<app>/<host>/<zone>), not just for the demo.
Backend:
pnpm install
pnpm run start:devFrontend (separate terminal):
cd src/frontend
pnpm install
pnpm run devFrontend dev server proxies /api requests to the backend on port 3000.
Production-like (single process):
pnpm run build
cd src/frontend && pnpm run build && cd ../..
pnpm run start:prod
Docker:
docker compose up --build
Starts the app, DynamoDB Local, and creates the table automatically.
- used as a reference architecture, not a product
- copied and adapted for new apps
This repo supports several tenant and auth profiles, but a generated app must not inherit all of them by default. Before scaffolding a new app, decide the deployment/auth profile explicitly. Treat this as a gate — not a default.
Decide before scaffolding:
- Tenant mode —
fixed|subdomain - Auth provider —
none|internal_magic_link|oidc - Frontend/mobile auth surface — none | magic-link form | Auth0 SPA/mobile flow
- User model needed immediately? — yes | no
Typical profiles:
| Profile | Tenant mode | AUTH_PROVIDER |
Auth surface | User model |
|---|---|---|---|---|
| Single product | fixed (APP_TENANT_ID=<app>-prod/-test) |
oidc |
Auth0 SPA/mobile | yes |
| Magic-link / workspace | subdomain or fixed |
internal_magic_link |
email magic-link | maybe (product-dependent) |
| Public/demo/simple tool | fixed |
none |
none | no |
Rule for the scaffold prompt / generator:
Before scaffolding, choose the deployment/auth profile. Do not scaffold unused auth flows. If
AUTH_PROVIDER=oidc, include only the OIDC/Auth0 client flow and backend bearer-token assumptions. IfAUTH_PROVIDER=internal_magic_link, include only the magic-link UI/session assumptions. IfAUTH_PROVIDER=none, omit auth UI entirely.
Concretely: a single-product scaffold takes the oidc path
(Provisioning a new Auth0/OIDC deployment)
and must not carry magic-link UI, subdomain/workspace assumptions, or other
flows just because Reference Architecture also supports them. The runtime
/api/config selector means the frontend only activates the chosen provider,
but the unused provider's UI/code should still be dropped from a generated app
rather than shipped dormant.
This is a baseline:
- proven deployment model
- consistent data patterns
- predictable runtime behaviour
It does not include:
- business/domain logic
- async pipelines or workflows
- complex orchestration
Those are layered on top per application.