Skip to content

Deployment Context & Service Naming

Two conventions that span more than one Config Service feature, so they live here rather than inside Dynamic Configuration & Secrets: the deployment context (${context:...}, non-secret per-install values) and service naming (the identifier a service is known by across secret paths, the runtime proxy, and its own AppRole/ServiceAccount credential). Read Dynamic Configuration & Secrets first for how Config Service itself resolves and stores things — this page is about the identifiers and non-secret values that flow through it.

Context Definition

${secret:...} isn't the only thing a service's envdef.yaml can reference — ${context:root_external} pulls from a second, much simpler source: the deployment context, a small set of per-install inputs (a domain, a scheme, a namespace) and a larger set of values derived from them (URLs, realms, image tags). This is the layer that replaces the self-hosted platform's variables/post-variable.yaml, whose ~116 hand-maintained computed keys all reduce to a handful of rules over a much smaller set of inputs — an operator supplies a domain and a scheme, not two hundred URLs.

Shipped inside the image as contextdef.yaml (path overridable via AC_CONTEXT_DEFINITION_PATH), so the Platform team owns it and it stays versioned with the service, not hand-edited per install. Two sections, inputs and derived — every name below is a real name from the shipped file, and every one of them is a valid ${context:<name>} reference in any service's own envdef.yaml.

Inputs

The minimal surface an operator actually fills in, each backed by one env var. required (fails startup if missing and no default), default, enum (a closed set) and pattern are all validated at startup, with the input's own name and env var in the error — a bad install fails loudly, at the variable, not as an unexplained value three services later.

Name Env var Required / default Allowed values Meaning
external_root_host AC_EXTERNAL_ROOT_HOST required, no default pattern ^[a-z0-9.-]+$ Root domain the install is reached on, e.g. acme.appcircle.io. Not any one service's own address — every externally reachable service is a subdomain of this one; see root_external/*_external_url below for what a service actually gets.
external_scheme AC_EXTERNAL_SCHEME https http, https Scheme every external URL below is built with.
external_port AC_EXTERNAL_PORT (empty) any Set only when the install is not on the scheme's default port.
api_gateway_internal_url AC_API_GATEWAY_INTERNAL_BASE_URL http://ac-apigateway.appcircle.svc.cluster.local any URL How a service reaches the API Gateway inside this install. A Compose install sets this directly to a plain container name (e.g. http://ac-apigateway:8080).
config_internal_url AC_CONFIG_INTERNAL_BASE_URL http://ac-config.appcircle.svc.cluster.local any URL How a service reaches Config Service itself inside this install. Same shape as the row above.
keycloak_internal_url AC_KEYCLOAK_INTERNAL_URL http://keycloak.appcircle.svc.cluster.local any URL How a service reaches Keycloak inside this install, server-to-server (token validation, admin API, OIDC discovery) — as opposed to auth_external_url below, which is what a browser uses. Confirmed against Service Environment Map's real target name — note it has no _BASE_, unlike the API Gateway row above; see the note below the next table for why.
keycloak_dmz_custom_domain AC_KEYCLOAK_DMZ_CUSTOM_DOMAIN (empty) any hostname Second external domain Keycloak is also reachable on, self-hosted DMZ setups only (appcircle-self-hosted's keycloak.dmzCustomDomain). Empty — the common case — means no DMZ custom domain; see api_gateway_additional_issuer_urls below for what this feeds.
kafka_bootstrap_servers AC_KAFKA_BOOTSTRAP_SERVERS required, no default any Kafka bootstrap broker host:port list, comma-separated. One Kafka cluster per install, shared identically by every service — see the note below on why Redis is modeled differently.
git_providers AC_GIT_PROVIDERS github,gitlab,bitbucket,azure any (comma-separated) Which Git providers this install's CI features support.

Kafka is one shared value; Redis is one value per service

Kafka is genuinely install-wide: one cluster, and every service connects to the exact same AC_KAFKA_BOOTSTRAP_SERVERS, so declaring it once in context and having every service read ${context:kafka_bootstrap_servers} is strictly less repetition than each service configuring it separately, with the exact same value every time.

Redis is not that shape — there is no single redis_address correct for every service, since nothing stops different services from pointing at different Redis instances/clusters. But each individual service's own address is still a per-install fact worth declaring once, the same way each service already gets its own *_internal_url entry below. That's why Redis gets one input per service (see Per-Service Redis Addresses) rather than either a single shared value like Kafka, or no context value at all. Services that do share one physical Redis instance still each keep their own key prefix (AC_REDIS_PREFIX, unrelated to this) to avoid colliding in the same keyspace — that part stays each service's own directly-set variable, same as AC_ENVIRONMENT.

Downstream Service Internal URLs

One input per backend service, all the exact same shape as api_gateway_internal_url above — how any service reaches that service inside this install. Env var names below are confirmed against the real target names in Service Environment Map's API Gateway section (the ASPNETCORE_OCELOT_HOSTS_* rows), not guessed:

Name Env var Default
signing_identity_internal_url AC_SIGNING_IDENTITY_API_INTERNAL_URL http://signing-identity.appcircle.svc.cluster.local
license_internal_url AC_LICENSE_API_INTERNAL_URL http://license.appcircle.svc.cluster.local
distribution_internal_url AC_DISTRIBUTION_INTERNAL_URL http://distribution.appcircle.svc.cluster.local
notification_internal_url AC_NOTIFICATION_INTERNAL_URL http://notification.appcircle.svc.cluster.local
build_internal_url AC_BUILD_API_INTERNAL_URL http://build.appcircle.svc.cluster.local
store_submit_internal_url AC_STORE_SUBMIT_API_INTERNAL_URL http://store-submit.appcircle.svc.cluster.local
report_internal_url AC_REPORT_INTERNAL_URL http://report.appcircle.svc.cluster.local
ui_internal_url AC_UI_INTERNAL_URL http://ui.appcircle.svc.cluster.local
task_internal_url AC_TASK_INTERNAL_URL http://task.appcircle.svc.cluster.local
store_profile_internal_url AC_STORE_PROFILE_INTERNAL_URL http://store-profile.appcircle.svc.cluster.local
store_api_internal_url AC_STORE_API_INTERNAL_URL http://store-api.appcircle.svc.cluster.local
tester_api_internal_url AC_TESTER_API_INTERNAL_URL http://tester-api.appcircle.svc.cluster.local
store_report_internal_url AC_STORE_REPORT_INTERNAL_URL http://store-report.appcircle.svc.cluster.local
webhook_internal_url AC_WEBHOOK_INTERNAL_URL http://webhook.appcircle.svc.cluster.local
resign_internal_url AC_RESIGN_INTERNAL_URL http://resign.appcircle.svc.cluster.local
publish_internal_url AC_PUBLISH_INTERNAL_URL http://publish.appcircle.svc.cluster.local
agentcache_internal_url AC_AGENTCACHE_INTERNAL_URL http://agentcache.appcircle.svc.cluster.local
store_admin_internal_url AC_STORE_ADMIN_INTERNAL_URL http://store-admin.appcircle.svc.cluster.local
resource_internal_url AC_RESOURCE_API_INTERNAL_URL http://resource.appcircle.svc.cluster.local
codepush_internal_url AC_CODEPUSH_INTERNAL_URL http://codepush.appcircle.svc.cluster.local
user_internal_url AC_USER_SERVER_INTERNAL_URL http://user.appcircle.svc.cluster.local — from ASPNETCORE_USER_SERVER_URL, a typed-HttpClient config rather than an Ocelot route; same concept, different consumer.

Not a single uniform naming convention — followed exactly, not \"corrected\"

An earlier pass through this table guessed a single _INTERNAL_BASE_URL shape for all of these, modeled on api_gateway_internal_url. Checking against the real target names in Service Environment Map showed that guess was wrong for every one of them: some carry _API_ (signing_identity, license, build, store_submit, resource), most don't; none carry _BASE_, even though AC_API_GATEWAY_INTERNAL_BASE_URL and AC_CONFIG_INTERNAL_BASE_URL above do. The platform's own migration table is genuinely not internally consistent here — this file matches each real name exactly rather than imposing a uniform pattern it doesn't have.

Three more (tester_admin, app_parser, schedule_manager) appeared in appcircle-self-hosted's ASPNETCORE_SERVER_URLS list but have no corresponding ASPNETCORE_OCELOT_HOSTS_* row in Service Environment Map to confirm a real target name against. They're kept below, but their env var name is this file's own best-effort guess, not a verified fact — confirm it before relying on it.

AC_IDENTITY_INTERNAL_URL (ASPNETCORE_OCELOT_HOSTS_IDENTITY) isn't in the table above at all: it's a second env var pointing at the same place keycloak_internal_url already does (Keycloak, via a different Ocelot route), not a distinct service — it doesn't need its own context input.

tester_admin_internal_url:
  env: AC_TESTER_ADMIN_INTERNAL_BASE_URL
  default: http://tester-admin.appcircle.svc.cluster.local
app_parser_internal_url:
  env: AC_APP_PARSER_INTERNAL_BASE_URL
  default: http://app-parser.appcircle.svc.cluster.local
schedule_manager_internal_url:
  env: AC_SCHEDULE_MANAGER_INTERNAL_BASE_URL
  default: http://schedule-manager.appcircle.svc.cluster.local

This closes a real, currently-live duplication

Today, API Gateway builds two independent things from this same set of addresses: its own Ocelot routing table (ASPNETCORE_OCELOT_HOSTS_<MODULE>, one env var per module, substituted into ocelot-source.yaml) and its /services//health/deep aggregators (ASPNETCORE_SERVER_URLS, one comma-separated env var). Neither references the other — two independently configured lists of the same facts, with no shared source of truth and a real drift risk if one is updated and the other isn't.

ASPNETCORE_SERVER_URLS can't just be written as several ${context:*_internal_url} references spliced together, either — the envdef placeholder grammar (Services/TemplateSyntax.cs) allows exactly one ${scope:reference} per value, not 24 of them. So the join happens once, here, as its own derived value: api_gateway_server_urls (contextdef.yaml), built with Scriban's array.join over all 24 downstream internal URLs above. Once API Gateway resolves its own envdef.yaml through Config Service, ASPNETCORE_SERVER_URLS becomes the single reference "${context:api_gateway_server_urls}" — no manual list to keep in sync, and it can't drift from the individual *_internal_url values feeding it.

Per-Service Redis Addresses

One input per service that runs its own distributed cache — same "how does this specific service reach its own dependency" shape as the internal URLs above, but for Redis rather than another Appcircle service. Every input defaults to the same address because a standard install runs one shared Redis resource; an operator overrides just one of them if that specific service ever needs to move to its own cluster. Confirmed against Service Environment Map: every service below currently reads ASPNETCORE_REDIS_ENDPOINT into the same target name, AC_REDIS_ADDRESS — that's not a conflict, since it's a per-process env var, not a globally unique one; the inputs below are what feeds that per-process AC_REDIS_ADDRESS once each service resolves its own envdef.yaml through Config Service, the same way build_internal_url feeds other services' env vars when they need to reach Build Server.

Name Env var Default
resource_redis_address AC_RESOURCE_REDIS_ADDRESS redis.appcircle.svc.cluster.local:6379
api_gateway_redis_address AC_API_GATEWAY_REDIS_ADDRESS redis.appcircle.svc.cluster.local:6379
distribution_redis_address AC_DISTRIBUTION_REDIS_ADDRESS redis.appcircle.svc.cluster.local:6379
tester_api_redis_address AC_TESTER_API_REDIS_ADDRESS redis.appcircle.svc.cluster.local:6379
store_api_redis_address AC_STORE_API_REDIS_ADDRESS redis.appcircle.svc.cluster.local:6379
build_redis_address AC_BUILD_REDIS_ADDRESS redis.appcircle.svc.cluster.local:6379
license_redis_address AC_LICENSE_REDIS_ADDRESS redis.appcircle.svc.cluster.local:6379
signing_identity_redis_address AC_SIGNING_IDENTITY_REDIS_ADDRESS redis.appcircle.svc.cluster.local:6379
task_redis_address AC_TASK_REDIS_ADDRESS redis.appcircle.svc.cluster.local:6379
agentcache_redis_address AC_AGENTCACHE_REDIS_ADDRESS redis.appcircle.svc.cluster.local:6379
publish_redis_address AC_PUBLISH_REDIS_ADDRESS redis.appcircle.svc.cluster.local:6379
schedule_manager_redis_address AC_SCHEDULE_MANAGER_REDIS_ADDRESS redis.appcircle.svc.cluster.local:6379
store_submit_redis_address AC_STORE_SUBMIT_REDIS_ADDRESS redis.appcircle.svc.cluster.local:6379
otp_redis_address AC_OTP_REDIS_ADDRESS redis.appcircle.svc.cluster.local:6379

Only services confirmed in Service Environment Map to actually read ASPNETCORE_REDIS_ENDPOINT (not the ones where it's dead/unused) get an entry here.

Derived

Named Scriban templates, each free to reference any input or any other derived value by name. Declaration order does not matter — dependencies are worked out at evaluation time by repeating passes until nothing more resolves; a pass that resolves nothing while values remain pending means a reference cycle or a name that will never exist, and that's always a fatal startup error, never a per-request one. The "Example" column below assumes every input at its default except external_root_host = acme.appcircle.io.

This is the table a service actually consumes fromroot_external and the *_external_url rows are what a service's own ${context:...} reference resolves to; external_root_host itself (above) is raw material for these, not something a service reads for its own address.

Name Formula Example
default_external_port 443/80, by external_scheme 443
external_port_effective external_port, or default_external_port if unset 443
external_port_suffix Empty when the effective port is literally 443 or 80, else :<port> (empty)
root_external <scheme>://<external_root_host><port_suffix> https://acme.appcircle.io
auth_external_url <scheme>://auth.<external_root_host><port_suffix> https://auth.acme.appcircle.io
store_external_url <scheme>://store.<external_root_host><port_suffix> https://store.acme.appcircle.io
tester_external_url <scheme>://my.<external_root_host><port_suffix> https://my.acme.appcircle.io
api_external_url <scheme>://api.<external_root_host><port_suffix> https://api.acme.appcircle.io
keycloak_realm_url <auth_external_url>/auth/realms/appcircle https://auth.acme.appcircle.io/auth/realms/appcircle
keycloak_store_realm_url <auth_external_url>/auth/realms/store https://auth.acme.appcircle.io/auth/realms/store
keycloak_distribute_realm_url <auth_external_url>/auth/realms/distribute https://auth.acme.appcircle.io/auth/realms/distribute
keycloak_x_forwarded_proto = external_scheme https
keycloak_x_forwarded_port = external_port_effective 443
keycloak_x_forwarded_host auth.{external_root_host} auth.acme.appcircle.io
enabled_git_providers git_providers, deduplicated github,gitlab,bitbucket,azure
api_gateway_server_urls All 24 downstream *_internal_url values (the 21 confirmed + 3 best-effort-guess ones), comma-joined http://signing-identity.appcircle.svc.cluster.local,http://license.appcircle.svc.cluster.local,...
api_gateway_additional_issuer_urls The three realm issuers above on auth_external_url, plus the same three on keycloak_dmz_custom_domain when it's set, comma-joined https://auth.acme.appcircle.io,https://auth.acme.appcircle.io/auth/realms/store,https://auth.acme.appcircle.io/auth/realms/distribute

ASPNETCORE_ADDITIONAL_ISSUER_URL_LIST is deterministic, not operator-arbitrary

An earlier pass through this doc guessed this was built from a DMZ conditional the envdef grammar couldn't express, then over-corrected to calling it arbitrary operator-supplied data like ASPNETCORE_ALLOWED_REDIRECT_HOSTS. Neither was right. appcircle-self-hosted's real generation template settled it:

ASPNETCORE_ADDITIONAL_ISSUER_URL_LIST=https://{{.keycloak.external.domain}},https://{{.keycloak.external.domain}}/auth/realms/store,https://{{.keycloak.external.domain}}/auth/realms/distribute{{if .keycloak.dmzCustomDomain.enabled}},https://{{.keycloak.dmzCustomDomain.domain}},https://{{.keycloak.dmzCustomDomain.domain}}/auth/realms/store,https://{{.keycloak.dmzCustomDomain.domain}}/auth/realms/distribute{{end}}
Three issuers (appcircle/store/distribute realms on the primary auth domain) are always present; three more, on a second DMZ domain, only when that install has one configured. Both halves are genuinely derivable — the conditional lives in Scriban (if/else/end, same as default_external_port), not in the envdef, so API Gateway's own envdef.yaml still only ever needs the single reference "${context:api_gateway_additional_issuer_urls}". One behavioral improvement over the legacy template: ours carries a non-default port through via auth_external_url's own external_port_suffix, which the legacy version never supported.

container_runtime/selinux_volume_suffix and environment/image_tag were removed from this file entirely, for two different but related reasons:

  • container_runtime/selinux_volume_suffix — no application service ever consumed them through Config Service, and the one real consumer of the SELinux-volume-mount concept — appcircle-self-hosted's compose.yaml.tmpl — computes it independently, directly on the host (getenforce), before Config Service is even running.
  • environment/image_tag — every service already reads its own AC_ENVIRONMENT directly (deployment-wide, no component — see Environment Variables), so none of them needed ${context:environment} to learn their own environment. image_tag's only plausible consumer is deploy tooling deciding which image to pull — a decision that, like SELinux above, has to happen before any container (Config Service included) is running; real image tagging for self-hosted installs happens in release/pipeline scripts, not through a runtime context lookup.

Both removals follow the same pattern: a value that only makes sense to whatever deploys the containers, not to a running service, does not belong in a definition a running service resolves from.

namespace, cluster_domain, and internal_domain_suffix were all removed — but api_gateway_internal_url and config_internal_url were kept, now as plain inputs instead of values derived from a namespace/cluster-domain pair. This is a different kind of trim from the two above: api_gateway_internal_url/config_internal_url are genuinely something a service running after Config Service is up could reasonably resolve (they have zero consumers today only because envdef itself is brand new, not because they're structurally unreachable) — they didn't need removing, just simplifying. cluster.local is Kubernetes's own default cluster domain and is never something a real install customizes, and namespace alone was only ever used to build these same two URLs, so composing them from a namespace/cluster_domain pair (plus a shared internal_domain_suffix in between) added three moving pieces for what is, in practice, one complete URL per dependency. Each now reads directly from its own env var (AC_API_GATEWAY_INTERNAL_BASE_URL/AC_CONFIG_INTERNAL_BASE_URL, matching the _BASE_URL convention AC_KEYCLOAK_INTERNAL_BASE_URL already uses — see Environment Variables), defaulting to the same Kubernetes DNS shape as before. This also simplifies a Compose install's override story: set the env var directly, no AC_CONTEXT_TEMPLATE__/AC_CONTEXT__ indirection needed.

This is the full, current set shipped in contextdef.yaml — if a name isn't in one of the two tables above, it isn't a valid ${context:...} reference yet; add it to contextdef.yaml (or override with AC_CONTEXT_TEMPLATE__<NAME>, below) before a service references it.

Deterministic by construction, on purpose: no template can read the clock, the disk, the network, or the process environment — Scriban's own date/timespan builtins are explicitly removed from the evaluation scope. The context must come out identical on every replica and across every restart; without that guarantee, "restart to pick up a context change" would mean nothing, since two replicas could then legitimately disagree. A value that must be stable but genuinely can't be computed this way (a license start date, a password) is never modeled as context — it's provisioned once into the secret store instead (see Provisioning).

An install extends this without ever replacing the file — two env-var forms, lowest to highest precedence:

Precedence Form Effect
1 (lowest) (shipped contextdef.yaml) The image's own inputs and derived templates.
2 AC_CONTEXT_TEMPLATE__<NAME>={{ ... }} Adds a new derived value, or replaces an existing template by name — still a template, still evaluated the same way.
3 (highest) AC_CONTEXT__<NAME>=value Pins a literal, unconditionally winning over anything derived — an operator overriding a value never needs to understand the template that would have produced it.

Both forms are flat names onlyAC_CONTEXT__A__B (a nested value) is a startup error, not a silently-dropped variable, since a context value that vanishes without a word is far harder to debug than a process that refuses to start.

A service consumes a context value in its own envdef.yaml exactly the way it consumes a secret, just with a different scope prefix: ${context:root_external} instead of ${secret:svc/key} — resolved in the same /resolve call, just from this source instead of the sourcing policy.

Service Naming

Every service has exactly one identifier that's supposed to mean the same thing everywhere: as the owning namespace (first segment) of its secret paths, as the service it sends when calling the runtime proxy (RuntimeSecretClient), and as the AppRole role_id / Kubernetes ServiceAccount name OpenBao/Vault actually authenticates it as. Central Secret Sourcing Policy's authorization check is exactly this: the caller's own identity, as OpenBao/Vault reports it back after login, must equal a path's first segment (or the path must list that caller in consumers).

For a service already onboarded — Compose's service-config/services.list (see How a Caller Authenticates) is the authoritative source for its real name; nothing here duplicates that list.

For the seven services still on the legacy ISafeStore<T> pattern (Appcircle.AspNetCore, not yet onboarded to this proxy — see the "Not a migration..." non-goal in docs/tasks/PL-120/spec.md), their existing secrets already live at a real, fixed KV location — confirmed by reading SafeStoreVault<T>'s own HTTP calls and ASPNETCORE_VAULT_SERVICE_PREFIX:

Real service Legacy mount Legacy prefix Full legacy KV path shape
SigningIdentityServer local signing local/signing/<key>
Store.AdminService local enterprise-store local/enterprise-store/<key>
Store.ProfileService local enterprise-store local/enterprise-store/<key>
StoreSubmitServer store store-submit store/store-submit/<key>
PublishService store publish store/publish/<key>

The legacy prefix is short, not the service's full name

Note these are not signing-identity-server, store-submit-server, etc. — the actual existing KV location uses the shorter prefix ASPNETCORE_VAULT_SERVICE_PREFIX was set to, years before this proxy existed. A SecretSourcingRule.Path migrating one of these secrets onto the runtime proxy must use that real prefix exactly ({ "path": "signing/root_certificate", "mount": "local", "writable": true }), not a prettier new-style name — the store has no entry at any other path, so a rule pointed at the "nice" name would just read/write nothing, forever.

Store.AdminService and Store.ProfileService already share one identity

Both use the legacy prefix enterprise-store — they've shared one Vault identity since before this proxy existed. Migrating either one's secrets onto the runtime proxy means the path's owning namespace is enterprise-store, which is neither service's own name — whichever migrates will need enterprise-store itself as its declared service, or be added to that rule's consumers under its own separate identity. Worth deciding deliberately before either service migrates, not something this design resolves on its own.

Caller identity and KV path don't have to be the literal same string, as long as authorization connects them: a service can authenticate under a new, cleaner identity of its own choosing and still reach a legacy path, by being added to that rule's consumers — the path's first segment is what has to match reality (the real legacy KV location), not the caller's own declared name.

For a brand-new (KV v2, generatable/resolved) secret, there is no legacy constraint — this repo's own docs, tests, and the checked-in local-dev policy fixture (Appcircle.ConfigService/src/Appcircle.ConfigService.WebApi/sourcing-policy.local.json) consistently use a <domain>-<role> kebab-case shape (signing-identity-server, build-service, publish-service, store-admin-service, task-service). This is illustrative convention used in examples, not a rule enforced anywhere in code — the identifier a newly-onboarded service actually gets is whatever it's given in services.list (Compose) or its ServiceAccount name (Helm), decided once, at onboarding. task-service specifically is a stand-in used throughout this repo's examples; it does not literally match the real TaskServer project's own registered name and isn't meant to be read as authoritative for it.

Worked Example: A Policy Mixing New and Legacy Paths

Putting the two mounts from KV v1 vs v2 and the real legacy prefixes above together — a policy that provisions a brand-new secret, exposes an existing v2 path at runtime, and migrates two real legacy secrets onto the runtime proxy:

{
  "unknownKey": "deny",
  "mounts": {
    "local": { "kvVersion": 1 },
    "store": { "kvVersion": 1 }
  },
  "rules": [
    {
      "path": "task-service/redis_password",
      "provider": "internal",
      "generatable": true,
      "generator": "password(32)"
    },
    {
      "path": "signing-identity-server/certs/root",
      "provider": "internal",
      "writable": true
    },
    {
      "path": "signing/root_certificate",
      "provider": "internal",
      "writable": true,
      "deletable": true,
      "mount": "local"
    },
    {
      "path": "store-submit/upload_token",
      "provider": "internal",
      "writable": true,
      "deletable": true,
      "mount": "store"
    },
    {
      "path": "shared/redis_password",
      "provider": "internal",
      "generatable": true,
      "generator": "password(32)",
      "consumers": ["build-service", "publish-service"]
    },
    {
      "path": "api-gateway/mail_encryption_key",
      "provider": "internal",
      "generatable": true,
      "generator": "password(16)"
    }
  ]
}
  • task-service/redis_password — brand new, KV v2 (this install's own default mount), generated by Provisioning the first time it's absent.
  • signing-identity-server/certs/root — also KV v2, a new-style path this service manages through the runtime proxy going forward; unrelated to any legacy secret.
  • signing/root_certificate — the real legacy path for SigningIdentityServer's existing certificate, migrated onto the runtime proxy at its actual existing KV location (mount: "local", prefix signing, per the table above) — the caller must present itself as signing (its existing AppRole/ServiceAccount identity) to reach it, unless it's added under a different identity via consumers.
  • store-submit/upload_token — same shape, for StoreSubmitServer's legacy store mount.
  • shared/redis_password — a cross-service credential outside any one service's own namespace, read (and written) by both build-service and publish-service via consumers.
  • api-gateway/mail_encryption_key — a concrete example of the line between context and secret. appcircle-self-hosted's api-gateway/default.env.tmpl currently supplies MAIL_ENCRYPTION_KEY from a manually-set value (.keycloak.mailEncryptionKey, truncated to 16 characters at render time). It is not a per-install fact the way a domain or a Kafka broker list is — it is cryptographic material with no correct value an operator could type in, which is exactly what generatable exists for: declare it once, password(16) generates it the first time it's absent, and no install ever hand-picks or hand-types an encryption key again. (The generator length here is illustrative — pick the length the actual encryption algorithm requires, not a guess.)

See Central Secret Sourcing Policy for what every field in a rule means.

Worked Example: Migrating API Gateway's default.env.tmpl, Line by Line

appcircle-self-hosted's config/api-gateway/default.env.tmpl is API Gateway's current, pre-Config-Service env file — every line below is a real line from that file, and every right-hand side is either a recommended value (a real context/secret reference already confirmed to exist, safe to copy as-is) or explicitly marked as not yet resolved (no confirmed mapping exists, so anything shown for it is illustrative only, not a recommendation). Nothing here is presented as a recommendation unless the context/secret value it points at is one already defined in this doc or in a real sourcing policy.

Legacy variable Recommended envdef.yaml value
ASPNETCORE_OIDC_EXTERNAL_URL "${context:auth_external_url}"
ASPNETCORE_KEYCLOAK_SERVER_URL "${context:keycloak_internal_url}"
ASPNETCORE_KAFKA_SERVER_URLS "${context:kafka_bootstrap_servers}"
ASPNETCORE_RESOURCE_SERVER_URL "${context:resource_internal_url}"
PrivateApiUrl (API Gateway's own copy of this variable — see the warning below) "${context:api_external_url}"
ASPNETCORE_REDIS_ENDPOINT "${context:api_gateway_redis_address}" — see Per-Service Redis Addresses
ASPNETCORE_SERVER_URLS "${context:api_gateway_server_urls}" — a derived value, not an input; see the note below Downstream Service Internal URLs for why this has to be pre-joined rather than written as several references in one value
ASPNETCORE_ADDITIONAL_ISSUER_URL_LIST "${context:api_gateway_additional_issuer_urls}" — another pre-joined derived value; see the note below the Derived table for the DMZ-custom-domain conditional it carries

The placeholder grammar mixes literal text and references freely in one value (see Dynamic Configuration & Secrets) — nothing here needs a new context or derived value, just the existing reference plus the fixed path/suffix the legacy template already hardcodes.

Legacy variable Recommended envdef.yaml value
ASPNETCORE_AUDIENCE "${context:auth_external_url}/resources"
ASPNETCORE_OIDC_DISCOVERY_URL "${context:keycloak_internal_url}/auth/realms/appcircle/.well-known/openid-configuration"
ASPNETCORE_STORE_OIDC_DISCOVERY_URL "${context:keycloak_internal_url}/auth/realms/store/.well-known/openid-configuration"
ASPNETCORE_DISTRIBUTE_OIDC_DISCOVERY_URL "${context:keycloak_internal_url}/auth/realms/distribute/.well-known/openid-configuration"
ASPNETCORE_CORS_SUFFIX ".${context:external_root_host}"the leading . is required: the real value this replaces is .appcircle.io, not acme.appcircle.io; external_root_host is deliberately bare (see its own row above), so the consuming envdef adds the dot, context does not.

PrivateApiUrl means something different depending on who reads it

For API Gateway's own envdef.yaml, PrivateApiUrl is the gateway's own external address (${context:api_external_url}, confirmed by its AC_EXTERNAL_URL target name in Service Environment Map's API Gateway row). For a different service reading its own PrivateApiUrl (e.g. Tester Web, confirmed target name AC_API_GATEWAY_INTERNAL_BASE_URL), the same variable name means "the address I use to call the gateway" — i.e. ${context:api_gateway_internal_url}. Same env var name, two different context values, depending entirely on which service's own envdef it appears in.

Static literals — no placeholder needed at all

None of these vary by install; they're fixed Keycloak client/audience identifiers or feature flags. Write the literal value directly, no ${...} of any kind:

Legacy variable Value
ASPNETCORE_TESTER_CLIENT_ID tester-web
ASPNETCORE_REPORTING_SERVER_CLIENT_ID reporting-server
ASPNETCORE_KEYCLOAK_AUDIENCE license-server
ASPNETCORE_APPCIRCLE_WEB_CLIENT_ID appcircle-web
ASPNETCORE_STORE_WEB_CLIENT_ID store-web
ASPNETCORE_RESOURCE_CLIENT_ID resource-server
ASPNETCORE_RATE_LIMITING_GLOBAL_IP_ENABLED false

Secrets — belong in the sourcing policy, not context

Legacy variable Recommended envdef.yaml value Sourcing policy shape
MAIL_ENCRYPTION_KEY "${secret:api-gateway/mail_encryption_key}" generatable, see the worked policy example above
ASPNETCORE_APPCIRCLE_WEB_SECRET "${secret:api-gateway/keycloak_client_secret}" generatable (a Keycloak client secret this install owns)
ASPNETCORE_UNSUB_RIJNDAEL_KEY "${secret:api-gateway/unsub_rijndael_key}" generatable
API_KEY_SECRET_ENCRYPTION_KEY "${secret:api-gateway/api_key_secret_encryption_key}" generatable
ASPNETCORE_STORE_JWT_RSA_PUBLIC_KEY "${secret:store-api/jwt_rsa_public_key}" store-api/jwt_rsa_private_key is generatable with generator: "rsa(2048)" and publicKeyPath: "store-api/jwt_rsa_public_key"; this gateway reads the public path via consumers, not its own path — see Store API's own section
ASPNETCORE_DISTRIBUTION_JWT_RSA_PUBLIC_KEY "${secret:distribution/jwt_rsa_public_key}" same shape, via consumers

Stays each service's own variable — not context, on purpose

Same reasoning as AC_ENVIRONMENT further up this page: no single value is correct for every service, or the value is this service's own deployment-topology property rather than a per-install fact. (ASPNETCORE_REDIS_ENDPOINT used to be listed here too — it's now ${context:api_gateway_redis_address}, see the table above, since each service's own Redis address is still a per-install fact worth declaring, even though no single address is correct for every service; see Per-Service Redis Addresses.)

Legacy variable Why it's not context
ASPNETCORE_IS_SELF_HOSTED Deployment-wide, but each service reads its own copy directly — same as AC_ENVIRONMENT.
ASPNETCORE_SELF_HOSTED_MODULES This gateway's own enabled-module allow-list, not a fact other services need.
ASPNETCORE_RATE_LIMITING_ENABLED This gateway's own feature switch, no other service reads it.