Skip to content

Environment Variables


Backend โ€” packages/backend/.env

Server

Variable Required Default Description
NODE_ENV Yes โ€” development, acceptance, or production
PORT Yes 3002 HTTP listen port (Azure uses 8080)
HOST No 0.0.0.0 Bind address

CORS

Variable Required Description
CORS_ORIGIN Yes Comma-separated allowed origins (e.g. https://mijn.open-regels.nl), matched by exact equality
CORS_PREVIEW_SLUGS No Comma-separated Static Web Apps app slugs whose numbered preview environments may call this backend. Empty by default, and ignored outright in production

CORS_PREVIEW_SLUGS holds slugs, not hostnames

A pull-request preview gets an ephemeral origin, and there is a new one per pull request, so it cannot be listed in CORS_ORIGIN. Azure derives a preview hostname from the app's stable slug, so the pattern anchors on the slug โ€” a bare *.azurestaticapps.net pattern would let any Azure Static Web App in the world make credentialed cross-origin requests to the tier.

Two properties are enforced in code rather than left to configuration. It is never honoured in production, so a value that finds its way onto the production App Service changes nothing about what production accepts. And the guard reads the deployment environment, not NODE_ENV: acceptance deliberately runs NODE_ENV=production, so keying on that would have treated it as production and refused every preview.

Keycloak / JWT

Variable Required Description
KEYCLOAK_URL Yes Keycloak base URL (e.g. https://keycloak.open-regels.nl)
KEYCLOAK_REALM Yes Realm name โ€” always ronl
KEYCLOAK_CLIENT_ID Yes Client ID โ€” always ronl-business-api
JWT_ISSUER Yes Full issuer URL: https://keycloak.open-regels.nl/realms/ronl
JWT_AUDIENCE Yes Must match token aud claim โ€” always ronl-business-api
TOKEN_CACHE_TTL No 300

There is no KEYCLOAK_CLIENT_SECRET

ronl-business-api is a public client: the realm export gives it publicClient: true, no secret and no service account. The setting existed on Config until v2026.09.10, was populated from the environment, was required in production โ€” and was read by no code path. Requiring a value nobody reads is how the production App Service came to hold the literal not-used. It was removed rather than corrected.

An unfilled value fails the boot in production

A non-empty check passes a placeholder, so the backend boots, /v1/health reports healthy, and the breakage surfaces at first use. Since v2026.09.10 a boot-time check rejects an unfilled value in production as well as an empty one, and ANTHROPIC_API_KEY uses it โ€” that key is genuinely consumed.

Matching is anchored, never by substring: exchange-mechanism-2026 contains change-me, and failing a boot over a legitimate secret would be a worse failure than the one this prevents. Production only, because failing a developer's boot over an unfilled .env would be hostile.

Operaton

Variable Required Description
OPERATON_BASE_URL Yes https://operaton.open-regels.nl/engine-rest
OPERATON_TIMEOUT 30000 Operaton request timeout in ms

MCP AI Assistant

Variable Required Default Description
MCP_ENABLED No false Enables the MCP client and POST /v1/mcp/chat. Must be true on ACC/PROD.
MCP_SKIP_HEALTH_CHECK No false Skips provider health checks on startup. Useful when providers start slowly on first deployment.
ANTHROPIC_API_KEY Yes โ€” Anthropic API key. Required in every environment, whatever MCP_ENABLED says: the boot fails without it, and in production also when it is still a placeholder value.
OPENAI_API_KEY No โ€” Enables OpenAILlmProvider and exposes gpt-4o and gpt-4o-mini in the model selector. Leave unset to use Anthropic only. Requires the openai package: npm install openai --workspace=@ronl/backend.
TRIPLYDB_MCP_ENABLED No false Enables the TriplyDB Knowledge Graph MCP provider.
TRIPLYDB_ENDPOINT Conditional โ€” SPARQL endpoint URL. Required when TRIPLYDB_MCP_ENABLED=true. Use https://api.open-regels.triply.cc/datasets/stevengort/RONL/services/RONL/sparql for the canonical RONL graph.
TRIPLYDB_TOKEN No โ€” TriplyDB API token. May be empty for public datasets.
CPRMV_MCP_ENABLED No false Enables the CPRMV legislation provider (Dutch and EU law via HTTP MCP).
CPRMV_URL No https://acc.cprmv.open-regels.nl/mcp CPRMV MCP server URL. Override for PROD deployment.
LDE_MCP_ENABLED No false Enables the LDE Process Library provider. Exposes deployed BPMN bundles, form schemas, and document templates to the AI Assistant.
LDE_DATABASE_URL Conditional โ€” PostgreSQL connection string for the lde_assets database. Required when LDE_MCP_ENABLED=true. On Azure: use a separate Flexible Server and append ?sslmode=require. Locally: reuse the existing ronl-postgres container.

OPERATON_USERNAME and OPERATON_PASSWORD are also passed to the operaton-mcp child process. Ensure they are set before enabling MCP.

Operaton โ€” M2M

Variable Required Default Description
OPERATON_M2M_BASE_URL No โ€” Override: a different Operaton engine for the M2M routes. Unset โ€” as on every tier โ€” /v1/m2m uses the main engine, OPERATON_BASE_URL
OPERATON_M2M_USERNAME No โ€” Basic auth username for the override engine; when unset, OPERATON_USERNAME is used, so set it together with the URL
OPERATON_M2M_PASSWORD No โ€” Basic auth password for the override engine; when unset, OPERATON_PASSWORD is used
M2M_ALLOWED_CLIENTS No operaton-mcp-client Comma-separated Keycloak client ids allowed to call /v1/m2m, matched against the token's azp. A token from any other client โ€” every person's token included โ€” is refused with 403 M2M_CLIENT_NOT_ALLOWED. Adding a consumer means adding its client id here; nothing changes in Keycloak

eDOCS

With EDOCS_STUB_MODE=false the backend refuses to start unless EDOCS_USER_ID, EDOCS_PASSWORD, ENTRA_TENANT_ID, ENTRA_CLIENT_ID and ENTRA_CLIENT_SECRET are set. A person works in eDOCS as themselves, with the Entra ID token Keycloak stored at their login; the service account serves machine clients and background archiving โ€” see eDOCS โ€” Live Testing.

Variable Required Default Description
EDOCS_STUB_MODE No true When true, all eDOCS service methods return realistic fake responses. Set to false to enable live calls. Never commit real credentials to the repository โ€” use Azure App Service Application settings.
EDOCS_BASE_URL Yes (live mode) โ€” eDOCS REST API root, e.g. https://<host>:<port>/edocsapi/v1.0. The client appends connect, workspaces, documents and libraries, so a trailing /connect makes every call fail. Not checked at startup
EDOCS_LIBRARY No DOCUVITT eDOCS library name
EDOCS_USER_ID Yes (live mode) โ€” The service account's user ID (testuser001), used by machine clients and background archiving. The backend refuses to start without it in live mode
EDOCS_PASSWORD Yes (live mode) โ€” The service account's password. The backend refuses to start without it in live mode; every failed login counts towards an eDOCS lockout
EDOCS_DEPARTMENT No IVR The UV_AFD_NAAM profile field set on every uploaded document, which the DM server requires and validates against its own department list. A property of the eDOCS environment, not of a process; an invalid value makes every upload fail with 502
ENTRA_TENANT_ID Yes (live mode) โ€” Provincie Flevoland's Entra tenant, for refreshing a person's brokered Entra ID token
ENTRA_CLIENT_ID Yes (live mode) โ€” The app registration Keycloak brokers (IOU-demonstrator)
ENTRA_CLIENT_SECRET Yes (live mode) โ€” Its client secret โ€” the same value Keycloak's entra-flevoland provider holds
ENTRA_IDP_ALIAS No entra-flevoland The Keycloak identity provider whose stored tokens the backend reads
EDOCS_ALLOW_SERVICE_FALLBACK No false true lets a person without a stored Entra token (a Keycloak test account) fall back to the service account, visibly (actingAs: "service", with an audit entry). Refused at startup when DEPLOYMENT_ENV=production
EDOCS_ALLOWED_CLIENTS No edocs-mcp-client,copilot-studio-edocs,operaton-mcp-client Comma-separated Keycloak client ids (token azp) allowed on /v1/edocs; they act as the service account. Any other machine client is refused with 403 EDOCS_CLIENT_NOT_ALLOWED
EDOCS_MCP_ENABLED No false Enables the AI assistant's eDOCS tools (.env.example sets true)
EDOCS_MCP_CLIENT_ID No edocs-mcp-client The Keycloak client the eDOCS MCP subprocess authenticates as when a call carries no person
EDOCS_MCP_CLIENT_SECRET Conditional โ€” Its client secret; needed when EDOCS_MCP_ENABLED=true

GitLab integration

Variable Default Description
GITLAB_TOKEN โ€” Personal access token with api scope for the GitLab instance
GITLAB_BASE_URL https://git.open-regels.nl GitLab instance base URL
GITLAB_PROJECT_PATH โ€” URL-encoded project path (e.g. showcases%2Fiou-architectuur)
GITLAB_UC_LABEL uc::submitted Label applied to newly created use-case issues

Database (PostgreSQL)

Variable Required Description
DATABASE_URL Yes Full connection string with ?sslmode=require in production
DATABASE_POOL_MIN No 2
DATABASE_POOL_MAX No 10

Redis

Variable Required Description
REDIS_URL Yes Redis connection string
REDIS_TTL No 3600

Rate limiting

Variable Required Default Description
RATE_LIMIT_WINDOW_MS No 60000 Rate limit window in ms
RATE_LIMIT_MAX_REQUESTS No 1000 Max requests per window
RATE_LIMIT_PER_TENANT No true Asks for the limit to be keyed per tenant and IP. Has no effect: the limiter runs before any route authenticates the caller, so there is never a tenant to key on and every bucket is per client IP

The limit buckets per client, which is why acceptance could raise it

TRUST_PROXY is true on both deployed tiers, so the limiter buckets per client rather than once per deployment. Acceptance runs RATE_LIMIT_MAX_REQUESTS=1000, raised from 100 so a full end-to-end run from one machine stops throttling in the PA cockpit specs, which spend about twenty requests per authoring journey.

Production should not follow automatically. Per-client bucketing is what makes a raise a convenience decision on acceptance; a tenfold ceiling per client is a much weaker defence on a public tier.

Logging

Variable Required Default Description
LOG_LEVEL No info debug, info, warn, error
LOG_FORMAT No json json (production) or pretty (local)
LOG_FILE_ENABLED No true Write logs to rotating files
LOG_FILE_PATH No ./logs Log file directory
LOG_FILE_MAX_SIZE No 10m Max log file size before rotation
LOG_FILE_MAX_FILES No 7 Number of rotated log files to keep

Audit logging

Variable Required Default Description
AUDIT_LOG_ENABLED No true Enable audit log writes
AUDIT_LOG_INCLUDE_IP No true Include client IP in audit records
AUDIT_LOG_RETENTION_DAYS No 2555 Parsed into Config and read by no code: nothing purges audit records

Security

Variable Required Default Description
HELMET_ENABLED No true Enable Helmet security headers
SECURE_COOKIES No false Set Secure flag on cookies (enable in prod)
TRUST_PROXY No false Trust Azure/proxy X-Forwarded-* headers (enable in prod)

Features

Variable Required Default Description
ENABLE_TENANT_ISOLATION No true Switches the tenant middleware's presence check (403 MISSING_TENANT for a token without a tenant). It does not switch tenant access: the TENANT_MISMATCH checks on process instances and tasks always run
ENABLE_METRICS No true Parsed into Config and read by no code; no metrics endpoint is served
ENABLE_HEALTH_CHECKS No true Parsed into Config and read by no code; /v1/health is always served
DEFAULT_MAX_PROCESS_INSTANCES No 1000 Parsed into Config and read by no code; no per-tenant instance limit is applied
RONL_SPARQL_ENDPOINT No https://api.triplydb.com/... Override the default RONL TriplyDB SPARQL endpoint used by the Regelcatalogus service

Media aggregator

The backend both serves a media aggregator at /v1/media-aggregator and, for the policy-analysis cockpit, consumes one as a media source. The two halves are configured separately.

Variable Required Default Description
MEDIA_SOURCE_ENABLED No false Adds media as a policy-analysis source: curation and searches query the aggregator at MEDIA_AGGREGATOR_BASE
MEDIA_AGGREGATOR_BASE When MEDIA_SOURCE_ENABLED=true โ€” Base URL of the aggregator the policy-analysis media source calls (<base>/search)
MEDIA_AGGREGATOR_API_KEY When the aggregator requires a key โ€” Sent as Authorization: Bearer by the policy-analysis media source
MEDIA_AGGREGATOR_ACCEPT_KEY No โ€” Guards GET /v1/media-aggregator/search: when set, a caller must send it as a bearer token, and anything else gets 401; when unset, the endpoint is open. This is the mediaAggregatorKey security scheme in the OpenAPI description. Set it to the same value as MEDIA_AGGREGATOR_API_KEY when the backend consumes its own aggregator
MEDIA_AGGREGATOR_CACHE_TTL_MS No 900000 How long the aggregator's in-memory article set is served before a refresh (15 minutes). A missing or non-positive value uses the default
MEDIA_AGGREGATOR_SENTIMENT_ENABLED No โ€” Has no effect: sentiment analysis is not implemented, and every article's sentiment is null whatever this says

Public surface

Variable Required Default Description
PUBLIC_PROCESS_BOARDS No caseworker Comma-separated list of boardOwner values whose process bundles are exposed on the public process library and its search. Bundles carrying no owner at all are always public. Widen it (e.g. caseworker,infra-board) to publish another board's bundles. A bundle's status label plays no part in this โ€” see Procesbibliotheek

Frontend โ€” packages/frontend/.env.<mode>

The frontend's variables are not secrets: they are committed per build mode in packages/frontend/.env.development, .env.acceptance, .env.production and .env.test, and Vite reads the file for the mode it builds with. The ACC deploy and its pull-request previews both build with --mode acceptance, so a preview carries the acceptance values.

Variable Required Description
VITE_API_URL Yes Business API base URL (e.g. https://api.open-regels.nl/v1)
VITE_KEYCLOAK_URL Yes Keycloak base URL (e.g. https://keycloak.open-regels.nl)
VITE_LDE_API_URL Yes LDE public API base URL. Used by ProcesBibliotheek to fetch deployed BPMN bundles. ACC: https://acc.backend.linkeddata.open-regels.nl/v1. PROD: https://backend.linkeddata.open-regels.nl/v1.
VITE_SITE_URL Yes The site's own origin. Fills the canonical link, og:url and the absolute og:image URL in index.html
VITE_OG_IMAGE Yes Link-preview image file under public/, appended to VITE_SITE_URL
VITE_OG_TITLE_PREFIX No Prefix for og:title, telling a non-production preview apart. Quoted in the file, or the trailing space is lost
VITE_ROBOTS Yes Content of the robots meta tag

The link-preview values per mode (.env.test carries the development values):

Variable Development Acceptance Production
VITE_SITE_URL http://localhost:5173 https://acc.mijn.open-regels.nl https://mijn.open-regels.nl
VITE_OG_IMAGE og-image-acc.png og-image-acc.png og-image-prod.png
VITE_OG_TITLE_PREFIX "[DEV] " "[ACC] " ""
VITE_ROBOTS noindex, nofollow noindex, nofollow index, follow

Public site โ€” packages/public-site/.env.<mode>

Committed per build mode in the same way, and here too the ACC deploy and its pull-request previews build with --mode acceptance. scripts/prerender.ts reads VITE_SITE_URL, VITE_API_URL and VITE_ROBOTS from the same file, and fails the build when one is missing. There is no VITE_OG_IMAGE: every environment uses the one og-open-regels.png.

Variable Development Acceptance Production Description
VITE_API_URL http://localhost:3002/v1 https://acc.api.open-regels.nl/v1 https://api.open-regels.nl/v1 Business API base URL for the public endpoints
VITE_STAFF_APP_URL http://localhost:5173 https://acc.mijn.open-regels.nl https://mijn.open-regels.nl Target of the sign-in link to the working environment
VITE_SITE_URL http://localhost:5175 https://acc.publiek.open-regels.nl https://publiek.open-regels.nl The site's own origin: canonical links, og:url, og:image, the sitemap and the footer
VITE_OG_TITLE_PREFIX "[DEV] " "[ACC] " "" Prefix for og:title; quoted to keep the trailing space
VITE_ROBOTS noindex, nofollow noindex, nofollow index, follow The robots meta tag; a noindex value also makes the prerender write a robots.txt that disallows everything and names no sitemap

DNS records

These CNAME records must exist in the open-regels.nl DNS zone before deploying:

# ACC
acc.api       CNAME   ronl-business-api-acc.azurewebsites.net
acc.mijn      CNAME   <acc-static-web-app>.azurestaticapps.net

# PROD
api           CNAME   ronl-business-api-prod.azurewebsites.net
mijn          CNAME   <prod-static-web-app>.azurestaticapps.net

VM subdomains use A records pointing to the VM's public IP:

acc.keycloak  A   <VM_IP>
keycloak      A   <VM_IP>
operaton      A   <VM_IP>

GitHub repository secrets and variables

The static sites deploy with a token, which is a secret:

Secret name Where to get it
AZURE_STATIC_WEB_APPS_API_TOKEN_ACC Azure Portal โ†’ Static Web App ACC โ†’ Manage deployment token
AZURE_STATIC_WEB_APPS_API_TOKEN_PROD Azure Portal โ†’ Static Web App PROD โ†’ Manage deployment token
AZURE_STATIC_WEB_APPS_API_TOKEN_PA_DEMO_ACC / _PROD Azure Portal โ†’ the PA-demo Static Web App โ†’ Manage deployment token
AZURE_STATIC_WEB_APPS_API_TOKEN_PUBLIC_SITE_ACC / _PROD Azure Portal โ†’ the public-site Static Web App โ†’ Manage deployment token

The backend deploys over OIDC and therefore needs no secret at all. What it reads are repository variables:

Variable name Value
AZURE_CLIENT_ID_ACC / AZURE_CLIENT_ID_PROD The app registration with a federated credential for that App Service โ€” one per tier, each scoped to its own
AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID The tenant and subscription the App Services live in

AZURE_WEBAPP_PUBLISH_PROFILE_ACC / _PROD are dead

Those secrets date from March 2026 and nothing reads them. SCM basic auth is disabled on both App Services, so a publish-profile deploy would be rejected โ€” which is why the backend authenticates with OIDC instead.

Store a token with scripts/set-secret.sh, not by piping

Piping a token straight out of the Azure CLI into gh secret set stores a trailing newline โ€” 120 bytes where the key is 119. Both halves are the documented way to do their job; the composition is what goes wrong, and it cost the public site its first production deploy. The failure named nothing: every step passed, then "An unknown exception has occurred".

A secret's value cannot be read back, so nothing can confirm or deny a stray newline afterwards. The script strips whitespace, refuses an empty result, and reports the byte count it stored.


Generating environment passwords

Save and run this script locally to generate all secrets for an environment:

#!/bin/bash
set -e

ENV=${1:-acc}   # usage: ./setup-env.sh acc  OR  ./setup-env.sh prod

POSTGRES_PASSWORD=$(openssl rand -base64 32)
KEYCLOAK_PASSWORD=$(openssl rand -base64 32)

mkdir -p ~/.ronl-secrets

cat > ~/.ronl-secrets/${ENV}-passwords.txt << EOF
# RONL ${ENV^^} Environment โ€” Generated: $(date)

PostgreSQL:
  Username: pgadmin
  Password: ${POSTGRES_PASSWORD}

Keycloak Admin:
  Username: admin
  Password: ${KEYCLOAK_PASSWORD}

Connection strings:
  DATABASE_URL: postgresql://pgadmin:${POSTGRES_PASSWORD}@ronl-postgres-${ENV}.postgres.database.azure.com:5432/audit_logs?sslmode=require
  Keycloak VM .env: KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_PASSWORD}
EOF

chmod 600 ~/.ronl-secrets/${ENV}-passwords.txt
echo "Passwords saved to: ~/.ronl-secrets/${ENV}-passwords.txt"
echo "Back this file up securely before proceeding."