Skip to content

41 — Frontend Deployment (ASK Web UI)

The ASK frontend is a Next.js (SSR) web app — the user-facing UI at https://ask.mod.auh1.dev.dir. It runs as a Node server: the browser talks to the Next server, and the Next server calls the backend services (core-service, assistant-service, KMS) server-side. Follows the same GitOps pattern as the other services (sanitized chart → env values → Harbor image → ArgoCD Application).


How the frontend is configured (read first)

Two config classes — this split decides what we can fix at deploy time vs. what's locked in the image:

Class Examples When set Air-gap control
Server-side runtime env CORE_SERVICE_URL, ASSISTANTS_SERVICE_URL, KM_SERVICE_URL, AUTH_SECRET runtime (pod env) 🟢 Helm env — fully ours
NEXT_PUBLIC_* (build-time) NEXT_PUBLIC_APP_BASE_URL, NEXT_PUBLIC_APP_ENV, feature flags baked into the JS bundle at docker build 🔴 locked in the image — only changeable by rebuild

Required env or the pod crashes at boot

apps/web/lib/env/app-environment.server.ts validates a zod schema at startup. These are required strings with no default — if any is missing, .parse() throws and the container crashes (same failure class as KMS's @format keys): AUTH_SECRET, BILLING_API_KEY, ASSISTANTS_SERVICE_URL, KM_SERVICE_URL, CORE_SERVICE_URL, VOICE_API_SERVICE_URL, STRAPI_SERVICE_URL. Voice and Strapi aren't deployed here — but their URL vars must still be set to a placeholder to satisfy zod. Those features then fail lazily if used (acceptable — they're off).

Build-time URL caveat (NEXT_PUBLIC_APP_BASE_URL)

The frontend-stg image was built (by GitLab CI) with config/stg.env values — likely pointing at a cloud/staging domain, not ask.mod.auh1.dev.dir. NEXT_PUBLIC_* are inlined into the client bundle at build, so the browser-side base URL / feature flags reflect that build. Server-side backend calls are fine (runtime env), but client-side absolute URLs may be off until the image is rebuilt with air-gap NEXT_PUBLIC_*. Verify after deploy; rebuild is the proper fix if the UI points at the wrong host. (Many flows are same-origin via the Next server, so this is often tolerable for bring-up.)


Air-gap changes from the PP baseline

Concern PP (values-ai71-pp-auh1-prod-ask.yaml) Air-gap (mod-auh1-dev)
Image registry.pp.gov.ae/ask/frontend-prod harbor.cl1.sq4.aegis.internal/ask/frontend-stg:staging
Ingress host ask.pp.gov.ae (+ chat./admin.) ask.mod.auh1.dev.dir (cert-manager internal-ca-issuer)
Backend URLs public *.ask.pp.gov.ae + NODE_TLS_REJECT_UNAUTHORIZED=0 internal service URLs (preferred) or ingress URLs + NODE_TLS_REJECT_UNAUTHORIZED=0
Node pool node.kubernetes.io/role: ask ask71
Autoscaling HPA 3–100 disabled / 1 replica for bring-up

Backend URL strategy

The Next server calls backends server-side, so prefer in-cluster service URLs (http://core-service.ask.svc.cluster.local, …) — no TLS bypass, no hairpin DNS. PP itself notes NODE_TLS_REJECT_UNAUTHORIZED=0 exists only because it used public URLs and can be removed once internal URLs are used. If a backend's fastapi_root_path matters (assistant-service serves under /ask71/v2/astsvc), verify whether the frontend expects the prefixed URL or the bare service.


Required env vars (what to set in values)

Env var Value (air-gap) Source
AUTH_SECRET openssl rand -hex 32 Vault → frontend-secret
AUTH_TRUST_HOST "true" values
BILLING_API_KEY placeholder (billing not wired) Vault → frontend-secret
CORE_SERVICE_URL http://core-service.ask.svc.cluster.local (or ingress) values
ASSISTANTS_SERVICE_URL assistant-service URL (mind /ask71/v2/astsvc) values
KM_SERVICE_URL http://knowledge-management-service.ask.svc.cluster.local values
VOICE_API_SERVICE_URL placeholder (voice not deployed) values
STRAPI_SERVICE_URL placeholder (Strapi CMS not deployed) values
EXTERNAL_INTEGRATIONS_URL omit (optional, defaults "")
NODE_TLS_REJECT_UNAUTHORIZED "0" only if using ingress (self-signed) URLs values

AUTH_SECRET + BILLING_API_KEY are secrets → Vault secret/ask/frontend/auh1-dev → ESO → frontend-secret → env valueFrom.secretKeyRef. (The base chart has no externalsecrets.yaml, so add a small ExternalSecret in the chart or devops, like the other services.)


Prerequisites

  • [ ] core-service, assistant-service, KMS deployed (the backends the UI calls)
  • [ ] Zitadel/SSO reachable (login flow goes via core-service IAM)
  • [ ] DNS: ask.mod.auh1.dev.dir → ingress LB; cert-manager issues its TLS cert
  • [ ] eso-ask-push policy grants secret/data/ask/frontend/* (add it — same step as KMS)
  • [ ] Harbor: ask/frontend-stg:staging mirrored (Phase 1)

Phase 1 — Sanitize chart + create env values

  1. Create the sanitized repo modgpt/mod-ask-frontend (helm/ only), like KMS/assistant.
  2. Add helm/environments/values-mod-auh1-dev-ask.yaml — full file at modgpt/mod-ask-frontend/helm/environments/values-mod-auh1-dev-ask.yaml. Key runtime env:

    env:
      - { name: NODE_TLS_REJECT_UNAUTHORIZED, value: '0' }   # PP pattern — internal CA
      - { name: AUTH_TRUST_HOST, value: "true" }
      - { name: CORE_SERVICE_URL, value: "http://core-service.ask.svc.cluster.local" }
      - { name: ASSISTANTS_SERVICE_URL, value: "http://assistant-service.ask.svc.cluster.local" }
      - { name: KM_SERVICE_URL, value: "http://knowledge-management-service.ask.svc.cluster.local" }
      - { name: VOICE_API_SERVICE_URL, value: "http://voice-not-deployed.invalid" }
      - { name: STRAPI_SERVICE_URL, value: "http://strapi-not-deployed.invalid" }
      - { name: AUTH_SECRET, valueFrom: { secretKeyRef: { name: frontend-secret, key: AUTH_SECRET } } }
      - { name: BILLING_API_KEY, valueFrom: { secretKeyRef: { name: frontend-secret, key: BILLING_API_KEY } } }
    ingress:
      hosts:
        - { host: ask.mod.auh1.dev.dir, paths: [{ path: /, pathType: Prefix }] }
        - { host: admin.ask.mod.auh1.dev.dir, paths: [{ path: /, pathType: Prefix }] }
      tls:
        - { secretName: frontend-tls, hosts: [ask.mod.auh1.dev.dir, admin.ask.mod.auh1.dev.dir] }
    

    Build-time vars — must be baked into the image

    These cannot be set at runtime. The AI71 build must include:

    Var Value
    NEXT_PUBLIC_APP_BASE_URL https://ask.mod.auh1.dev.dir
    NEXT_PUBLIC_APP_ENV mod-auh1-dev-ask
    NEXT_PUBLIC_AMPLITUDE_ENABLED false
    NEXT_PUBLIC_SENTRY_ENABLED false
    NEXT_PUBLIC_FF_DISABLE_VOICE_MODE true
    NEXT_PUBLIC_FF_DISABLE_SPEECH_TO_TEXT true
    1. helm template smoke test (zero errors); confirm the env block + ingress render.

Mirror the image (bastion)

Update mirror-config.yaml (remove the placeholder digest, like KMS) and run on the bastion:

- { name: frontend-stg, source: acr, project: ask, tags: ["staging"] }
./core42-mirror-image.py --only frontend


Phase 2 — Vault secret + frontend-secret

The frontend chart has no ExternalSecret template. Secrets are seeded to Vault for auditability, then materialized as a k8s Secret manually from the bastion (same one-time pattern as the CA ConfigMap).

Key What it is Air-gap value Consequence if missing
AUTH_SECRET NextAuth session-signing secret. Required by zod. openssl rand -hex 32 — generate once, keep stable (rotating logs everyone out) pod CrashLoops (zod Required)
BILLING_API_KEY Billing service API key. Required by zod; billing not wired. openssl rand -hex 32 placeholder pod CrashLoops (zod Required) — placeholder is mandatory

2a. Seed Vault

export VAULT_ADDR=https://vault.cl1.sq4.aegis.internal VAULT_SKIP_VERIFY=true
vault kv put secret/ask/frontend/auh1-dev \
  AUTH_SECRET="$(openssl rand -hex 32)" \
  BILLING_API_KEY="$(openssl rand -hex 32)"

2b. Create frontend-secret on bastion (one-time)

kubectl create secret generic frontend-secret -n ask \
  --from-literal=AUTH_SECRET="$(vault kv get -field=AUTH_SECRET secret/ask/frontend/auh1-dev)" \
  --from-literal=BILLING_API_KEY="$(vault kv get -field=BILLING_API_KEY secret/ask/frontend/auh1-dev)" \
  --dry-run=client -o yaml | kubectl apply -f -
kubectl get secret -n ask frontend-secret   # → Opaque, 2 keys

Create BEFORE the app syncs

The Deployment mounts frontend-secret via secretKeyRef. If the secret doesn't exist when the pod starts, it fails immediately. Order: seed Vault (2a) → create secret (2b) → ArgoCD sync.


Phase 2.5 — DNS (so the browser/Mac can reach the UI)

The UI is reached at https://ask.mod.auh1.dev.dir. The *.ask.mod.auh1.dev.dir hosts resolve to the HAProxy VIP 100.115.2.210 (TLS passthrough → nginx ingress termination with the internal CA). In-cluster pods resolve via the cluster DNS automatically; your Mac/laptop needs the hosts added.

Required hosts for the full login flow (frontend + SSO redirect + API):

Host Purpose
ask.mod.auh1.dev.dir the frontend UI
sso.ask.mod.auh1.dev.dir Zitadel login (the SSO redirect target)
api.ask.mod.auh1.dev.dir backend API (client-side calls + curl tests)
cdn.ask.mod.auh1.dev.dir static assets (if referenced)

On the Mac (/etc/hosts) — all point to the same VIP:

sudo tee -a /etc/hosts >/dev/null <<'EOF'

# ASK frontend + login + API (HAProxy VIP)
100.115.2.210  ask.mod.auh1.dev.dir api.ask.mod.auh1.dev.dir cdn.ask.mod.auh1.dev.dir sso.ask.mod.auh1.dev.dir
EOF
sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder 2>/dev/null
ping -c1 ask.mod.auh1.dev.dir          # → 100.115.2.210

VIP vs ingress-node IP

The DNS-zone doc lists ingress-controller node IPs 100.115.1.51/52/53, but the platform routes the ASK hosts through the HAProxy VIP 100.115.2.210 (same VIP as gitlab/argocd/vault). Use 100.115.2.210 on the Mac to stay on the same TLS-passthrough path the other ASK hosts use. Reachable from the Mac over VPN (port 443 confirmed). /etc/hosts has no wildcard — list each host.


Phase 3 — ArgoCD Application

Fill the stub mod-ask-devops/environment/auh1/dev/02-argocd-application/ask/argocd-application-frontend.yaml (multi-source chart + $values overlay, project: mod-auh1-dev-ask.ask, namespace: ask, syncOptions: CreateNamespace=true, ServerSideApply=true) — copy the KMS/assistant Application shape.


Phase 4 — Deploy & verify

kubectl get pods -n ask | grep -i frontend
kubectl logs -n ask deploy/frontend --tail=40   # watch for zod env errors at boot
Check Test Healthy
Boot (zod env) logs show no ZodError/Required at startup pod Running
Health kubectl exec -n ask deploy/frontend -- wget -qO- localhost:3000/api/health (or via httpx) 200
Ingress + TLS curl -sk https://ask.mod.auh1.dev.dir/api/health 200
Backend wiring open the UI, log in (Zitadel via core-service), load assistants UI renders, login works, assistant list loads

Likely first issues: - ZodError: Required at boot → a required env var (voice/strapi/billing) is unset → add the placeholder. - Login redirect loop / wrong host → NEXT_PUBLIC_APP_BASE_URL baked for another domain → needs image rebuild (build-time caveat above). - Backend calls fail with TLS errors → using ingress URLs without NODE_TLS_REJECT_UNAUTHORIZED=0, or switch to internal service URLs.


Known deviations / follow-ups

  • NEXT_PUBLIC_* baked for the wrong domain — proper fix is rebuilding frontend-stg with air-gap config/<env>.env. Bring-up tolerates it where flows are same-origin.
  • Voice / Strapi / Billing — not deployed; placeholders satisfy zod, features inactive.
  • NODE_TLS_REJECT_UNAUTHORIZED=0 — non-production (TLS bypass). Remove once on internal service URLs or once the internal CA is trusted by the Node runtime (same pattern as the S3 TLS deviation).