Co-authored-by: Hanzo Dev <[email protected]>
hanzoai/spa
Zero-config SPA server. Drop your build output and run.
FROM ghcr.io/hanzoai/spa
COPY dist /public
That's it. No flags, no config files, no Node.js.
What it does
- SPA mode always on —
index.htmlserved for all routes - Smart caching — hashed assets (Vite
index-DByAis3x.js) get 1 year immutable,index.html+config.jsonget no-cache - Pre-compressed — serves
.brand.gzfiles automatically - Security headers — HSTS, X-Content-Type-Options, Referrer-Policy, Permissions-Policy
- Health check —
GET /health→{"status":"ok"} - Runtime config — templates
/public/config.jsonfromSPA_*env vars at startup - Scratch-based — ~5MB image, zero attack surface
Environment
| Var | Default | Description |
|---|---|---|
PORT |
3000 |
Listen port |
ROOT |
/public |
Static files directory |
MULTI_APP |
false |
Hostname-prefix routing to /public/<app>/ |
DEFAULT_APP |
default |
Fallback app in multi-app mode |
ALLOW_FRAMING |
false |
Drop X-Frame-Options + frame-ancestors |
SPA_PROTECT |
— | iam locks the whole site behind Hanzo IAM OIDC (see below) |
SPA_* |
— | Templated into /public/config.json (see below) |
Protect mode
Set SPA_PROTECT=iam and the entire site is locked behind Hanzo IAM
(hanzo.id) OIDC — one env var, any static site. Anonymous visitors see only a
minimal white sign-in gate; no real markup or asset byte is served until an
allow-listed admin signs in via Authorization-Code + PKCE. Fail-closed
everywhere. Unset = no gate (byte-for-byte the default behavior).
| Var | Default | Description |
|---|---|---|
SPA_PROTECT |
— | iam enables the gate; empty/unset disables it |
SPA_PROTECT_ALLOW |
— | Comma list of allowed emails, case-insensitive. Required when enabled — empty gates everyone |
SPA_IAM_HOST |
https://hanzo.id |
OIDC issuer (reuses the existing var) |
SPA_PROTECT_CLIENT_ID |
— | Confidential OIDC client id (public) |
SPA_PROTECT_REDIRECT |
derived | OIDC callback; default https://<Host>/auth/callback |
SPA_PROTECT_BRAND |
Protected |
Optional wordmark on the gate |
PROTECT_CLIENT_SECRET |
— | Secret. OIDC client secret — env only, inject from a k8s Secret. No SPA_ prefix (see below) |
PROTECT_COOKIE_SECRET |
random | Secret. HMAC key for the session cookie — inject from a k8s Secret in prod so sessions survive restarts. No SPA_ prefix |
Why the secrets drop the
SPA_prefix: everySPA_*env var is templated into/public/config.json, which is served to browsers. So the two secrets use non-SPA_names and are read server-side only. As defense in depth,writeRuntimeConfigalso refuses to template anySPA_*key whose name containsSECRET,PASSWORD, orPRIVATE(but notTOKEN—SPA_COMMERCE_TOKENis a browser-safe Published token that must ship in config.json).
Reserved routes when enabled: /health (never gated), /auth/login,
/auth/callback, /auth/logout. OIDC endpoints are discovered from
${SPA_IAM_HOST}/.well-known/openid-configuration at startup. Identity is read
from the userinfo endpoint (hanzo.id may sign id_tokens with post-quantum
MLDSA65, which is not locally verifiable) over the access token.
Runtime config
The SPA reads /config.json on boot and derives its API/IAM/RPC hosts from it. One bundle runs on any environment; only the pod env changes.
env:
- name: SPA_ENV
value: test
- name: SPA_API_HOST
value: https://api.test.example.com
- name: SPA_IAM_HOST
value: https://iam.test.example.com
- name: SPA_RPC_HOST
value: https://rpc.test.example.com
- name: SPA_ID_HOST
value: https://id.test.example.com
- name: SPA_CHAIN_ID
value: "1"
Writes at startup:
{"apiHost":"https://api.test.example.com","chainId":1,"env":"test","iamHost":"https://iam.test.example.com","idHost":"https://id.test.example.com","rpcHost":"https://rpc.test.example.com","v":1}
Rules:
SPA_FOO_BAR→fooBar(camelCase).- All-digit values → JSON numbers.
true/false→ JSON booleans.- Other values → JSON strings.
v: 1is always set — schema version.- No
SPA_*vars set → the placeholder/public/config.jsonshipped with the image is left untouched (SPA falls back to its own defaults).
In MULTI_APP=true mode, each /public/<app>/config.json gets the same content. Per-app env overrides aren't needed because multi-app routing is hostname-based and SPAs detect env from hostname as a fallback anyway.
Usage
# Local
go run .
# Docker
docker build -t spa .
docker run -v ./dist:/public -p 3000:3000 spa
# Production (multi-stage)
FROM node:22-alpine AS build
WORKDIR /app
COPY . .
RUN pnpm install && pnpm build
FROM ghcr.io/hanzoai/spa
COPY --from=build /app/dist /public
Ship a placeholder /public/config.json in your build output for local dev; the SPA server replaces it at pod startup.
Tests
go test -v ./...
Licensed under MIT or Apache-2.0, at your option — per HIP-0137.