Skip to content
Modify the values and secrets passed to your service

Modify the values and secrets passed to your service

Your service takes two kinds of runtime input besides its code: deploy-time values (non-secret knobs — resources, environment variables, application config) and secrets (credentials, tokens, keys). This guide gives practical examples of modifying both. None of them require rebuilding an image.

The split matters: values are versioned in your repository and reviewed like code; secrets live in Vault and never touch git. Never put a secret in spec.values — including appConfig.

Modifying values

Everything under spec.values in your Fairway manifest becomes the default content of the generated chart’s values.yaml, and every environment can override it at deploy time. (For the generator-time vs deploy-time distinction, see the Fairway service-owner guide.)

Change a default for every environment

Edit spec.values in fairway.yaml — for example, adding an environment variable and raising the memory limit:

# fairway.yaml
spec:
  values:
    environment:
      HTTP_CLIENT_TIMEOUT: "10s"
    resources:
      requests:
        cpu: 50m
        memory: 64Mi
      limits:
        cpu: 500m
        memory: 512Mi

The next chart release carries the new defaults to every environment that deploys it. Validate before pushing:

fairway validate fairway.yaml

Override locally (Caproni)

Your Caproni values file overrides just the keys you need — Helm deep-merges maps, so a partial tree is enough:

# caproni/<service>-values.yaml
resources:
  limits:
    memory: 1Gi        # profiling locally; don't change the shipped default
appConfig:
  data:
    feature_flags:
      endpoint: http://flipt:8080   # local flag backend

Redeploy (caproni up) to re-render; restart the pod to reload application config.

Override at deploy time (operators)

The published chart is a plain OCI Helm artifact, so any environment’s operator can override values at install time:

helm install my-service \
  oci://registry.gitlab.com/<group>/<project>/chart --version 1.4.0 \
  --values production-overrides.yaml

Each platform binding has its own surface for supplying these overrides — Caproni uses the values file above, and on Cells the Configuration COM module materialises them from the tenant model — but they all converge on the same values.yaml schema.

Modifying secrets

Secrets are stored in Vault and synchronised into your workload’s runtime; your repository only ever names them:

    flowchart LR
    vault["Vault<br/>runway/env/&lt;env&gt;/service/&lt;id&gt;/&lt;name&gt;"] --> sync["Sync<br/>(GKE: External Secrets Operator<br/>EKS: provisioner → AWS Secrets Manager)"]
    sync --> ksecret["Kubernetes Secret"]
    ksecret --> container["Container<br/>(env vars via secretEnvFrom,<br/>files via secretVolumes)"]
  

Prerequisite: managing secrets needs Vault access — an Okta access request plus a Vault policy MR. The Okta step can take around two weeks, so start early. The Runway secrets-management guide is the authoritative walkthrough; the examples below connect it to the Theseus workflow.

Add a secret

  1. In Vault, navigate to runway/env/<environment>/service/<runway_service_id> (environment is staging or production) and create a secret — for example env-vars — with one key per value you need:

    API_KEY: "your-api-key-here"
    DATABASE_PASSWORD: "your-database-password"

    Keys become environment-variable names, so keep them uppercase with underscores.

  2. Declare the secret in your Runway workload config and expose it as environment variables:

    # .runway/<runway_service_id>/gke-service.yaml
    <runway_service_id>:
      workloadSecrets:
        - name: env-vars
      secretEnvFrom:
        - env-vars

Every key in env-vars arrives in the container as an environment variable. On GKE the sync is immediate (External Secrets Operator reads Vault directly); on EKS it waits for the provisioner’s Terraform cycle.

Mount a file-based secret

For credentials that must be files — service accounts, certificates — use secretVolumes instead:

# .runway/<runway_service_id>/gke-service.yaml
<runway_service_id>:
  workloadSecrets:
    - name: gcp-service-account
  secretVolumes:
    - name: gcp-service-account
      path: /secrets/gcp
  spec:
    environment:
      GOOGLE_APPLICATION_CREDENTIALS: /secrets/gcp/credentials.json

Each key in the Vault secret becomes a read-only file under the mount path — here, a credentials.json key appears at /secrets/gcp/credentials.json.

Rotate a secret

In Vault, open the secret and Create new version with the updated values — the path and your repository configuration don’t change. The new version syncs on the same schedule as above (immediately on GKE, next provisioner cycle on EKS).

If your service reads a mounted secret file, it can pick up rotation without a restart using LabKit’s rotating file provider:

p := secret.NewRotatingFileProvider("/secrets/db", "password", time.Minute)
if err := p.Start(ctx); err != nil {
	return err
}
defer p.Shutdown(ctx) //nolint:errcheck

go func() {
	for event := range p.OnRotate() {
		if event.Err != nil {
			logger.Warn("rotation poll error", labkitlog.Error(event.Err))
			continue
		}
		pool.Reconnect(event.NewValue.Value())
	}
}()

Environment-variable secrets are read at startup, so a rotation there needs a pod restart to take effect.

Provide a secret in local development (Caproni)

Locally there is no Vault sync — you create the Kubernetes Secret yourself and reference it the same way. This is exactly what the exemplar’s lifecycle hook does for DATABASE_URL:

kubectl -n <namespace> create secret generic my-service-secrets \
  --from-literal=DATABASE_URL="postgres://app:…@deps-postgresql:5432/app" \
  --dry-run=client -o yaml | kubectl apply -f -
# caproni/<service>-values.yaml
secretEnvFrom:
  - my-service-secrets

The container sees the same environment variables it would receive in production, so no code changes between environments.

Consume secrets with LabKit v2/secret

Keep secret access behind LabKit v2/secret rather than raw os.Getenv, so the backend can change without touching application code:

// Default client reads environment variables (secretEnvFrom).
secrets := secret.New()

dbURL, err := secrets.Get(ctx, "DATABASE_URL")
if err != nil {
	return fmt.Errorf("read DATABASE_URL: %w", err)
}
db, err := postgres.NewWithConfig(&postgres.Config{DSN: dbURL.Value()})

For volume-mounted secrets (secretVolumes), swap the provider — the calling code stays identical:

secrets := secret.NewWithConfig(&secret.Config{
	Provider: secret.NewFileProvider("/secrets/gcp"),
})

Values are wrapped in a Secret type whose String method redacts, so an accidental %v in a log line prints a placeholder, never the credential.

Related reading

Last updated on