Configuration & Secrets
The EDK enterprise containers share a configuration model with mounted YAML, environment variables, platform configuration, and per-tenant configuration stored in the tenant workload database. Secrets are kept out of those layers through secret-reference interpolation, with the actual values resolved at runtime from the configured secret backend.
The Layered Configuration Model
Configuration in an EDK container is the result of a chain of PropertySource instances composed by the IDK config system and enriched by the EDK overlay:
- Shipped
application.yaml. Lives at/app/config/application.yamlinside the image. Carries safe defaults for local testing. - Mounted YAML overlays. A deployment mounts additional YAML files (or replaces the shipped one) at known paths under
/app/config/. Files are merged in lexical order. - Environment variables. Standard EDK env-var mapping translates
OAUTH2__SERVERS__ACME__ISSUER_URIinto theoauth2.servers.acme.issuer_uriproperty. - Cloud config providers. Optional Azure App Configuration or REST Config providers are wired through
lib-conf-azure-appconfigorlib-conf-rest-config. Both layer in as standard property sources. - Platform bootstrap and config services. Runtime services first resolve bootstrap metadata from the platform service, then resolve platform-owned configuration slices through the same platform route. In the enterprise deployment these peer calls are internal and normally use gRPC to
enterprise-platform:9090. - Per-tenant workload-database config. The
TenantConfigPropertySourcereadstenant_config_propertyrows from the schema for the currently resolved tenant in the tenant workload database. This is the layer that tenant administrators write through platform administration and configuration APIs.
Resolution within a request is App -> Tenant -> Principal (the three ConfigService scopes). A property that exists at both the app and tenant level resolves to the tenant value within a request bound to that tenant, and to the app value otherwise.
The Shipped application.yaml
The application.yaml shipped in each container is deliberately minimal and permissive, for local testing:
server:
rest:
port: 8080
auth:
enabled: false
anonymous:
allowed: true
It ships permissive rather than locked-down so a first docker run reaches the health endpoint without configuring JWT issuance up front. A production deployment overrides server.rest.auth to enabled: true and configures the JWT issuer the admin REST validates bearer tokens against.
Layered onto this, a typical production overlay sets:
- The PostgreSQL JDBC URLs and credentials for the platform database and the tenant workload database (referenced through secret interpolation).
- The KMS endpoint and the service-JWT key alias the container uses to authenticate to it.
- The tenant resolution settings: the platform base host, the trusted reverse-proxy hop count, the cache TTL.
- The container-specific routing: the AS issuer URL and JWKS for the issuer container, the trust source registry refresh schedule for the verifier container, the DID method allowlist for the DID container.
Environment Variables
Env-var mapping follows the EDK convention: double-underscore for property-path separators, single underscore inside a single path segment, uppercase. database.url becomes DATABASE__URL. oauth2.servers.acme.issuer_uri becomes OAUTH2__SERVERS__ACME__ISSUER_URI.
Two env vars are read directly by the container startup code and never via the property resolver, because they need to be available before the DI graph is composed:
APP_PROFILE: the profile name passed into the Metro app graph factory. Used by config sources that key on profile.PORT: the HTTP listen port. Defaults to 8080 if not set.
The Helm chart sets the standard image and platform values under global and injects per-service environment overrides through services.<role>.env:
global:
imageRegistry: nexus.sphereon.com/edk-docker
imageTag: "<enterprise-version>"
imagePullPolicy: IfNotPresent
imagePullSecrets: []
platformBaseDomain: example.com
services:
issuer:
env:
- name: APP_PROFILE
value: production
Use the exact global.imageTag supplied through your OEM, MSP, or EDK distribution channel. The tag is the product version for that delivery; the source revision is reported separately through image metadata and /version. Use global.imagePullSecrets for authenticated registry pulls. Do not put Docker credentials in services.<role>.env.
Secret References
Kubernetes deployment bootstrap Secrets
The Helm chart needs two deployment bootstrap values before application-level secret providers can initialize:
serviceIdentity:
internalClientExistingSecret: edk-runtime-secrets
internalClientSecretKey: internal-client-secret
keystore:
existingSecret: edk-runtime-secrets
passwordKey: keystore-password
edk-runtime-secrets is an example Kubernetes Secret name. Its
internal-client-secret value is the confidential-client credential used by
satellites to obtain platform-issued workload tokens. Its keystore-password
value protects the platform and tenant-KMS software PKCS#12 stores. Generate the
values independently, keep them out of Git and values files, and have the
cluster's secret-management mechanism create the Secret in the release
namespace. These values are injected with Kubernetes secretKeyRef; they are
not ${secret:...} property-source references.
Helm validates the configured names. Kubernetes validates the Secret object and
key names when a container starts. Missing objects and keys therefore surface as
CreateContainerConfigError pod events. Restart affected Deployments after
rotating data under an existing Secret name.
Application-level secret providers
Secrets in YAML and env vars are stored as references, never as plaintext, through the EDK secret-interpolation syntax:
database:
url: jdbc:postgresql://postgres:5432/edk
username: edk_app
password: ${secret:vault:edk/postgres/app-password}
${secret:vault:...} is resolved at startup by the configured SecretProvider SPI implementation. The EDK ships three production-grade implementations:
- HashiCorp Vault. Configured against any KV v2 secret engine. Authentication via AppRole, Kubernetes auth, or token. Module:
lib-conf-vault. - AWS Secrets Manager. Configured against the regional Secrets Manager endpoint. Authentication via the AWS credential chain (IAM role for service accounts on EKS, environment, profile). Module:
lib-conf-aws-secrets. - Azure Key Vault. Configured against a vault URL. Authentication via the Azure credential chain (managed identity, service principal, environment). Module:
lib-conf-azure-keyvault.
When the deployment standardises on Kubernetes secrets, secrets are mounted as files and referenced through the ${file:...} interpolation rather than ${secret:...}. The result is the same: no plaintext secret in the YAML, no plaintext secret in the env var, no plaintext secret in the image.
A deployment may have multiple secret providers wired simultaneously, distinguished by the prefix after secret: (secret:vault:, secret:aws:, secret:azure:). Per-tenant secret backend selection is supported through the TenantConfigSecretClassifier.
Per-Tenant Configuration in the Workload Database
Per-tenant configuration lives in tenant_config_property inside the resolved tenant's schema in the tenant workload database. Each row is (tenant_id, key, value, secret_reference, updated_at). The runtime reads these rows through TenantConfigPropertySource when a tenant is in scope on the call.
Tenant administrators write to this table indirectly, through the typed admin REST per configuration domain. The EDK does not expose a raw JSONB config endpoint; every config domain has a typed REST surface (the AS instance admin, the issuer config admin, the verifier config admin, the DID method admin, the trust source admin, the federation provider admin, the integration registry, the webhook admin). Behind those surfaces, the values land in tenant_config_property as typed properties.
Secret values written through the admin REST never land in tenant_config_property as plaintext. The TenantConfigSecretClassifier recognises secret-bearing properties and persists only a secret reference; the actual value lands in the configured secret backend under a key path that includes the tenant id.
Cross-replica invalidation when a tenant administrator updates configuration goes through the shared event subsystem: the admin command emits an application.tenant.config-updated event, a Postgres LISTEN/NOTIFY bridge on the tenant workload database fans out, and each replica's TenantConfigPropertySource cache invalidates for the affected tenant. A TTL fallback covers missed notifications.
Tenant Resolution Settings
The tenant resolution stack reads its settings from the top of the property resolver chain (the app-scope, not per-tenant, because tenant resolution runs before any tenant is in scope). The relevant properties:
tenant.resolution.platform_base_host: the host suffix that subdomain resolution treats as the platform base. Subdomains of this resolve to tenant slugs.tenant.resolution.trusted_proxy_hop_count: how manyX-Forwarded-Hosthops the resolver trusts. Important behind reverse proxies and CDNs.tenant.resolution.cache_ttl_seconds: fallback TTL on the in-memory tenant routing cache. Cross-replica invalidation handles the typical case; the TTL covers missed notifications.tenant.resolution.well_known_path_modes: which.well-knownURL forms each protocol supports (spec form only, spec + legacy, or custom). The defaults match the discovery URL forms described in the topology page.
The TenantResolutionSettingsBinder reads these properties and feeds them into the Ktor TenantResolutionPlugin at server startup.
Database Routing
The enterprise deployment has two database roles. The platform service owns the platform database. Tenant-KMS, DID, tenant-AS, wallet-unit, wallet-interaction, issuer, and verifier use the tenant workload database, with schema-per-tenant isolation:
database:
platform:
url: jdbc:postgresql://platform-postgres:5432/edk_platform
username: edk_platform
password: ${secret:vault:edk/postgres/platform/password}
tenant:
url: jdbc:postgresql://tenant-postgres:5432/edk_tenant
username: edk_tenant
password: ${secret:vault:edk/postgres/tenant/password}
isolation: schema
schemaPattern: tenant_{id}
At tenant registration time, the platform provisions the workload schema for the tenant. A request bound to the acme tenant routes repository calls to the tenant workload database with the search path set to that tenant's schema. Replicas of a runtime service share the same workload database and route by the resolved tenant.
When a tenant needs stronger isolation than schema-per-tenant, the lib-data-store-db-routing-config and lib-data-store-db-routing-pooling modules can route that tenant to a dedicated database or PostgreSQL instance. Connection pooling is per-target via HikariCP, and routing changes take effect on the next resolver cache miss and cross-replica invalidation without restarting the container.
Service-to-Service Auth Configuration
Internal service calls use short-lived workload JWTs issued by the platform authorization server. Each satellite authenticates as a registered confidential client using the shared runtime Secret, then sends the resulting bearer to the intended receiver audience. The service client id, asserted service id, and receiver audience are one contract:
serviceIdentity:
internalClientExistingSecret: edk-runtime-secrets
clientIds:
tenant-as: tenant-as-service
tenant-kms: kms-service
issuer: issuer-service
serviceIds:
tenant-as: service-tenant-as
tenant-kms: service-crypto
issuer: service-oid4vci
audiences:
platform: enterprise-platform
tenant-kms: enterprise-tenant-kms
issuer: enterprise-issuer
Receivers validate the platform-issued JWT, workload-token class, client-to-service
binding, and audience before trusted tenant or principal headers can affect
request context. grpc.authMode=service-jwt uses this in-process contract.
grpc.authMode=mesh-mtls delegates peer authentication to the service mesh and
requires the matching mesh policy on both caller and receiver.
The audience controls have separate jobs. A receiver expected audience is
serviceIdentity.audiences.<receiver>. A caller's route-requested audience is
its effective serviceTokenAudience. The platform AS internal-client key
default-access-token-audience supplies a target only when the
client_credentials request omits one; allowed-access-token-audiences
contains the permitted explicit non-default targets. The caller's client id is
also bound to its asserted service id.
The chart derives this registration matrix from the serviceIdentity maps:
| Caller | Client/service binding | Default | Allowed additional audiences |
|---|---|---|---|
| tenant-KMS | clientIds.tenant-kms / serviceIds.tenant-kms | audiences.platform | none |
| wallet-unit | clientIds.wallet-unit / serviceIds.wallet-unit | audiences.platform | none |
| tenant-AS | clientIds.tenant-as / serviceIds.tenant-as | audiences.platform | audiences.tenant-kms |
| DID | clientIds.did / serviceIds.did | audiences.platform | audiences.tenant-kms |
| issuer | clientIds.issuer / serviceIds.issuer | audiences.platform | audiences.tenant-kms, audiences.wallet-interaction |
| verifier | clientIds.verifier / serviceIds.verifier | audiences.platform | audiences.tenant-kms, audiences.wallet-interaction |
| wallet-interaction | clientIds.wallet-interaction / serviceIds.wallet-interaction | audiences.platform | audiences.wallet-unit |
An audience-free client-credentials request requires a nonblank default. One
explicit audience is allowed only when it is the default or an allowed
additional target. Missing defaults, multiple or duplicate targets, and
unregistered targets return HTTP 400 invalid_target. An explicit route
serviceTokenAudience or preferServiceTokenOverSessionBearer=true is
fail-closed: a missing service token cannot fall back to the session bearer,
delegation, or anonymous access. A partial server.service-identity is also a
configuration error.
Peer Transport Configuration
Peer transport is separate from peer authentication. The enterprise images include generated gRPC client stubs and routed remote command dependencies. Platform, tenant-KMS, wallet-unit, and wallet-interaction run the inbound gRPC receivers; the other runtime services use outbound routes to them:
grpc:
enabled: true
port: 9090
authMode: service-jwt
Keep gRPC east-west only. Runtime services route platform configuration commands to the platform service, KMS commands to tenant-KMS, and wallet commands to wallet-interaction or wallet-unit. The public gateway routes only HTTPS REST/protocol traffic. Platform, tenant-KMS, wallet-unit, and wallet-interaction receivers validate the workload JWT audience before any trusted internal tenant or principal header can affect request context.
Configuration Hot-Reload
The boundary for hot-reload is the property source. Property sources that support change notifications (TenantConfigPropertySource, the Azure App Configuration provider, the REST config provider) propagate changes into the running container without a restart. The shipped application.yaml, mounted YAML files, and environment variables are read at startup only.
Tenant administrators changing per-tenant config through the admin REST is the most common hot-reload path. App-level config changes typically require a rolling restart of the affected container.
Bootstrap Runtime Config
The platform exposes a narrow bootstrap projection for values needed before a service or browser app can call the richer APIs:
- satellites use the internal
platform.bootstrap.getcommand, or the protectedGET /api/platform/bootstrap/v1/consumer-config/{consumerId}REST endpoint, for platform metadata, service endpoint objects, service identity hints, audiences, config domains, license role/capabilities, telemetry hints, revision, TTL, and diagnostics. This internal projection may include Kubernetes or Docker service DNS names, ports, namespaces, and gRPC URLs; - browser apps call
GET /api/platform/bootstrap/v1/runtime-config/{applicationId}for an allowlisted public projection. The REST response is shaped as{ metadata, data }; browser service wiring lives underdata.services, where each service owns itsbaseUrl, optionalaudience, and namedendpoints. The current application ids areadmin-console,platform-onboarding, andlicense-portal.
Browser runtime config derives the platform public base URL from the incoming request origin and forwarded headers unless an explicit platform external base URL is configured. Platform APIs remain on the platform origin, for example https://platform.<base-domain>/api/platform/admin/v1. Tenant APIs are not platform APIs: tenant-KMS and DID browser URLs resolve to the tenant origin, for example https://<tenant>.<base-domain>/api/kms/v1 and https://<tenant>.<base-domain>/api/did/v1, or are omitted until a tenant selector/public base is known. An admin-console /admin-console/api/* proxy is only an explicit frontend BFF deployment mode, not the canonical service topology.
In BFF mode the admin console may fetch runtime config through the internal platform URL, but it must forward the browser-visible public origin with X-Forwarded-Host and X-Forwarded-Proto. The BFF also uses server-only internal upstreams for platform-admin, platform-config, token, setup-status, tenant-KMS, DID, issuer-owned, and verifier-owned API calls. For tenant-owned internal calls, the BFF still preserves the public tenant Host and forwarded scheme so tenant resolution and generated links remain tenant-bound. Compose and Helm set these as ADMIN_CONSOLE_PLATFORM_BASE_URL, ADMIN_CONSOLE_TENANT_KMS_BASE_URL, ADMIN_CONSOLE_TENANT_DID_BASE_URL, ADMIN_CONSOLE_ISSUER_BASE_URL, and ADMIN_CONSOLE_VERIFIER_BASE_URL. The runtime-config baseUrl values still stay public (platform.<base-domain> and <tenant>.<base-domain>); Docker or Kubernetes service DNS names belong only in server-side BFF/consumer-config/bootstrap projections.
This projection is intentionally not a replacement for platform config or the domain APIs. Service definitions and service runtime settings remain in Platform Config (/api/platform/config/v1). Credential designs, issuer and verifier designs, render variants, status-list policy artifacts, DCQL query bodies, and verifier presentation definitions remain in their dedicated credential-design, issuer, verifier, and DCQL APIs. Platform config may reference those resources by id/version, but bootstrap never carries their bodies.
The public bootstrap path is anonymous because it is needed before browser sign-in, but its payload is browser-safe only: no secrets, database coordinates, secret-backend paths, KMS credentials, internal-only URLs, or unrestricted config keys. Everything beyond that projection requires a bearer or an internal workload token.
Protocol metadata is the exception to request-origin derivation. OAuth/OIDC, OID4VCI, OID4VP, DID, and other .well-known documents must continue to advertise the canonical public endpoint binding for the resolved tenant/service rather than blindly echoing the request host.