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 from — root_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}}
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'scompose.yaml.tmpl— computes it independently, directly on the host (getenforce), before Config Service is even running.environment/image_tag— every service already reads its ownAC_ENVIRONMENTdirectly (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 only — AC_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", prefixsigning, per the table above) — the caller must present itself assigning(its existing AppRole/ServiceAccount identity) to reach it, unless it's added under a different identity viaconsumers.store-submit/upload_token— same shape, for StoreSubmitServer's legacystoremount.shared/redis_password— a cross-service credential outside any one service's own namespace, read (and written) by bothbuild-serviceandpublish-serviceviaconsumers.api-gateway/mail_encryption_key— a concrete example of the line between context and secret.appcircle-self-hosted'sapi-gateway/default.env.tmplcurrently suppliesMAIL_ENCRYPTION_KEYfrom 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 whatgeneratableexists 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.
Recommended — a single context reference¶
| 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 |
Recommended — a context reference with literal text appended¶
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. |