Skip to content
Pass application configuration via Fairway

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_URL go through LabKit v2/secret (delivered via spec.values.secretEnvFrom or secretVolumes), never through configuration.
  • Infrastructure wiring. Connection details for declared dependencies (postgresql, redis, …) belong to spec.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-&lt;name&gt;"]
    cm --> mount["/etc/&lt;name&gt;/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:8080

Fairway 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 single config.yaml key;
  • 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_DIR is reserved — fairway validate rejects a manifest that also sets it under spec.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/&lt;name&gt;"] --> 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.data yet — 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

Last updated on