Inputs
An application reads its configuration from two places: environment variables and files. A contract describes both. For each input it records the type, the constraints, whether it is required or secret, and — because libraries disagree — exactly how the app parses it.
The platform supplies each input from a source: a literal, a Kubernetes reference, an injector, a mounted file or a config-file overlay. Whatever the source, the value the app finally sees is checked twice: by the platform before deploy where it can be, and by the SDK at boot always.
Environment variables
vars is a map keyed by the variable name, which must match ^[A-Z][A-Z0-9_]*$. SDKs map idiomatic field names (databaseUrl, DatabaseUrl) to this form.
Variable types
The type set is closed in v1alpha1: a new type needs a spec change, because every SDK must parse it identically.
| Type | Meaning | Constraint fields | Platform value | Wire form |
|---|---|---|---|---|
string | Free text. | minLength, maxLength (in characters), pattern (RE2, matches anywhere unless anchored) | string | as is |
int | 64-bit signed integer. SDKs export narrower host ranges as min/max. | min, max | int | base-10, no leading + or zeros: 8080 |
float | Finite decimal number, parsed independently of locale. | min, max | number | shortest round-trip decimal: 0.5 |
bool | true or false, case-insensitive. | — | bool | true / false |
duration | A length of time, written in Go syntax on the platform side. | min, max (durations), encoding | Go-syntax duration: 1m30s | per encoding: go, iso8601, seconds or timespan |
url | An absolute URL with a scheme. | schemes, maxLength (the URL as it is) | string with scheme:// | as is |
enum | One of a fixed set of strings. | values (non-empty) | one of values | as is |
list | A list of strings or integers. | items (string | int), encoding, separator, minItems, maxItems, itemMin and itemMax (int items), itemMinLength and itemMaxLength (string items, after splitting) | list | per encoding: csv, json or indexed |
json | A structured value, checked against a JSON Schema generated from the app’s own type. | schema (JSON Schema), maxLength (the wire string) | any JSON value | compact JSON |
patternis RE2 and matches anywhere in the value; anchor it with^…$to match the whole value. RE2's\d,\w,\sand\bare ASCII-only.jsonvariables andconfigfiles carry a JSON Schema generated from the app's own type, so the platform checks structured values against the type the app deserializes into.- An empty string is a value for
string. For every other type it means unset.
Length limits
- Lengths count characters, meaning Unicode code points, never bytes or UTF-16 code units:
日本is 2 characters, andZÜ01fits anitemMaxLengthof 4. CUE'sstrings.MaxRunesand JSON Schema'smaxLengthcount the same way, so the platform and every SDK agree. minLengthandmaxLengthbound astring, andmaxLengthalso aurl(the URL as it is) or ajsonvalue.maxLengthon ajsonvalue bounds its wire string: before deploy, the compact JSON the renderer writes; at boot, the raw value the app receives, whitespace included, before it is parsed. The Helm values schema cannot express it, sodocuconf vetand the SDK check it.itemMinLengthanditemMaxLengthbound each item of astringlist, after the list is split, so acsvseparator never counts. They are not allowed on anintlist, asitemMinanditemMaxare not allowed on astringlist.- A value outside its limits is
out_of_rangeat boot. A too-long secret is reported by its length, never its value. - An app that stores values in fixed-width byte fields, such as a COBOL
PIC X(n), should declare a limit that leaves room for multi-byte characters, or restrict values to ASCII with apatternsuch as^[ -~]*$.
Fields on every variable
| Field | Rule |
|---|---|
type | One of the variable types. The set is closed in v1alpha1. |
description | Required. Plain text, at least 5 characters: what the input is, in one phrase. Rendered into generated docs. |
details | Optional. CommonMark: why the input exists and when to change it. Not blank, at most 4000 characters. Used only in generated docs, never at runtime. |
required | Default false. A required variable cannot have a default. |
default | Must satisfy the variable’s own constraints (checked at declaration time). |
secret | The value must come from a secretKeyRef. No default, no examples, never printed. |
group | Free-form grouping for docs. |
examples | Example values, as strings, for docs. |
deprecated | {message, replacedBy?}. SDKs warn at boot when it is set. |
configKey | The app’s own config key (Orders:Timeout, orders.timeout), for docs. |
Value sources
A value is usually a literal. Some only exist in the cluster or are supplied at runtime, so the platform may give a reference instead:
| Source | Example | Allowed for | Checked | Status |
|---|---|---|---|---|
| literal | PORT: 9090 | every non-secret type | before deploy | Implemented |
| configMapKeyRef | {configMapKeyRef: {name: "limits", key: "rate"}} | every non-secret type except list | at boot | Implemented |
| fieldRef (Downward API) | {fieldRef: {fieldPath: "metadata.namespace"}} | string | always valid | Implemented |
| resourceFieldRef | {resourceFieldRef: {resource: "limits.memory"}} | int | always valid | Implemented |
| secretKeyRef | {secretKeyRef: {name: "db", key: "url"}} | secret variables (one of the two secret sources) | at boot | Implemented |
| injected, with a reference | {injected: {provider: "bank-vaults", ref: "vault:secret/data/db#url"}} | every type, including secrets; rendered verbatim as the env value for the injector to resolve | reference shape before deploy; the value at boot | Implemented |
| injected, without a reference | {injected: {provider: "otel-operator"}} | every type, including secrets; not rendered: a webhook, init process or operator sets it | at boot | Implemented |
A secret variable is never a literal. It comes from a secretKeyRef or from an injector, and its content is checked only at boot. No docuconf tool ever prints a secret value.
Cloud-native injection
Many platforms do not put configuration into the pod spec at all. A mutating webhook, an init process or a wrapper resolves secrets at startup and hands the app a ready environment, or writes files the app reads. docuconf supports this by design:
- The SDK validates the process as it actually starts. Configuration is read from the real process environment and filesystem at startup, after any injector has run. A value from Bank-Vaults or
op runis checked exactly like one from the pod spec. - The contract does not care who supplies a value. It describes what the app accepts. The platform's values document says who supplies it.
- The platform declares injected inputs as
injected, naming the provider and, when the injector reads a reference from the env value, that reference.docuconf vetchecks the reference's shape, not the secret, and renders it verbatim for the injector to resolve.
# Platform values for orders-api
DATABASE_URL:
injected: {provider: bank-vaults, ref: "vault:secret/data/orders/db#url"}
OTEL_EXPORTER_OTLP_ENDPOINT:
injected: {provider: otel-operator} # set by the operator; not rendered
LOG_LEVEL: warn # an ordinary literal| Injector | What it does | How the contract describes it |
|---|---|---|
| Bank-Vaults (vault-env webhook) | Env values like vault:secret/data/db#url are resolved by vault-env, which then execs the app with real values. | injected value source with ref; the SDK sees and validates the resolved value. |
| Vault Agent injector | Writes secrets as files under /vault/secrets, rendered from templates. | injected file source, with the pod annotations that make the agent write the file at its declared path; the SDK checks the file at boot like any other. |
| External Secrets Operator | Syncs a cloud secret manager into a Kubernetes Secret. | secretKeyRef or secret file source, as usual; nothing special. |
| Secrets Store CSI driver | Mounts secrets from Vault, AWS, Azure or GCP as files. | csi file source. |
| 1Password, Doppler, Infisical and similar wrappers | A wrapper process (op run, doppler run) resolves references and execs the app. | injected value source, with the wrapper’s reference format as ref. |
| Operators and webhooks (OpenTelemetry, service meshes) | Add variables such as OTEL_EXPORTER_OTLP_ENDPOINT to the pod. | injected value source without ref, so the platform knows who supplies it, with the pod annotation or label that switches the injector on. |
SDKs validate injected values like any other, because they read the process environment at startup, after injection. If a secret still holds a reference (vault:, op://, ref+), the injector did not run, and the SDK fails with invalid_type, naming the variable and the reference scheme but never the value.
Enabling the injector
Most injectors are switched on by annotations or labels on the pod. An injected source, for a variable or a file, may carry them as podAnnotations and podLabels, maps of strings. The values document may also have its own podAnnotations and podLabels, beside the variables, for settings every injected input shares, such as the Vault role. The contract does not change: which injector runs is a fact about the cluster.
- Placeholders let an annotation that names one input be written once, beside it:
{input}is the variable's or file's name, and for a file{path}is its path,{dir}the directory it occupies and{file}its file name. Nothing else is expanded, so a Vault Agent template's{{ .Data }}stays as it is. A placeholder the source does not define ({path}on a variable, any placeholder in the shared maps) is anundefinedPlaceholdererror. - Rendering.
docuconf rendermerges the shared maps and every injected input into two maps,podAnnotationsandpodLabels, for the pod template's metadata: webhooks see pods, not Deployments. In Helm, the library chart'sdocuconf.podAnnotationsanddocuconf.podLabelshelpers return the same maps. Keys must be Kubernetes qualified names and label values valid label values, checked after expansion. - Conflicts. The same key with the same value from several inputs is fine. The same key with different values is a
conflictingPodAnnotationorconflictingPodLabelerror naming the key and both sources, indocuconf vet, in the render and in the Helm helpers. - Not secret. Anyone who can read the pod can read its annotations, so they hold references and settings (a Vault path, a role, a template), never secret material.
The Vault Agent injector writing a secret config file db-creds, declared at /vault/secrets/db.json:
# values.yaml
podAnnotations: # shared: one agent per pod, one role
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: ledger
# files.yaml
db-creds:
injected:
provider: vault-agent
podAnnotations:
vault.hashicorp.com/agent-inject-secret-{input}: database/creds/ledger
vault.hashicorp.com/agent-inject-template-{input}: '{{- with secret "database/creds/ledger" -}}{{ .Data | toJSON }}{{- end }}'
vault.hashicorp.com/secret-volume-path-{input}: "{dir}"
vault.hashicorp.com/agent-inject-file-{input}: "{file}"renders the two shared annotations plus agent-inject-secret-db-creds, agent-inject-template-db-creds, secret-volume-path-db-creds: /vault/secrets and agent-inject-file-db-creds: db.json, so the agent writes the file exactly at the declared path. Values are strings: write "true", not true. See SPEC.md §4.5.2 for Bank-Vaults, the OpenTelemetry operator and the edge cases.
Files
files is a map keyed by input name, a DNS label because it names a volume.
File types
| Type | Content | Constraint fields | Secret |
|---|---|---|---|
config | A structured config file in format json, yaml or toml. | format, schema (JSON Schema generated from the type the app binds the file to) | optional |
tls | A key pair in the kubernetes.io/tls layout: tls.crt, tls.key, and ca.crt when requireCA is set. | dnsNames, keyAlgorithms (RSA, ECDSA, Ed25519), minRemaining, requireCA | always |
caBundle | One or more PEM CA certificates. | minCertificates (default 1) | optional |
keystore | A PKCS#12 or JKS keystore. | format, passwordVar (a declared secret variable) | always |
text | A text file, such as a licence key. | pattern (RE2), minLength, maxLength | optional |
binary | Opaque bytes, such as a GeoIP database. | maxSize only | optional |
Fields on every file input
| Field | Rule |
|---|---|
type | One of the file types. |
description, details, required, group, deprecated | As for variables. |
secret | Content must come from a secret store. Forced true for tls and keystore. |
path | Where the app reads it: a directory for tls, a file otherwise. Absolute. |
pathEnv | A variable the platform sets to path (SSL_CERT_FILE). Not also declared in vars. |
reload | restart (default): a changed source rolls the pods. watch: the app reloads it. |
maxSize | Upper bound in bytes. |
Mount rules, enforced by the meta-schema: a file is mounted at its parent directory, and a TLS pair at its own directory; no two inputs share a directory; none is mounted over a reserved directory such as /, /etc, /etc/ssl/certs, /usr, /var or /app; files are projected with items, never subPath, so rotation reaches them; secret files are mode 0400, others 0444.
File sources
| Source | For | Checked before deploy | Status |
|---|---|---|---|
| inline | non-secret files | Everything: format, schema, pattern, size, certificate count. Rendered as an immutable, content-hashed ConfigMap. Config files may be given as structured data. | Implemented |
| configMap | non-secret files | That a key is given for single files. | Implemented |
| secret | any | Key presence; with resolved metadata, the Secret’s type (kubernetes.io/tls) and its keys. | Implemented |
| certificate (cert-manager) | tls | From the Certificate spec: covers dnsNames, uses an allowed key algorithm, renewBefore ≥ minRemaining. | Implemented |
| csi (Secrets Store CSI driver) | any | Nothing; checked at boot. | Implemented |
| image (image volume) | non-secret files | Nothing; for data above the 1 MiB ConfigMap limit. | Implemented |
| injected (Vault Agent and similar) | any | That the injector is named, and its pod annotations and labels; no volume is rendered, the injector writes the file at path. Checked at boot. | Implemented |
Resolved fields — a Secret's type and keys, a Certificate's spec — are metadata the platform tooling reads from the cluster. They are never secret contents. When absent, those checks move to boot.
Rotation. reload: watch means the app rereads the file, so the platform does nothing. reload: restart makes the platform roll the pods when the source changes. Inline content is content-hashed, so it always rolls the pods.
Config-file overlays
Hosts such as .NET, Spring Boot and Rails layer configuration files under environment variables. A common cloud-native pattern keeps the app's baked-in files and adds one more file, mounted by the platform, between them and the environment:
var builder = WebApplication.CreateBuilder(args);
// 1. Baked into the image: defaults and per-environment values.
builder.Configuration.AddJsonFile("appsettings.json", optional: false);
// 2. Mounted by the platform from a ConfigMap, reloaded on change.
builder.Configuration.AddJsonFile("/app/config/appsettings.Production.json", optional: true, reloadOnChange: true);
// 3. Environment variables (including injected secrets) win.
builder.Configuration.AddEnvironmentVariables();A contract describes the overlay, and the platform renders it:
overlays: platform: {
format: "json"
path: "/app/config/appsettings.Production.json"
reload: "watch" // reloadOnChange: true
}- Precedence is fixed and the same on every host: baked-in base file, then profile file, then the platform overlay, then environment variables.
- The platform routes variables to the overlay in its values document.
docuconf renderwrites each value at the variable'sconfigKey(Orders:CheckoutTimeoutbecomes{"Orders": {"CheckoutTimeout": …}}) in native types — numbers, booleans, arrays — rather than env-var strings, and ships the file as a ConfigMap mounted atpath. - Validation is identical: overlay values are typed values checked by
#Validate, the same as env values. Secrets never go in an overlay, because overlays are ConfigMaps. - Overlays add, never replace. The overlay sits at its own path, so the app's baked-in
appsettings.Production.jsonand its values still apply. Mounting over the baked-in file would silently discard them, and the meta-schema rejects an overlay path that is also a baked-in config file. reload: watchmaps to the host's reload-on-change, so the app sees new values without a restart (in .NET, throughIOptionsMonitor<T>).restartrolls the pods instead.
| Host | Baked-in files | Platform overlay | Reload |
|---|---|---|---|
| .NET (Microsoft.Extensions.Configuration) | appsettings.json, appsettings.{Environment}.json | AddJsonFile("/app/config/appsettings.Production.json", optional: true, reloadOnChange: true) | IOptionsMonitor<T> sees changes; IOptions<T> needs a restart |
| Spring Boot | application.yml, application-{profile}.yml | spring.config.additional-location=file:/app/config/ | restart, or Spring Cloud refresh |
| Rails (anyway_config) | config/<name>.yml | an extra YAML file read before env | restart |
| Rust (figment) | Config.toml with profiles | Toml::file("/app/config/Config.toml") merged before Env | restart |
| Kotlin (Hoplite) | application.conf / .yaml | an extra property source ahead of the defaults | restart, or Hoplite reloadable config |
docuconf render and the Helm library chart write the overlay's ConfigMap, volume and mount. The SDKs for hosts that layer files load it in the right place; see the SDK table.
Wire encodings
Environment values are strings. The platform always holds typed values — lists as lists, durations in Go syntax — and the renderer writes each one in the form the app's library parses. For most types every library agrees. Lists and durations differ, so the contract records the app's encoding:
| List encoding | Wire form | Native to |
|---|---|---|
csv (default) | a,b joined by separator | caarlos0/env, Spring Boot, anyway_config |
json | ["a","b"] | pydantic-settings |
indexed | NAME__0=a, NAME__1=b | Microsoft.Extensions.Configuration |
| Duration encoding | Wire form for 90s | Native to |
|---|---|---|
go (default) | 1m30s | Go time.ParseDuration |
iso8601 | PT1M30S | pydantic timedelta, ActiveSupport::Duration, java.time.Duration (Spring) |
seconds | 90 | anything that takes a number |
timespan | 00:01:30 | .NET TimeSpan.Parse |
Renderers double every $ in a literal ($ becomes $$), because Kubernetes expands $(NAME) in env values. Platform authors never see encodings: they write "90s" and ["a", "b"] for every app.
Profiles
Values in files the image ships with are part of what the app runs with, so the contract includes them. A value in an always-loaded base file (appsettings.json, application.yml) is a default. A value in a profile file (appsettings.Staging.json) goes in profiles.defaults, applied when the selector variable (ASPNETCORE_ENVIRONMENT, SPRING_PROFILES_ACTIVE, RAILS_ENV) selects that profile. A required variable is satisfied when the platform or the selected profile supplies it. A secret may never have a value in any file.