Records the per-environment config strategy for both apps:
SPA — Angular `environment.ts` + project.json `fileReplacements`.
Build-time substitution; no runtime config fetch (rejected for
the LCP/TTFB cost it would add and the deploy-time HTML rewrite
the alternatives need). Concretely cleans up the hard-coded
URLs we left in observability/tracing.ts and home-status.service.
ts ("hard-coded for v1 — env-config when it lands" comments).
BFF — `process.env` + small per-key boot-time validators (the
shape `apps/portal-bff/src/config/check-database-url.ts`
already follows). `@nestjs/config` rejected as too heavy for the
current key count.
Audit log connection — formalises the `AUDIT_DATABASE_URL` split
that ADR-0013 §"Wired as features land" already pointed at: when
the env var is set, the AuditModule instantiates a second Prisma
client with audit_writer-only credentials (defense in depth);
when unset, the dev fallback (shared pool + `SET LOCAL ROLE
audit_writer` per transaction) keeps working. A boot-time UPDATE-
rejection self-test runs against the dedicated pool when
configured — refuses to start if the pool can mutate the audit
table.
Cross-references in the §Confirmation list pull together the
env-var-as-boot-gate items already pre-figured in ADR-0009 /
ADR-0010 / ADR-0012 / ADR-0013 / ADR-0014, so the validator
pattern is the single landing place when those features ship.
Doc index and CLAUDE.md updated; no code changes in this PR.
16 KiB
status, date, decision-makers, tags
| status | date | decision-makers | tags | ||||
|---|---|---|---|---|---|---|---|
| accepted | 2026-05-10 | R&D Lead |
|
Environment configuration — Angular environment.ts, BFF env vars, audit pool split
Context and Problem Statement
Both apps need per-environment configuration values — service endpoints, observability targets, feature flags, secrets — but the runtimes are radically different:
- The BFF is Node and naturally reads
process.env. There is already a.env.examplecatalogue (seeapps/portal-bff/.env.example) and a startup validator forDATABASE_URL(check-database-url.ts). - The SPA is a pre-built static bundle served to the browser. There is no
process.envat runtime, and the bundle is identical across dev / staging / production unless we build per-environment artefacts.
Two questions need a recorded answer before they bite the next time a feature lands:
- How does the SPA learn its environment-specific endpoints? Today the OTLP collector URL and the BFF API URL are hard-coded in
apps/portal-shell/src/observability/tracing.tsandapps/portal-shell/src/app/pages/home/home-status.service.ts. That works forlocalhostdevelopment, breaks the moment we ship to staging or production. - How does the BFF connect to Postgres for the audit log? ADR-0013 specifies the audit log uses a distinct Postgres role (
audit_writer). v1 of the audit module (audit.service.ts) cuts a single connection from the sharedDATABASE_URLpool and runsSET LOCAL ROLE audit_writerper transaction. ADR-0013 §"Wired as features land" already lists a separateAUDIT_DATABASE_URLpool as the production-grade hardening; this ADR records the actual contract so the implementation is unambiguous when it ships.
Decision Drivers
- Twelve-factor compliance — config in the environment, not in the build artefact, wherever feasible.
- No surprise drift between dev and prod — same mechanism, different values; not "dev uses X, prod uses Y".
- Simplicity for the contributor —
cp .env.example .env, edit, run. No config-discovery folklore. - Prod-faithful security — the BFF connection that writes audit rows must not be the same connection that reads / writes the rest of the database. Defense in depth.
- Stability bar — recognised, battle-tested patterns. No bricolage.
Considered Options
SPA configuration mechanism
- Angular
environment.ts+fileReplacements— asrc/environments/environment.tsfile holds the dev defaults; per-environment files (environment.staging.ts,environment.prod.ts) are swapped in at build time via the project.json'sfileReplacements. Idiomatic Angular, supported by@angular/build. (Chosen.) - Runtime fetch from
/config.json— the SPA fetches a JSON file from the server at boot, before any feature code runs. Late-binding, single artefact across environments. <meta name="config" content="…">injected inindex.html— the deploy step rewrites the<meta>content; the SPA readsdocument.querySelector('meta[name=config]').contentat boot.- esbuild
definesubstitution —process.env.Xreferences in source are textually replaced at build time; equivalent toenvironment.tsbut less Angular-idiomatic.
BFF audit-log connection
- Single
DATABASE_URL+SET LOCAL ROLE audit_writerper transaction — current v1. One Prisma client, one pool. Role switch wraps each INSERT. - Separate
AUDIT_DATABASE_URLpool with a login user that hasaudit_writermembership only — second Prisma client, second pool. The connection cannot perform anything exceptINSERT INTO audit.events. (Chosen for production hardening; v2 of the audit module.) - Single connection that runs as
audit_writerpermanently — everything goes through the audit-writer role. Wrong: the BFF needs many more privileges thanaudit_writerfor the rest of its work.
BFF env-var loading
process.envdirectly + a small validator at boot — current pattern. Plain Node, no dep. Fail loud on missing or malformed values. (Chosen, kept.)@nestjs/config— Nest-idiomatic config module. Schema validation, hierarchical lookup,.envloading. Heavier surface for what amounts to ~10 keys today.zodschema overprocess.env— typed validation, narrow surface. Adds a runtime dep.
Decision Outcome
SPA — Angular environment.ts with build-time fileReplacements
apps/portal-shell/src/environments/environment.tsis the default (dev), checked in. Holds two keys today:bffApiBaseUrlandotlpEndpoint. Both default tohttp://localhost:3000/apiandhttp://localhost:4318/v1/tracesrespectively.apps/portal-shell/src/environments/environment.staging.tsandapps/portal-shell/src/environments/environment.prod.tsship later — checked in, since they target real, public, non-secret URLs (the BFF and Collector are public services from the SPA's perspective).- The Angular build target's
productionconfiguration inproject.jsonadds afileReplacementsentry that swapsenvironment.tsforenvironment.prod.ts; a futurestagingconfiguration does the same withenvironment.staging.ts. Build script:nx build portal-shell --configuration=productioncontinues to do the right thing without code changes. - Anything secret (none today on the SPA side, by design) is not in
environment.ts. The SPA is a static bundle; secrets baked in are public secrets. If a feature ever requires a per-user / per-tenant secret, it lives in the session payload (per ADR-0010), never in the bundle.
Concrete migration of today's hard-coded values:
| Today | After |
|---|---|
'http://localhost:4318/v1/traces' in observability/tracing.ts |
environment.otlpEndpoint |
'http://localhost:3000/api/health' in pages/home/home-status.service.ts |
${environment.bffApiBaseUrl}/health |
propagateTraceHeaderCorsUrls: [/^http:\/\/localhost:3000\/.*/] |
derived from environment.bffApiBaseUrl |
The runtime-fetch and <meta> alternatives are rejected: same artefact across environments is appealing but introduces a startup-blocking fetch (RUM cost) and a deploy-time HTML-rewrite step (extra moving piece). Build-time replacement is cheaper, idiomatic, and fits our infrastructure-as-code approach to deploy artefacts.
BFF — process.env + boot-time validator, with explicit AUDIT_DATABASE_URL split
- BFF config keys remain plain
process.env['X']reads, validated at boot in small dedicated files (the same shape asapps/portal-bff/src/config/check-database-url.ts).@nestjs/configis rejected: too much surface for a small key set; Nest's DI is not a real win when config is read once at boot. - The validator family grows as feature ADRs land —
LOG_USER_ID_SALTonce auth is wired (ADR-0009 / ADR-0013),SESSION_ENCRYPTION_KEYandSESSION_*_TIMEOUT_SECONDSonce sessions land (ADR-0010),OBO_CACHE_ENCRYPTION_KEYand<SERVICE>_API_BASE_URLonce downstream wiring lands (ADR-0014). Each of those ADRs already mentions "BFF refuses to start without …" — that contract is implemented in this validator pattern. AUDIT_DATABASE_URLis the production split: when set, it overrides the sharedDATABASE_URLfor the audit module's Prisma client only. The connection string points at the same Postgres instance but uses a login user that is a member ofaudit_writeronly — no superuser, no public-schema CRUD, justINSERT INTO audit.events. When not set (the dev default), the audit module falls back to the shared client +SET LOCAL ROLE audit_writerper-transaction (current v1 behaviour). The fallback explicit choice keeps the dev experience one-step (single.env, no role-membership setup beyond the existing init.sql), while guaranteeing production gets the harder isolation.- A boot-time self-test asserts the split when
AUDIT_DATABASE_URLis set: a deliberateUPDATE audit.events SET …is issued and is expected to fail withpermission denied. If it succeeds, the BFF refuses to start — the audit log is no longer append-only. - Renamed from the previous
.env.exampleplaceholderAUDIT_DATABASE_URL (separate creds, role 'audit_writer')— the contract is now the formal one above.
Per-environment file structure
Both apps converge on the same naming for clarity:
apps/portal-shell/src/environments/
environment.ts ← dev, checked in
environment.staging.ts ← staging, checked in (later)
environment.prod.ts ← prod, checked in (later)
apps/portal-bff/.env.example ← single source-of-truth template, checked in
apps/portal-bff/.env ← per-developer values, git-ignored
Production BFF env values are managed by the deploy platform's secret manager (per the future on-prem infrastructure ADR — phase 3b); the .env.example stays the catalogue.
Consequences
- Good, because every env-specific value has an obvious home and an obvious change procedure (edit
environment.{env}.tsfor SPA, edit secret-manager /.envfor BFF). - Good, because the SPA stays a static bundle — no runtime config-fetch latency, no extra deploy step beyond what
nx buildalready does. - Good, because the audit-pool split is now formalised. The migration from v1 (
SET LOCAL ROLE) to v2 (separate pool) is a config change in production (setAUDIT_DATABASE_URL) and a small code change inaudit.module.ts(instantiate a second client when the env var is set). Both are bounded. - Good, because the boot-time validator pattern keeps the BFF crashing early on misconfig — never a request-time surprise.
- Bad, because per-environment
environment.tsfiles mean the SPA does not produce a single artefact reusable across environments. Every promotion through the deploy pipeline rebuilds. Acceptable: build is fast (~2 s) and the alternative (runtime config) was rejected on its own merits. - Bad, because Angular's
fileReplacementsis a build-time mechanism that hides the substitution from the source — the contributor readingenvironment.tsdoes not see the prod values without openingenvironment.prod.tsand the project.json target. Mitigated by keeping all three files side by side insrc/environments/. - Neutral, because BFF config still uses raw
process.env— no fancy schema validation. Acceptable while the key set stays under ~30; revisit withzodif it grows substantially.
Confirmation
Wired as part of the next feature work that touches per-environment config:
apps/portal-shell/src/environments/environment.ts(and per-environment siblings) created;bffApiBaseUrlandotlpEndpointexposed.- The hard-coded URLs in
observability/tracing.tsandpages/home/home-status.service.tsconsume the environment object instead. apps/portal-shell/project.jsonproductionconfiguration declares thefileReplacementsswap; astagingconfiguration follows when the staging build target is set up.- BFF gains a
apps/portal-bff/src/config/audit-database-url.tsvalidator that readsAUDIT_DATABASE_URL(optional) and exposes a typed config; theAuditModuleinstantiates a second Prisma client when the var is set. - Boot-time UPDATE-rejection self-test runs against the audit-writer pool when configured.
apps/portal-bff/.env.exampledocumentsAUDIT_DATABASE_URLper the contract above (replacing the placeholder line).
Wired as the corresponding feature ADRs ship (already documented in their own §Confirmation, repeated here for indexing):
LOG_USER_ID_SALTmandatory at boot — auth ADR-0009 / sessions ADR-0010 / observability ADR-0012.SESSION_ENCRYPTION_KEY,SESSION_IDLE_TIMEOUT_SECONDS,SESSION_ABSOLUTE_TIMEOUT_SECONDS— sessions ADR-0010.MFA_FRESHNESS_SECONDS— MFA ADR-0011.OBO_CACHE_ENCRYPTION_KEY,BFF_JWKS_PRIVATE_KEY_PATH,BFF_JWKS_KID,<SERVICE>_API_BASE_URL— downstream API ADR-0014.
Pros and Cons of the Options
SPA configuration mechanism
Angular environment.ts + fileReplacements (chosen)
- Good, because idiomatic Angular — every Angular contributor recognises it.
- Good, because zero runtime cost, zero extra deploy step.
- Good, because the values are visible in source (one file per environment, side by side).
- Bad, because per-environment artefact (rebuild on promotion). Acceptable given build is fast.
Runtime fetch from /config.json
- Good, because single artefact across environments (build once, deploy everywhere).
- Bad, because adds a startup-blocking HTTP fetch (RUM cost on
LCP/TTFBbudgets per ADR-0017). - Bad, because a runtime-config error means the SPA cannot start — discoverable only at first user load.
<meta name="config" content="…"> injected at deploy
- Good, because also single artefact.
- Bad, because the deploy platform must rewrite
index.htmlas part of release. Extra moving piece, easy to forget on a new platform. - Bad, because mixing config and HTML hurts cacheability of
index.html.
esbuild define substitution
- Good, because also build-time, also fast.
- Bad, because circumvents Angular's expected pattern; future contributors look for
environment.tsfirst. Avoid the surprise.
BFF audit-log connection
Separate AUDIT_DATABASE_URL pool (chosen for prod, opt-in)
- Good, because hard runtime isolation — the audit-writing connection cannot perform anything else.
- Good, because a SQL injection on a non-audit code path cannot reach the audit log.
- Bad, because second connection pool to operate (sized, monitored, drained on shutdown). Bounded operational cost.
Single DATABASE_URL + SET LOCAL ROLE (current v1)
- Good, because one pool, one set of credentials. Simpler dev story.
- Bad, because the BFF connection retains all its other privileges; a runtime exploit could
RESET ROLEand bypass the contract. Acceptable for v1, mitigated by Postgres role grants ensuringUPDATE/TRUNCATEare denied even to the underlying superuser at runtime — but the BFF login user could still read business data the audit-only connection wouldn't.
Single connection that runs as audit_writer permanently
- Bad, because the BFF's other features need many more privileges than
audit_writer. Doesn't fit.
BFF env-var loading
process.env + boot-time validator (chosen)
- Good, because zero dep, fast, clear failure.
- Bad, because no schema declaration in one place — validation is per-file.
@nestjs/config
- Good, because Nest-idiomatic, hierarchical lookup.
- Bad, because heavy for our key count, adds DI ceremony, hides the simplicity of "just read process.env".
zod schema
- Good, because typed validation, clear schema declaration.
- Bad, because runtime dep for an at-boot check that does not need to be re-run.
More Information
- Angular
fileReplacements: https://angular.dev/tools/cli/build#configuration-environment-specific - Twelve-factor config: https://12factor.net/config
- Related ADRs: ADR-0006 (Postgres + Prisma), ADR-0010 (sessions), ADR-0012 (OTLP endpoint env var), ADR-0013 (audit pool, this ADR formalises its split), ADR-0014 (per-downstream env vars).