Skip to main content
Version: v0.25.0 (Latest)

Deployment Topology

The five EDK images are not microservices in the "one container per business capability" sense. They are five containers because the network surface, the access pattern, and the operational lifecycle of each one is genuinely different. The split shows up most clearly in the ingress layout, the database wiring, and the service-to-service auth between them.

Public Versus Internal Surface

Each container has both a public and an internal surface, but the proportions are very different.

The KMS exposes no public surface at all. The provider admin REST and the signing REST are both internal-only. Issuer, verifier, AS, and DID call into it across the cluster network. Operators reach the admin REST through whatever in-cluster route the deployment provides (port-forward, internal LB, a bastion). It is a deliberate constraint: nothing about KMS belongs on the public internet.

The DID container has a small public surface (the Universal Resolver path) and a larger internal surface (did:web publishing, did:webvh log management, the manager admin REST, method enable/disable per tenant). Public DID resolution is its primary job, so the deployment template binds the resolver path to the public ingress and keeps everything else internal.

The AS, Issuer, and Verifier each have a wallet-facing public protocol surface and a tenant-administrator internal admin surface. The protocol surface is what wallets and external clients hit (/oid4vci/..., /oid4vp/..., /authorize, /token, /.well-known/...). The admin surface lives under /api/v1/... and is meant for tenant operators and the platform admin only. The split is enforced at the ingress: the public ingress allows only the protocol paths and .well-known URLs through; the internal ingress carries /api/v1/... and requires a bearer JWT with the right scopes.

Service-to-Service Auth

Once a request lands on the issuer, the verifier, or the AS, those containers may call the KMS for signing, the DID resolver for verifying counterparty signatures, or each other for cross-protocol operations (the issuer calling the AS to validate a pre-authorized code, for example). Those calls cross trust boundaries inside the cluster, so they are not unauthenticated.

The deployment template wires service-to-service auth in one of two ways:

  • Service JWT. Each data-plane container holds a service identity backed by a signing key on the KMS. Outbound peer calls carry a short-lived JWT with the service principal's claims, and the receiving container validates it against the KMS-published JWKS. This is the default for the published Helm chart.
  • mTLS. When the cluster already runs a mesh (Istio, Linkerd, Cilium) or has its own internal CA, the cluster's mTLS suffices and the service JWT becomes optional. The Helm chart has a switch for this.

Either way, the principle is the same: the KMS does not trust an unauthenticated caller, and the issuer/verifier/AS do not trust each other on the strength of being on the same cluster network.

Shared Postgres

All five containers share a PostgreSQL instance by default. Each module within each container owns its own SQLDelight schema and migrations. The tenant_id column is on every business table and every repository query filters on it. That gives row-level multi-tenancy within a single shared schema, which is the operating mode the published Helm chart enables by default.

When a customer requires database-level isolation (regulatory or contractual), the EDK supports per-tenant database routing through the lib-data-store-db-routing-config and lib-data-store-db-routing-pooling modules. The routing config maps tenant slugs to JDBC URLs and credentials. Connection pooling is per-target via HikariCP. The same container code reads from whichever target the resolved tenant points at, with no schema differences.

The shared Postgres also carries the per-tenant configuration that the runtime reads through the TenantConfigPropertySource. Tenant admins write tenant_config_property rows through the admin REST; the data planes pick them up on the next resolver cache miss and through Postgres LISTEN/NOTIFY invalidation.

Cross-Replica Cache Invalidation

Each data-plane container caches the tenant routing table, the per-tenant config, and the public-endpoint bindings in process. With multiple replicas behind a load balancer, a mutation on replica A must be visible on replica B without a restart, or behaviour diverges between replicas.

The EDK solves this through the shared event subsystem: admin commands emit domain events on mutation, a Postgres LISTEN/NOTIFY bridge fans them out to subscribers in every replica, and the local caches invalidate. A TTL fallback covers the case where a notification is missed. The mechanism is the same for tenant routing, public-endpoint bindings, and tenant_config_property updates.

Tenant-Aware Public Endpoint Bindings

A tenant typically owns at least one host that the wallet reaches it at, a subdomain like acme.issuer.example.com, or a verified custom domain like wallet.acme.com. The tenant_public_endpoint row binds a tenant + service type (OID4VCI issuer, OID4VP verifier, OAuth AS, DID resolver) to the host (.well-known path layout, pathPrefix) under which that service is reachable for that tenant.

The metadata that the data planes advertise (the credential_issuer value in OID4VCI metadata, the issuer in OAuth AS metadata, the request_uri_base in an OID4VP authorization request, the status_uri in a credential offer) comes from the tenant public-endpoint binding rather than from the bare request host. This matters in production: when the tenant is reached via a CDN, a reverse proxy, or a custom domain that does not match the cluster's hostname, the metadata still advertises URLs the wallet can actually reach.

The Helm chart's default behaviour is fail-closed: if no tenant_public_endpoint binding exists for the resolved tenant and service type, the data plane refuses to advertise anything rather than falling back to the request host. The fallback is configurable per environment.

Putting It Together

EDK container deployment layout

A canonical small-to-medium deployment runs:

  • One replica of each data-plane container (KMS, DID, AS, Issuer, Verifier) sized to the workload, on Kubernetes or on a single Docker host via the published Compose file.
  • A shared PostgreSQL instance, ideally with a streaming replica for read scale-out (the EDK does not route reads to replicas yet, this is a Postgres-side optimisation only).
  • A public ingress controller with TLS termination, routing *.issuer.example.com to the issuer service, *.verifier.example.com to the verifier, *.as.example.com to the AS, and *.did.example.com (resolver path only) to the DID container. Wildcard certs on the platform base host plus per-tenant cert issuance for verified custom domains.
  • An internal ingress (or a service-mesh policy) that only the in-cluster admin paths reach, enforcing bearer-JWT auth at the gateway or in-process. The KMS admin REST sits behind this same internal ingress.
  • A monitoring stack subscribed to /metrics on each container and to the OpenTelemetry collector wired through the EDK telemetry module.

For a high-traffic deployment, the issuer, verifier, AS, and DID containers scale horizontally. The KMS scales as well, but more conservatively, most deployments find the bottleneck is the provider backend (AWS KMS rate limits, HSM throughput) rather than the container itself.