Aller au contenu

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)

make quickdeploy ENV=staging

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 timeNEXT_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}.enc in the Aether repo — SOPS-encrypted with age, safe to commit to GitHub.
  • Server plaintext: /opt/docker/helios/envs/.env.{dev,staging,prod} (chmod 600), which deploy.sh reads as ENV_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:

  1. src/lib/practices.ts — Single source of truth. src/proxy.ts derives 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 from params, 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.
  2. Nginx vhostsserver_name directives (in Aether nginx/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_DSN passed as a build arg to the prod image. It's NEXT_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=prod set as a runtime env var. It gates SEO indexing; any other value leaves the whole site noindex. Verify the live pages do not emit <meta name="robots" content="noindex"> and that /robots.txt allows 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

  1. Edit content in Aletheia /websites/
  2. Publish the page (draft → published)
  3. If ISR revalidation is configured (A5): page updates automatically
  4. 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 website field is non-empty or elapsed_ms is under Aletheia's threshold (~2s). Aletheia logger.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.theme has valid primary_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

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.