Pass application configuration via Fairway
This guide shows how to get service-specific configuration — endpoints, tuning
knobs, feature-flag namespaces — into your service in every environment without
rebuilding images, using Fairway’s
spec.values.appConfig
mechanism. The
go-service-template exemplar
implements everything below, so you can read this guide against working code.
Two things appConfig is not for:
- Secrets. Credentials such as
DATABASE_URLgo through LabKitv2/secret(delivered viaspec.values.secretEnvFromorsecretVolumes), never through configuration. - Infrastructure wiring. Connection details for declared dependencies
(
postgresql,redis, …) belong tospec.values.infrastructure, which the chart mounts separately at/etc/labkit/infrastructure.yaml.
How it flows
You declare defaults once, in the manifest. Every deployment target overrides the same tree with its own values; the contract inside the container never changes:
flowchart LR
manifest["fairway.yaml<br/>spec.values.appConfig.data"] --> chart["Generated chart<br/>(defaults in values.yaml)"]
chart --> override["Per-environment<br/>values override"]
override --> cm["ConfigMap<br/>appconfig-<name>"]
cm --> mount["/etc/<name>/config.yaml<br/>CONFIG_DIR set on container"]
mount --> svc["Service loads<br/>$CONFIG_DIR/config.yaml"]
Declare the defaults in your manifest
Put the config tree under spec.values.appConfig.data. Use snake_case keys —
LabKit-based services conventionally require it:
# fairway.yaml
spec:
values:
appConfig:
data:
version: 1
name: go-service-template
server:
port: 4000
feature_flags:
namespace: theseus-go-service
endpoint: http://flipt:8080Fairway treats this tree as opaque: arbitrary nesting, any JSON-compatible value type, keys preserved verbatim.
From this, the generated chart:
- renders the tree into a ConfigMap named
appconfig-<manifest-name>with a singleconfig.yamlkey; - mounts it read-only into the main container at
/etc/<manifest-name>/config.yaml; - sets
CONFIG_DIR=/etc/<manifest-name>on the container so your service never hard-codes the path.CONFIG_DIRis reserved —fairway validaterejects a manifest that also sets it underspec.values.environment.
Read it in your service
Resolve the config file with CONFIG_DIR taking precedence, then load it with
LabKit v2/config
so the file is schema-validated at startup:
// Precedence: CONFIG_DIR (set by the fairway-generated chart) over
// CONFIG_PATH (non-chart deployments) over the bare-host local profile.
func resolveConfigPath() string {
if dir := os.Getenv("CONFIG_DIR"); dir != "" {
return filepath.Join(dir, "config.yaml")
}
if path := os.Getenv("CONFIG_PATH"); path != "" {
return path
}
return "config/local.yaml"
}Fairway does not validate the shape of appConfig.data against your schema, and
neither does Helm — a typo would otherwise surface at pod startup. Close the gap
in CI with a strict-mode test that extracts the tree from fairway.yaml and
loads it against your config proto; the exemplar’s
appconfig_test.go
is a copy-paste-ready pattern.
How each environment gets the config
Workstation (bare host)
No chart, no cluster: neither CONFIG_DIR nor CONFIG_PATH is set, so the
service falls back to the local profile committed in the repository.
flowchart LR
file["config/local.yaml<br/>(committed profile)"] --> svc["go run ./cmd/server"]
Caproni (local Kubernetes)
The Fairway-generated chart delivers the ConfigMap itself — no lifecycle-hook
glue. Local tweaks go in your Caproni values file under appConfig.data; Helm
deep-merges maps, so you override only the keys you need:
# caproni/<service>-values.yaml
appConfig:
data:
feature_flags:
endpoint: http://flipt:8080
flowchart LR
manifest["fairway.yaml<br/>appConfig defaults"] --> merge["Helm deep-merge"]
localvals["Caproni values file<br/>appConfig.data overrides"] --> merge
merge --> cm["appconfig ConfigMap"] --> pod["Pod<br/>CONFIG_DIR=/etc/<name>"] --> svc["Service"]
The service reads config once at startup, and the chart does not emit a
config-checksum annotation, so restart the pod (or your air process in edit
mode) to pick up changes.
Runway (Cloud Run, today)
The current Runway runtime is Cloud Run, which has no ConfigMaps — the chart is
not involved. The profile is baked into the image and selected with
CONFIG_PATH in .runway/env-*.yml. This is why CONFIG_PATH stays in the
resolution chain:
flowchart LR
profile["config/runway.yaml<br/>(baked into the image)"] --> env["CONFIG_PATH=/config/runway.yaml<br/>(.runway/env-*.yml)"] --> svc["Service"]
Cells and Dedicated (forward-looking)
This section describes the direction set by the Theseus Platform Vision; the Cells and Dedicated Platform Binding is in active development.
On the Cells and Dedicated binding, the same Fairway chart ships unchanged. The
per-cell configuration comes from the tenant model: Instrumentor drives the
Theseus Configuration COM module, which materialises each cell’s
appConfig.data overrides as Helm values, and the release orchestrator installs
the chart with them. Inside the pod, the contract is identical to Caproni —
the same ConfigMap, the same mount, the same CONFIG_DIR:
flowchart LR
tm["Tenant Model"] --> amp["AMP"] --> inst["Instrumentor"]
inst --> ccom["Theseus Configuration<br/>COM module"]
ccom --> values["Per-cell Helm values<br/>(appConfig.data overrides)"]
values --> argo["Argo releases"] --> chart["Fairway chart"]
chart --> cm["appconfig ConfigMap<br/>in the Cell"] --> svc["Service<br/>CONFIG_DIR contract"]
A service built against CONFIG_DIR today needs no changes to run on Cells:
only the source of the values differs. See the
target matrix
for how the bindings compare.
Caveats
- No schema validation of
appConfig.datayet — guard it with the CI test pattern above; a schema-declaration mechanism is planned upstream. - Deployment-only — CronJobs do not inherit the ConfigMap, the mount, or
CONFIG_DIR; per-CronJob appConfig is a follow-up Fairway primitive. - Restart to reload — config is read at startup; a ConfigMap update alone does not restart pods.
Related reading
- Fairway service-owner guide — the full
appConfigreference, including the two-layer (generator-time vs deploy-time) model values.proto— authoritative schema for everyspec.values.*field- LabKit
v2/config— protobuf-first config loading with protovalidate and strict mode - Go service template exemplar — all of the above as working code
- Platform bindings — why the same declaration resolves differently per target