Dynamic Configuration & Secrets¶
How Appcircle services get their configuration and secrets: Appcircle.ConfigService (repo
ac-service-config), the Appcircle.Core.Config client library every service uses to talk to it,
how a caller authenticates on each deployment target (Docker Compose, Helm), and
ac-config-agent for third-party components that cannot call Config Service themselves. Grew
out of the OpenBao migration TDD — see TDD-0004
and ADR-0007.
For the envdef.yaml file format a service author writes, see
Environment Configuration (envdef). For the AC_ naming rules,
see Environment Variables.
Config Service never lets a caller hold a raw OpenBao/Vault token. Every caller presents its own credential; Config Service exchanges it for a scoped store token server-side, uses it, and throws it away — nothing is ever returned to the caller.
Client Library — Appcircle.Core.Config¶
Two clients, for the two moments a service needs config:
| Client | When | Used for |
|---|---|---|
ConfigServiceClient |
Once, at startup | POST /v1/config/resolve — the whole envdef.yaml at once |
RuntimeSecretClient |
Any time after startup | GET/PUT/DELETE /v1/secrets/{path} — one value at a time |
Startup — resolve the whole envdef.yaml:
var options = new ConfigServiceOptions
{
ServiceUrl = BuildResolveUrl(Environment.GetEnvironmentVariable("AC_CONFIG_INTERNAL_URL")),
Service = EnvDefLoader.LoadService("envdef.yaml"), // reads the file's own `service:` field
DefPath = "envdef.yaml",
CredentialSource = Environment.GetEnvironmentVariable("AC_CONFIG_CREDENTIAL_URI")
};
options.Validate();
builder.Configuration.AddConfigService(opts => { /* copy the fields above */ });
EnvDefLoader.LoadService reads the identity out of the file instead of a second,
independently-set Service value — the two drifting apart would silently point the auth exchange
at the wrong identity.
Any time after — read or write one secret:
using var client = new RuntimeSecretClient(new RuntimeSecretClientOptions
{
ServiceUrl = "http://ac-config",
Service = "task-service",
CredentialSource = Environment.GetEnvironmentVariable("AC_CONFIG_CREDENTIAL_URI")
});
var value = await client.ReadAsync("task-service/some_key", cancellationToken); // null if absent
await client.WriteAsync("task-service/some_key", "new-value", cancellationToken); // throws if refused
Both clients take the same CredentialSource — see How a Caller Authenticates below.
| Bootstrap variable | Meaning |
|---|---|
AC_CONFIG_INTERNAL_URL |
Base URL of Config Service, e.g. http://ac-config |
AC_CONFIG_CREDENTIAL_URI |
Where to read the caller's own credential from (an AppRole secret_id or a ServiceAccount JWT, depending on deployment target) — see How a Caller Authenticates |
AC_CONFIG_REPORT_ENABLED |
Off by default. Prints every resolved key at startup; secret-sourced keys print name only, never the value |
Startup ordering — retrying while Config Service isn't up yet
Kubernetes/Compose restart a crashed container until Config Service answers, so production
never needs this. A bare dotnet run has no such restart loop — set opts.StartupRetryEnabled
= true (plus StartupRetryAttempts/StartupRetryDelay, both defaulted) on ConfigServiceOptions
for local dev only. Off by default everywhere; retries any resolve failure since a
non-transient one just fails identically on every attempt.
How a Caller Authenticates¶
Every call — resolve, runtime read, runtime write — starts the same way: the caller presents its own credential, Config Service exchanges it for a scoped store token, uses that token, then drops it. The caller never sees that store token.
The credential itself is one of two concrete things, never anything else — which one depends on the deployment target:
- Docker Compose → an AppRole
secret_id - Helm/Kubernetes → the pod's own ServiceAccount JWT
One install-wide setting, AC_CALLER_AUTH_KIND (approle or kubernetes, default kubernetes),
tells Config Service which of the two to expect from every caller on this install — it is not
something a caller declares about itself. The client library never branches on this either: it
just sends whatever CredentialSource resolves to (a secret_id string, or a JWT string) as a
Bearer token, without knowing or caring which one it is.
The caller also sends its own claimed identity as a service query parameter, alongside that
credential — this is not derived from the secret path being requested. That claim is only ever
provisional: Config Service passes it straight to OpenBao/Vault as the AppRole role_id (or
Kubernetes role) in the exchange step below, and the login only succeeds if the presented
secret_id/JWT actually matches what that role is registered for. Whatever identity OpenBao/Vault
reports back (never the caller's raw claim) is what every later authorization check uses.
caller Config Service OpenBao/Vault
|--- Bearer <secret_id|JWT> ->| |
| (+ ?service=<claimed>) |--- exchange (role_id/role + credential)|
| |--------------------------------------->|
| |<---------- scoped token ---------------|
| |--- read/write with scoped token ------>|
|<-------- value/result ------| |
Docker Compose (AppRole)¶
The credential is an AppRole secret_id; service names the AppRole role_id.
Onboarding a service — fully automated: add its name to
appcircle-docker-compose/service-config/services.list and run ./stack.sh up. The active
store then has, for that service, a read-only policy (service-<name>-read, scoped to
secret/data/<name>/*), an AppRole role, and a credential file under ./credentials/<name>/.
Idempotent — rerunning up just re-declares the same policy/role/credential.
AC_CONFIG_CREDENTIAL_URI is one of:
- File — a service running as a container on the compose network: bind-mount
./credentials/<name>and point at/credentials/<name>/secret_id. - HTTP — a service that can't rely on a shared bind-mounted path (a developer's host
machine, started from an IDE):
http://localhost:8090/credentials/<name>against the always-upcredential-server.py, which reads./credentials/<name>/and returns{"role_id": ..., "secret_id": ...}, unauthenticated (local-dev only — no TTLs, no rotation, no access control beyond the read policy itself).
Helm / Kubernetes (ServiceAccount)¶
The credential is the pod's own projected ServiceAccount JWT; service names the Vault/OpenBao
role it should be bound to.
AC_CONFIG_CREDENTIAL_URI is the mounted token file path (e.g.
/var/run/secrets/kubernetes.io/serviceaccount/token) — no HTTP-source case exists here, since
every pod already has this file without any extra setup.
Not yet automated
Compose's services.list + ./stack.sh up has no Helm equivalent yet: nothing
automatically creates the OpenBao/Vault auth/kubernetes role + read policy for a new
service the way provision.sh does for Compose. Until that exists, onboarding a service
under Helm means creating that role/policy by hand. Tracked as a gap, not a design choice.
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), shipped as contextdef.yaml and overridable via
AC_CONTEXT_DEFINITION_PATH/AC_CONTEXT__<NAME>/AC_CONTEXT_TEMPLATE__<NAME>. Resolved in the
same /resolve call as secrets, just from a different, simpler source. See Deployment Context &
Service Naming for the full
mechanism (input validation, Scriban templates, precedence, determinism guarantees).
Central Secret Sourcing Policy¶
A service's envdef.yaml only ever says that it needs ${secret:svc/key}. Where that value
comes from, whether it may be generated, and whether a service may write it at runtime — all of
that is decided centrally, by one policy document, not per service.
Supplied as JSON, inline (AC_SOURCING_POLICY) or as a file (AC_SOURCING_POLICY_PATH, mutually
exclusive with the inline form). Read once at startup, immutable for the process lifetime.
{
"unknownKey": "deny",
"mounts": {
"legacy-mount": { "kvVersion": 1 }
},
"rules": [
{
"path": "task-service/redis_password",
"provider": "internal",
"generatable": true,
"generator": "password(32)"
},
{
"path": "task-service/certs/root",
"provider": "internal",
"writable": true
},
{
"path": "task-service/legacy_key",
"provider": "internal",
"writable": true,
"deletable": true,
"mount": "legacy-mount"
},
{
"path": "shared/redis_password",
"provider": "internal",
"generatable": true,
"generator": "password(32)",
"consumers": ["build-service", "publish-service"]
},
{
"path": "task-service/jwt_rsa_private_key",
"provider": "internal",
"generatable": true,
"generator": "rsa(2048)",
"publicKeyPath": "task-service/jwt_rsa_public_key"
},
{
"path": "task-service/jwt_rsa_public_key",
"provider": "internal",
"consumers": ["api-gateway"]
}
]
}
For a worked example using this install's actual services and its two real legacy mounts (local,
store), see Deployment Context & Service
Naming.
| Field | Meaning |
|---|---|
unknownKey |
deny (default, fail-closed — every path must be declared) or allow-internal (read-only fallback, never generated). |
mounts |
Every non-default secrets-engine mount this policy's rules reference, keyed by mount name, each declaring its own kvVersion. See KV v1 vs v2 below. |
path |
Exact (store-api/db_connection) or a pattern: trailing /* (mongo/*), leading */ (*/jwt_signing_key). Most specific match wins. |
provider |
internal (this install's own store — may generate/write into) or external (customer-managed — read-only, see External Store). |
generatable |
May Provisioning create this value once, if absent? Must be false when provider is external, and can never resolve to a kvVersion: 1 mount. |
writable |
May Runtime write this value, any time? Must be false when provider is external. Separate from generatable — see why below. |
deletable |
May Runtime delete this value, any time? Must be false when provider is external. Separate from writable, too — see why below. |
generator |
Required when generatable is true — password(n), hex(n), base64(n), uuid (alias: guid — same generator, accepted since legacy Keycloak client secrets in this codebase were literally .NET Guids), or rsa(n) (n one of 2048/3072/4096 bits). |
publicKeyPath |
Required when, and only meaningful when, generator is rsa(n): the path the generated public key is written to alongside this rule's own (private-key) path. That path needs its own rule too — usually generatable: false, with whichever services need to verify this service's JWTs listed in consumers — since a rule only lets you generate at its own path, not read a different one. See Provisioning. |
consumers |
Other services allowed to read (and, if writable, write) this path on top of its owning namespace — for a shared credential that lives outside any one service's own namespace. |
mount |
The secrets-engine mount this path lives under, e.g. local, store. Absent (default) means this install's own default mount, always kvVersion: 2. A non-empty value must be declared in the policy's own mounts section above — see KV v1 vs v2. |
Note there is no kvVersion field on a rule. A rule only says which mount it lives under
(mount); that mount's own declaration in the mounts section is the only place kvVersion is
ever written down. This is deliberate, not an oversight — see below.
Authorization, everywhere: a service may always touch its own namespace (the path's first
segment); anywhere else, it must be named in that rule's consumers, or the call is rejected.
Which credential actually performs each operation¶
generatable/writable/deletable only say whether an operation is allowed — not which
credential carries it out. That mapping is fixed by the system, the same for every rule, and
is not (and must not be) a policy field: making it configurable per rule would let a rule opt a
generatable path into using the caller's own credential, which is exactly the privilege escalation
this design exists to prevent.
| Rule field | Operation | Credential that performs it |
|---|---|---|
generatable |
Create the value once, if absent — lazily during a resolve, or proactively during a bootstrap sweep | Config Service's own privileged AC_PROVISIONING_TOKEN — see Provisioning |
writable |
Set the value, any time, via the runtime proxy | The caller's own scoped credential — see Runtime |
deletable |
Delete the value, any time, via the runtime proxy | The caller's own scoped credential — see Runtime |
| (read, always) | Read the value, at resolve or via the runtime proxy | The caller's own scoped credential |
Reading this table the other way: if you see AC_PROVISIONING_TOKEN involved at all, you are
looking at generation, never a runtime write/delete — those two never touch a privileged
credential, by construction.
KV v1 vs v2 — legacy paths¶
OpenBao/Vault's KV secrets engine comes in two incompatible on-the-wire shapes — KV v2 nests reads
under data.data and wraps writes under a "data" key (plus, on the provisioning-only
compare-and-set path, an "options": {"cas": ...} sibling); KV v1 is flat, no nesting, no CAS.
(The v1/ segment in every OpenBao/Vault HTTP URL is the HTTP API version — unrelated to this;
it's present on both KV v1 and KV v2 paths.)
This matters here because it's not a single install-wide choice: the secrets this system
provisions and resolves are all KV v2, but the handful of services still being migrated off the
old shared-token ISafeStore<T> pattern (Appcircle.AspNetCore) already have real secrets sitting
at KV v1 paths in the store — moving them to the runtime proxy must not also force a store-side
migration to v2. A single service can genuinely have both kinds of path at once.
KV version is a property of the mount, not the path. OpenBao/Vault fixes a secrets engine's
version when the mount is enabled (vault secrets enable -version=1|2 ...) — one mount cannot
serve some paths as v1 and others as v2. Concretely, in this codebase, the legacy ISafeStore<T>
services (Appcircle.AspNetCore) talk to two such KV v1 mounts, both entirely distinct from this
install's own default mount (AC_OPENBAO_KV_MOUNT/AC_VAULT_KV_MOUNT, which is v2) — see
Deployment Context & Service Naming for
exactly which real service maps to which mount and legacy path prefix.
Because of that, kvVersion is declared once per mount, in the policy's mounts section —
never per rule, and never guessed from the path string (a wildcard match on .v1 or similar would
be one typo away from silently reading/writing the wrong shape). A rule only says which mount it
lives under; that mount's own entry in mounts is the single place its version is written down.
Two rules referencing the same mount can never disagree about its version, because there is no
per-rule field left for them to disagree on — the version belongs to the mount, declared exactly
once. (An earlier iteration of this design put kvVersion directly on the rule alongside mount;
that let two rules for the same mount silently declare different versions, an error that surfaced
only against a real store, not while parsing the policy. The mounts registry closes that gap
structurally.)
A rule's mount being absent means this install's own default mount — always kvVersion: 2, since
provisioning's compare-and-set create needs v2 and this install's own default mount is exactly
where provisioning writes. A non-empty mount must appear as a key in mounts, or policy parsing
fails outright — a typo'd or forgotten mount name is a parse-time error, not a request that
silently goes to the wrong shape and 404s against a real store. generatable paths can never
resolve to a kvVersion: 1 mount, enforced the same way: compare-and-set has no v1 equivalent, so
a v1 mount is only ever for a secret this system did not create.
Provisioning¶
Config Service's own action — creating a value nobody asked for right now, because a policy
rule declared it must exist. Always uses the privileged AC_PROVISIONING_TOKEN, never a caller's
credential.
The mechanics (authoritative read → compare-and-set write → journal) are identical no matter what
triggers them — only what triggers them differs, and that's the entire reason
AC_PROVISIONING_MODE has two non-off values instead of one:
AC_PROVISIONING_MODE |
Trigger | Recommended? |
|---|---|---|
off (default) |
Nothing — no privileged credential exists in this process at all. | — |
resolve |
Lazily, as a side effect of some caller's own /resolve hitting an absent path. |
No — kept for the current mental model, but means the privileged token is live in a process that also serves HTTP traffic, indefinitely. |
bootstrap |
Proactively: walks the entire sourcing policy once, provisions every missing exact-path generatable rule, logs a summary, and exits without ever starting Kestrel. |
Yes — run as a separate, short-lived job (a Compose one-shot container, a Kubernetes Job) whenever the policy changes. |
path is absent, rule.generatable = true (resolve mode: found via some caller's own /resolve)
| (bootstrap mode: found by walking the whole policy)
v
does path really not exist? ---------> privileged AC_PROVISIONING_TOKEN reads authoritatively
|
v (confirmed absent)
create it (compare-and-set) -----------> same privileged token writes; loses the race gracefully
| if another instance created it first
v
journal the creation ------------------> same privileged token, shared `_state/provision/...` prefix
(resolve mode additionally re-reads with the caller's own credential afterward, to hand the
value back in that resolve response — provisioning creates the value, it does not by itself grant
anyone sight of it. bootstrap mode has no caller to hand anything back to; it just provisions
and exits.)
| Variable | Meaning |
|---|---|
AC_PROVISIONING_MODE |
off (default), resolve, or bootstrap — see table above. |
AC_PROVISIONING_TOKEN |
Privileged store credential, required whenever mode is not off, used only here — never the caller's own. |
Idempotent, once-only: re-running does nothing once the value exists — no forced-regenerate flag,
so an accidental rotation can never break a running deployment. This is what makes bootstrap
mode safe to run as often as you like (every time the policy changes, or just on a schedule) —
each run only ever creates what's genuinely still missing.
A pattern rule can't be swept ahead of time
A pattern rule (*/jwt_signing_key, mongo/*) has no single concrete KV path to provision —
the concrete path only becomes known once a specific caller's own resolve references it. The
bootstrap sweep skips these (logging how many) and moves on; they still generate lazily,
exactly as before, on that caller's own first resolve — but only on an install that also runs
a resolve-mode process somewhere, since bootstrap-mode processes never serve /resolve at
all.
The serving replica should run with AC_PROVISIONING_MODE=off, always
Keep the long-running, HTTP-serving Config Service replica on off (the default — just don't
set the variable). Only a separate, short-lived bootstrap run should ever hold
AC_PROVISIONING_TOKEN. This is why bootstrap mode exists: before it, provisioning a
newly-added policy rule meant restarting the serving replica with provisioning turned on, then
restarting it again to turn it back off — two restarts, in the right order, just to add one
secret. A separate job removes that dance entirely from the serving replica's lifecycle.
AC_PROVISIONING_TOKEN needs read and write — never write-only
Compare-and-set provisioning has to authoritatively check absence before creating
(the "reads authoritatively" step above), so this credential's OpenBao/Vault ACL needs both
read and create/update — "write-only" cannot support that check. The actual
least-privilege lever isn't narrowing the capability list, it's narrowing the paths: scope
this token's ACL to only the paths the sourcing policy marks generatable (plus the
_state/provision/* journal prefix), not the whole store. Separately, Config Service's own
code never surfaces an existing value read this way — it only ever inspects whether the read
found something, never what it found — so even though the token can technically read a value,
nothing in this path ever logs or returns one.
An rsa(n) rule provisions two paths, not one
Every other generator produces one string for one path. rsa(n) produces a PKCS#8 private
key (-----BEGIN PRIVATE KEY-----) and an X.509 SubjectPublicKeyInfo public key
(-----BEGIN PUBLIC KEY-----) together, and the private key's own rule declares where the
public half goes via publicKeyPath. SecretProvisioner writes the private key first, then
the public key right after, both with the same compare-and-set safety as every other path. If
a prior run crashed between the two writes, the next pass (a resolve call or a bootstrap
sweep) finds the private key already present, does not regenerate it, and instead
re-derives the public key from that existing private key and writes only the missing half —
self-healing without ever producing a public key that doesn't match its private key.
Runtime¶
The caller's own action, at a moment of its own choosing — not Config Service creating
something on its behalf. GET/PUT/DELETE /v1/secrets/{path}?service=<name> (PL-120).
signing-identity-server needs to persist a certificate it just renewed
|
v
PUT /v1/secrets/signing-identity-server/certs/root { "value": "<cert>" }
|
v
rule.writable = true? --- no ---> refused (403)
| yes
v
AC_RUNTIME_WRITE_ENABLED? --- no ---> refused (403)
| yes
v
broker signing-identity-server's OWN credential into a scoped token
|
v
write the value with THAT token ----> store's own ACL for this caller decides: allowed, or 403
|
v
log the write (origin, path, service, mount, kvVersion) — a log line, not a store write
Read works the same way minus the two gates — it always uses the caller's own credential, exactly
like resolve. Delete (DELETE /v1/secrets/{path}) follows the identical shape as write — same
credential, same store-ACL backstop, same journal log line — with its own gate pair instead:
rule.Deletable and AC_RUNTIME_DELETE_ENABLED. It's a soft delete (KV v2's standard
behavior) — the store may still recover a prior version through its own versioning; nothing here
destroys history outright.
Why write and delete each need their own field, separate from generatable and from each
other: provisioning is Config Service acting with its own elevated authority to satisfy a
declared invariant; runtime write/delete are entirely the caller's own action on its own data.
Using the caller's own credential means the store's own ACL is a second, independent check — a
bug in Config Service's authorization code has no effect if the store itself refuses the
operation. A single shared privileged credential (the old design) would have no such backstop: any
authorization bug could mutate any service's namespace. And a path being writable never
implies it's also deletable — deleting is a materially different risk (a wrong value can be
overwritten again; a deleted value may not come back), so an operator can allow updates while
keeping delete off entirely.
| Variable | Meaning |
|---|---|
AC_RUNTIME_WRITE_ENABLED |
Off by default. Install-wide kill switch for write — does not grant capability, only permits attempting it. |
AC_RUNTIME_DELETE_ENABLED |
Off by default. Same shape, independently revocable from write. |
Both gates plus the store's own ACL — all three, every time
rule.Writable/rule.Deletable and the matching AC_RUNTIME_*_ENABLED flag being satisfied
does not mean the operation succeeds — the caller's own AppRole/ServiceAccount policy still
has to grant create/update/delete on its own namespace. This part isn't automated
yet: today's Compose policy template (policy.hcl.tmpl) only grants read/list. A service
with a writable/deletable path fails with the store's own 403 until its policy is extended
— by design, not a bug: turning on runtime write/delete never widens what a caller's
credential can already do.
Read, write, and delete all follow the rule's own mount, and that mount's own kvVersion
A writable/deletable rule pointed at a kvVersion: 1 mount (see
KV v1 vs v2 above) gets full read/write/delete through this same
proxy — it's the intended path for migrating a service off ISafeStore<T> onto the runtime
proxy without also having to migrate its existing secret off KV v1 first. Both the value read
and the value write/delete target that rule's own mount — never this install's own default
mount. The journal (below) is a log line, not a store write, so it has no mount of its own.
The journal is a log line, not a store write
Runtime write/delete log a line (origin, path, caller service, mount, kvVersion) instead
of writing an entry into the store, the way an earlier iteration of this design did (at
<owner>/_journal/<rest-of-path>, with the caller's own credential). Two reasons: this is a
busy, caller-driven path, so an extra store round-trip on every single write/delete is real
cost; and for a path whose mount predates this proxy (a legacy v1 mount), that mount's ACL was
never written with a _journal sub-path in mind, so the store write would fail on every call
for exactly those paths — the ones this feature exists to support. Provisioning's
journal is unaffected — it's a real store write, under a purpose-provisioned privileged
credential's own ACL, once per secret rather than on every call.
External / Customer-Managed Secret Store¶
An install has one bundled internal store (may generate/write into) and, optionally, one
customer-managed external store (read-only, always — "never write into a store we don't own").
A rule's provider field decides which; never inferred at runtime.
| Variable | Meaning |
|---|---|
AC_SECRET_PROVIDER |
openbao (default) or vault — backs the internal store |
AC_EXTERNAL_SECRET_PROVIDER |
vault — enables the external store for provider: external rules. Absent by default. |
| OpenBao variable | Vault variable | Meaning |
|---|---|---|
AC_OPENBAO_ENDPOINT |
AC_VAULT_ENDPOINT |
Base address. Required. |
AC_OPENBAO_KV_MOUNT |
AC_VAULT_KV_MOUNT |
Default KV v2 mount, used whenever a rule declares no mount of its own. Default secret. A rule with kvVersion: 1 overrides this via its own mount field — see KV v1 vs v2. |
AC_OPENBAO_SECRET_FIELD |
AC_VAULT_SECRET_FIELD |
Field within the KV entry. Default value. |
AC_OPENBAO_KUBERNETES_AUTH_PATH |
AC_VAULT_KUBERNETES_AUTH_PATH |
Login path for a ServiceAccount JWT. |
AC_OPENBAO_APPROLE_AUTH_PATH |
AC_VAULT_APPROLE_AUTH_PATH |
Login path for an AppRole credential. |
AC_OPENBAO_TIMEOUT_IN_SECONDS |
AC_VAULT_TIMEOUT_IN_SECONDS |
Per-request timeout. |
Different from the infra OpenBao Agent
OpenBao Agent is a systemd-level agent for
VM/infra secrets (secret/vm/...) — a disjoint namespace, unrelated to any of the above.
Config Agent (ac-config-agent)¶
Redis, Keycloak, MongoDB, Postgres, and other third-party containers cannot call Config Service
themselves. ac-config-agent is a generic
init container that resolves on their behalf and writes the result to a .env file on a
shared volume — the main container just reads that file at its own startup.
First release scope: init-container mode only, .env output only.
| Variable | Meaning |
|---|---|
AC_CONFIG_INTERNAL_URL / AC_CONFIG_CREDENTIAL_URI |
Same as the client library above |
AC_CONFIG_DEF_PATH |
The target service's envdef.yaml, mounted read-only alongside the agent |
AC_CONFIG_OUTPUT_PATH |
Where to write the resolved .env file |
Exits non-zero on any failure — a blocked init container beats a main container starting with no
config. Not the same thing as the infra OpenBao Agent above: this one calls Config Service's
/resolve, never OpenBao/Vault directly, and knows nothing about the sourcing policy.
Config Service's Own Environment Variables¶
Every variable Config Service itself reads, in one place — each is explained in context above; this is the quick-reference version. (This is Config Service's own configuration surface, not a caller's — for what a consuming service/agent configures, see Client Library and Config Agent above.)
| Variable | Category | Default | Meaning |
|---|---|---|---|
AC_ENVIRONMENT |
Host | required | Development/Staging/Production/... — selects the NLog config and IWebHostEnvironment. |
AC_PORT |
Host | required (serving mode only) | Kestrel listen port. Never read at all in bootstrap provisioning mode — see Provisioning. |
AC_CALLER_AUTH_KIND |
Caller auth | kubernetes |
approle or kubernetes — which credential kind every caller on this install presents. See How a Caller Authenticates. |
AC_CONTEXT_DEFINITION_PATH |
Deployment context | (image default) | Path to the context definition file this install derives its ${context:...} values from. See Context Definition. |
AC_CONTEXT__<NAME> |
Deployment context | — | Sets one context value literally, overriding anything derived. See Context Definition. |
AC_CONTEXT_TEMPLATE__<NAME> |
Deployment context | — | Adds or replaces one derived context template. See Context Definition. |
AC_SOURCING_POLICY |
Sourcing policy | unset | Inline JSON policy document. Mutually exclusive with the file form below. |
AC_SOURCING_POLICY_PATH |
Sourcing policy | unset | Path to the policy JSON file — the usual form. See Central Secret Sourcing Policy. |
AC_PROVISIONING_MODE |
Provisioning | off |
off/resolve/bootstrap. See Provisioning. |
AC_PROVISIONING_TOKEN |
Provisioning | unset | Privileged store credential. Required whenever AC_PROVISIONING_MODE is not off. |
AC_PROVISIONING_JOURNAL_PREFIX |
Provisioning | _state/provision |
KV prefix the provisioning journal is written under. |
AC_STARTUP_PROBE_ATTEMPTS |
Provisioning | 10 |
Store-reachability probe attempts before this process gives up and exits at startup. |
AC_STARTUP_PROBE_DELAY_IN_SECONDS |
Provisioning | 3 |
Delay between those probe attempts. |
AC_RUNTIME_WRITE_ENABLED |
Runtime proxy | false |
Install-wide kill switch for PUT /v1/secrets/{path}. See Runtime. |
AC_RUNTIME_DELETE_ENABLED |
Runtime proxy | false |
Install-wide kill switch for DELETE /v1/secrets/{path}. Independently revocable from write. |
AC_SECRET_PROVIDER |
Store selection | openbao |
openbao or vault — which provider backs the internal store. |
AC_EXTERNAL_SECRET_PROVIDER |
Store selection | unset | vault — enables the external store for provider: external rules. |
AC_OPENBAO_ENDPOINT |
OpenBao provider | required if selected | Base address, e.g. http://openbao:8200. |
AC_OPENBAO_KV_MOUNT |
OpenBao provider | secret |
Default KV v2 mount; a rule's own mount field overrides this (required for kvVersion: 1). |
AC_OPENBAO_SECRET_FIELD |
OpenBao provider | value |
Field within the KV entry. |
AC_OPENBAO_KUBERNETES_AUTH_PATH |
OpenBao provider | auth/kubernetes/login |
Login path for a ServiceAccount JWT. |
AC_OPENBAO_APPROLE_AUTH_PATH |
OpenBao provider | auth/approle/login |
Login path for an AppRole credential. |
AC_OPENBAO_TIMEOUT_IN_SECONDS |
OpenBao provider | 10 |
Per-request timeout against OpenBao. |
AC_VAULT_ENDPOINT |
Vault provider | required if selected | Same shape as the AC_OPENBAO_* row above, for HashiCorp Vault. |
AC_VAULT_KV_MOUNT / AC_VAULT_SECRET_FIELD / AC_VAULT_KUBERNETES_AUTH_PATH / AC_VAULT_APPROLE_AUTH_PATH / AC_VAULT_TIMEOUT_IN_SECONDS |
Vault provider | (same defaults as OpenBao) | Mirrors every AC_OPENBAO_* variable above — see External / Customer-Managed Secret Store. |
Related Pages¶
- Environment Configuration (envdef)
- Environment Variables
- Service Environment Map
- OpenBao Agent — the separate, VM/infra-level integration
- TDD-0004: OpenBao Migration & Env Bootstrap
- ADR-0007: Vault License Risk and Alternatives