Helios Operations Guide¶
Deployment¶
Deploy to server¶
# Dev (from develop branch)
make deploy ENV=dev
# Staging
make deploy ENV=staging
# Production (requires a tag)
git tag v1.0
git push --tags
make deploy ENV=prod REF=v1.0
Quick deploy (skip git fetch)¶
Local development¶
npm run dev # Start dev server (localhost:3000)
npm run build # Production build
npm run lint # ESLint
Key Configuration¶
Environment variables¶
| Variable | Where | Purpose |
|---|---|---|
ALETHEIA_API_URL |
.env (server) |
Server-side API URL (Docker container name) |
REVALIDATION_SECRET |
.env (server) |
ISR webhook auth — X-Revalidate-Secret header on POST /webhooks/revalidate/, constant-time compared (must match Aletheia's HELIOS_REVALIDATION_SECRET) |
NEXT_PUBLIC_UMAMI_URL |
.env (server) |
Umami analytics script URL |
NEXT_PUBLIC_UMAMI_WEBSITE_ID |
.env (server) |
Umami website ID |
NEXT_PUBLIC_SENTRY_DSN |
build arg (Dockerfile) | Client-side Sentry DSN. Inlined at build time — NEXT_PUBLIC_* vars are baked into the bundle, not read at runtime. Defaults empty in the Dockerfile, so if the prod build doesn't pass it, client-side errors silently never report. |
ENVIRONMENT |
.env (server, runtime) |
Gates SEO indexing — must be prod in production or the whole site stays noindex. Read at runtime (not a build arg). |
Build-time vs runtime split. deploy.sh handles three categories differently:
- Public build targets (BUILD_API_URL, BUILD_CMS_ORIGIN) are hardcoded constants in deploy.sh per env, not read from the env file — they're public hostnames, and a hardcoded value is guaranteed-present and review-visible (a grep miss on a renamed key yields "", which would silently drop the CSP frame-ancestors and break the CMS preview iframe).
- NEXT_PUBLIC_* are grep'd from the env file and passed as build args — Next.js inlines them into the client bundle at next build, so changing them needs a rebuild.
- Runtime vars reach the container via compose env_file + environment:.
Secrets management (SOPS + age)¶
Helios has no database, so its secrets are just the API URL + REVALIDATION_SECRET (+ analytics/Sentry keys). They are managed from the Aether repo, not here:
- Source of truth:
envs/helios/.env.{dev,staging,prod}.encin the Aether repo — SOPS-encrypted with age, safe to commit to GitHub. - Server plaintext:
/opt/docker/helios/envs/.env.{dev,staging,prod}(chmod 600), whichdeploy.shreads asENV_FILE. Never committed. - Age key on the server:
/opt/docker/.age-key.txt.
Workflow (run from /opt/docker/aether/repo/):
make decrypt # .enc (repo) → plaintext at /opt/docker/helios/envs/.env.*
make encrypt # edit server plaintext, then re-encrypt → commit the .enc
make setup # bootstrap a fresh env file with baseline values
So to change a secret: edit the server plaintext → make encrypt → commit the .enc. To provision a new server: pull Aether → make decrypt.
Domain configuration¶
Domain → practice mapping is defined in two places:
src/lib/practices.ts— Single source of truth.src/proxy.tsderives its Host map from this and rewrites every page request to the internal/_sites/[practice]/…path (the tenant's code as segment value, Phase B); pages read the tenant fromparams, never from the Host header (which would force dynamic rendering and kill ISR). Unknown hosts get a plain 404 from the proxy — there is no default-tenant fallback.- Nginx vhosts —
server_namedirectives (in Aethernginx/conf.d/,/opt/docker/aether/repo/)
When adding a new practice: add one entry to practices.ts, update nginx vhosts.
Build-time Aletheia dependency¶
next build prerenders every active tenant (practice × page): it probes
GET /sites/{code}/config/ per registered practice against
ALETHEIA_API_URL (deploy.sh sets it to the env's public Aletheia URL as
BUILD_API_URL) and enumerates pages/team/jobs for the tenants that respond.
A tenant that 404s (unseeded env, is_active=False) or is unreachable is
skipped with a activePracticeCodes: skipping … build log line — the build
still succeeds and that tenant's pages render on demand (ISR) at runtime, so
a down Aletheia degrades the build to "no prerenders" rather than failing it.
Post-deploy smoke test¶
deploy.sh ends by running scripts/smoke.sh against the freshly deployed
env (20 end-to-end checks: tenant isolation, crawler status codes, images
through the optimizer, robots/sitemap/og, webhook auth). A failure does NOT
roll back — the new version is already live — it exits non-zero with the
failing checks printed; redeploy the previous ref if they're real. Run it
manually anytime: make smoke (see the script header for targets), and
against the other tenants' hosts with SMOKE_HOST=….
SEO protection¶
Non-production deployments are blocked from indexing by shouldIndexSite()
(src/lib/seo.ts): unless the process runs with ENVIRONMENT=prod, both
/robots.txt (src/app/_sites/[practice]/robots.txt/route.ts) and the
<meta name="robots"> tag (src/app/_sites/[practice]/layout.tsx) emit noindex.
Both consume the same helper, so they can't drift.
Per-practice go-live is controlled in Aletheia, not in Helios code: flip
SiteConfig.is_active for the practice — while inactive, its API endpoints
404 and Helios never serves the site.
Production launch checklist¶
Two settings are easy to miss because they fail silently — the site builds and serves fine, but observability or SEO is quietly broken. Both must be wired in the Aether deploy:
- [ ]
NEXT_PUBLIC_SENTRY_DSNpassed as a build arg to the prod image. It'sNEXT_PUBLIC_*, so it's inlined at build time, not read at runtime — and it defaults empty in the Dockerfile. If the build doesn't pass it, client-side errors never reach Sentry and nothing warns you. Verify after deploy by triggering a client error and confirming it lands in Sentry. (Note: the shared free Sentry quota is currently exhausted by Aletheia backend noise — see the team's Sentry-quota note — so a missing event may be quota, not config.) - [ ]
ENVIRONMENT=prodset as a runtime env var. It gates SEO indexing; any other value leaves the whole sitenoindex. Verify the live pages do not emit<meta name="robots" content="noindex">and that/robots.txtallows crawling.
Content Updates¶
Content is managed in Aletheia at /websites/:
- Pages:
/websites/pages/— create/edit pages with content blocks - Config:
/websites/config/— per-practice theme, SEO, domain settings - Media: upload images/videos via the page editor
Content update flow¶
- Edit content in Aletheia
/websites/ - Publish the page (draft → published)
- If ISR revalidation is configured (A5): page updates automatically
- If not: redeploy Helios to pick up new content (
make deploy ENV=prod REF=...)
Troubleshooting¶
Pages show stale content¶
Pages are static/ISR: prerendered at build, re-rendered when Aletheia POSTs
/webhooks/revalidate/ on a content change (tag-based, instant) or when the
time-based safety net expires (ALETHEIA_REVALIDATE_TTL, default 1h,
stale-while-revalidate). If an edit doesn't appear:
- Check the webhook: Sentry warnings revalidate: auth rejected mean
REVALIDATION_SECRET drifted from Aletheia's HELIOS_REVALIDATION_SECRET.
- Worst case, redeploy: make deploy ENV=prod REF=...
New practice shows 404¶
Check domain mappings:
1. src/lib/practices.ts — PRACTICES array
2. Nginx vhost — server_name
Contact form returns error¶
- The browser POSTs same-origin to
/api/contact, which forwards server-side to Aletheia (src/app/api/contact/route.ts). No CORS is involved. - Check Aletheia is running and
/api/v1/websites/sites/{code}/contact/endpoint responds - Check
ALETHEIA_API_URL(server-side, in the env file) points at a reachable Aletheia — the proxy uses the internal container URL on the server - A 502 from
/api/contact(with a Sentry event) means Helios couldn't reach Aletheia; a relayed 5xx means Aletheia itself errored - Bot protection is a honeypot (B19): a genuine lead is silently dropped server-side if the off-screen
websitefield is non-empty orelapsed_msis under Aletheia's threshold (~2s). Aletheialogger.warnings each drop — check its logs if real submissions vanish without a client-side error
Logo missing from the social (OG) card only¶
The /api/og social card reads the practice logo from the build-time
public/media/ cache (not live from Aletheia), so a logo uploaded after the
last build won't appear on the card until you redeploy — even though the site
header and JSON-LD logo update immediately (those go through the live /api/media
proxy). This is expected: redeploy Helios (make deploy ENV=prod REF=...) to
refresh the card. The route emits a Sentry warning (og card: logo not in media
cache: …) when this happens, so a "logo not on the social card" report routes to
redeploy, not a code bug. (An og card: unsupported logo type … warning instead
means the logo's format isn't in the card's allowlist — svg/png/jpg/webp.)
Dynamic theme not applying¶
- Check
SiteConfig.themehas validprimary_hue,primary_chroma,accent_hue,accent_chroma - Inspect
<html style="...">— should have inline OKLCH variables - Dev server needs restart to pick up new
NEXT_PUBLIC_*env vars
Nav dropdown not working on LAN IP¶
Dev-mode-only issue: Turbopack HMR doesn't hydrate React on non-localhost IPs.
Works in production builds (npm run build && npx next start).
Use localhost:3000 for dev testing of interactive features.