---
status: accepted
date: 2026-05-10
decision-makers: R&D Lead
tags: [frontend, backend, infrastructure, process]
---

# 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.example` catalogue (see `apps/portal-bff/.env.example`) and a startup validator for `DATABASE_URL` ([`check-database-url.ts`](../../apps/portal-bff/src/config/check-database-url.ts)).
- The **SPA** is a pre-built static bundle served to the browser. There is no `process.env` at 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:

1. **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.ts` and `apps/portal-shell/src/app/pages/home/home-status.service.ts`. That works for `localhost` development, breaks the moment we ship to staging or production.
2. **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`](../../apps/portal-bff/src/audit/audit.service.ts)) cuts a single connection from the shared `DATABASE_URL` pool and runs `SET LOCAL ROLE audit_writer` per transaction. ADR-0013 §"Wired as features land" already lists a separate `AUDIT_DATABASE_URL` pool 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`** — a `src/environments/environment.ts` file holds the dev defaults; per-environment files (`environment.staging.ts`, `environment.prod.ts`) are swapped in at build time via the project.json's `fileReplacements`. 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 in `index.html`** — the deploy step rewrites the `<meta>` content; the SPA reads `document.querySelector('meta[name=config]').content` at boot.
- **esbuild `define` substitution** — `process.env.X` references in source are textually replaced at build time; equivalent to `environment.ts` but less Angular-idiomatic.

### BFF audit-log connection

- **Single `DATABASE_URL` + `SET LOCAL ROLE audit_writer` per transaction** — current v1. One Prisma client, one pool. Role switch wraps each INSERT.
- **Separate `AUDIT_DATABASE_URL` pool with a login user that has `audit_writer` membership only** — second Prisma client, second pool. The connection cannot perform anything except `INSERT INTO audit.events`. **(Chosen for production hardening; v2 of the audit module.)**
- **Single connection that runs as `audit_writer` permanently** — everything goes through the audit-writer role. Wrong: the BFF needs many more privileges than `audit_writer` for the rest of its work.

### BFF env-var loading

- **`process.env` directly + 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, `.env` loading. Heavier surface for what amounts to ~10 keys today.
- **`zod` schema over `process.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.ts` is the **default** (dev), checked in. Holds two keys today: `bffApiBaseUrl` and `otlpEndpoint`. Both default to `http://localhost:3000/api` and `http://localhost:4318/v1/traces` respectively.
- `apps/portal-shell/src/environments/environment.staging.ts` and `apps/portal-shell/src/environments/environment.prod.ts` ship 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 `production` configuration in `project.json` adds a `fileReplacements` entry that swaps `environment.ts` for `environment.prod.ts`; a future `staging` configuration does the same with `environment.staging.ts`. Build script: `nx build portal-shell --configuration=production` continues 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 as `apps/portal-bff/src/config/check-database-url.ts`). `@nestjs/config` is 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_SALT` once auth is wired (ADR-0009 / ADR-0013), `SESSION_ENCRYPTION_KEY` and `SESSION_*_TIMEOUT_SECONDS` once sessions land (ADR-0010), `OBO_CACHE_ENCRYPTION_KEY` and `<SERVICE>_API_BASE_URL` once 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_URL` is the production split**: when set, it overrides the shared `DATABASE_URL` for 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 of `audit_writer` only — no superuser, no public-schema CRUD, just `INSERT INTO audit.events`. When **not** set (the dev default), the audit module falls back to the shared client + `SET LOCAL ROLE audit_writer` per-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_URL` is set: a deliberate `UPDATE audit.events SET …` is issued and is expected to fail with `permission denied`. If it succeeds, the BFF refuses to start — the audit log is no longer append-only.
- Renamed from the previous `.env.example` placeholder `AUDIT_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}.ts` for SPA, edit secret-manager / `.env` for BFF).
- Good, because the SPA stays a static bundle — no runtime config-fetch latency, no extra deploy step beyond what `nx build` already 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 (set `AUDIT_DATABASE_URL`) and a small code change in `audit.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.ts` files 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 `fileReplacements` is a build-time mechanism that hides the substitution from the source — the contributor reading `environment.ts` does not see the prod values without opening `environment.prod.ts` and the project.json target. Mitigated by keeping all three files side by side in `src/environments/`.
- Neutral, because BFF config still uses raw `process.env` — no fancy schema validation. Acceptable while the key set stays under ~30; revisit with `zod` if 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; `bffApiBaseUrl` and `otlpEndpoint` exposed.
- The hard-coded URLs in `observability/tracing.ts` and `pages/home/home-status.service.ts` consume the environment object instead.
- `apps/portal-shell/project.json` `production` configuration declares the `fileReplacements` swap; a `staging` configuration follows when the staging build target is set up.
- BFF gains a `apps/portal-bff/src/config/audit-database-url.ts` validator that reads `AUDIT_DATABASE_URL` (optional) and exposes a typed config; the `AuditModule` instantiates 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.example` documents `AUDIT_DATABASE_URL` per 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_SALT` mandatory 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`/`TTFB` budgets 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.html` as 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.ts` first. 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 ROLE` and bypass the contract. Acceptable for v1, mitigated by Postgres role grants ensuring `UPDATE` / `TRUNCATE` are 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](0006-persistence-postgresql-prisma.md) (Postgres + Prisma), [ADR-0010](0010-session-management-redis.md) (sessions), [ADR-0012](0012-observability-pino-opentelemetry.md) (OTLP endpoint env var), [ADR-0013](0013-audit-trail-separated-postgres-append-only.md) (audit pool, this ADR formalises its split), [ADR-0014](0014-downstream-api-access-obo-pattern.md) (per-downstream env vars).
