vars.yaml schema
vars.yaml is the manifest every service ships. It declares every environment variable the service expects at runtime, where the value should come from, and what should happen if it's missing. fetch-env.sh reads this file on every restart and resolves each entry to a concrete value before the containers start.
The manifest lives at /opt/services/<service>/vars.yaml after fetch-config syncs the bundle.
Top-level shape
service: api
variables:
- name: ENVIRONMENT
source: local
required: true
- name: REDIS_HOST
required: true
containers: [telapi]
- name: REDIS_PASSWORD
required: true
sensitive: true
containers: [telapi]
- name: LOG_LEVEL
default: 'info'
containers: [telapi]
| Top-level field | Type | Description |
|---|---|---|
service | string | The service identifier. Combined with NAMESPACE it forms the SSM and Secrets Manager paths (/<NS>/<service>/...). |
variables | list of object | Variable declarations (see below). |
Variable fields
| Field | Type | Description |
|---|---|---|
name | string | Environment variable name (required). |
required | bool | Unconditionally required. Deployment fails (fetch-env.sh aborts) if missing from every source. |
required_when | string | Conditionally required — format OTHER_VAR=value. Example: required_when: AUTH_WITH_MICROSOFT=true makes the variable required only when AUTH_WITH_MICROSOFT resolves to true. |
default | string | Default value when not provided via SSM / SM / bootstrap. |
sensitive | bool | Documents intent. Sensitive variables are expected to live in Secrets Manager. Seed scripts and provisioning workflows use this flag to decide whether to create an SSM parameter or a SM secret. fetch-env.sh itself does not branch on this flag. |
source | string | local = comes from the bootstrap /opt/deployment/.env, not fetched from SSM / SM. Default (omitted) = remote. |
scope | string | init = consumed by init.sh on the host, not passed to containers. Documentation hint; not enforced. |
containers | list | Which docker-compose.yaml services should receive this variable. Documentation hint used by show-env.sh and seed tooling; the actual injection is via ${VAR} substitution in docker-compose.yaml. |
secret | object | Value comes from an existing Secrets Manager secret (e.g. an RDS master user secret) rather than from this service's auto-discovered SM bundle. See external-secret pattern below. |
compose | object | The value is built at runtime from a template after every other variable is resolved. See composition pattern below. |
fetch-env.sh is permissive about unknown fields — comments and reminder strings can sit alongside the schema fields.
Worked examples
Bootstrap-only variable
Set by cloud-init, never fetched from SSM / SM:
- name: ENVIRONMENT
source: local
required: true
- name: HOSTNAME
source: local
required: true
- name: ECR_REGISTRY
source: local
required: true
fetch-env.sh reads these from /opt/deployment/.env. Mark as source: local whenever a value must come from the host — otherwise SSM (when populated) wins after eval $ENV_EXPORTS, which is a footgun for things like NAMESPACE itself.
Non-sensitive variable from SSM
- name: REDIS_HOST
required: true
containers: [telapi]
- name: LOG_LEVEL
default: 'info'
containers: [telapi]
fetch-env.sh looks up /${NAMESPACE}/${SERVICE}/REDIS_HOST in SSM. If absent and default: is set, the default is used; otherwise (with required: true) the script aborts.
Sensitive variable from Secrets Manager
- name: REDIS_PASSWORD
required: true
sensitive: true
containers: [telapi]
fetch-env.sh calls secretsmanager:list-secrets filtered by name-prefix ${NAMESPACE}/${SERVICE}/, fetches every match, parses each as JSON, and merges its keys. REDIS_PASSWORD is expected to be a top-level key in one of those JSON objects (typically ${NAMESPACE}/<service>/secrets).
Customers can split secrets however they like — fetch-env.sh discovers everything under the prefix:
# Single secret (default pattern):
voiceai/staging/api/secrets -> { "DATABASE_URL": "...", "REDIS_PASSWORD": "...", "JWT_SECRET": "..." }
# Multiple secrets (alternative pattern):
voiceai/staging/api/db-creds -> { "DATABASE_URL": "..." }
voiceai/staging/api/cache-creds -> { "REDIS_PASSWORD": "..." }
voiceai/staging/api/auth-creds -> { "JWT_SECRET": "..." }
Conditional requirement
- name: AUTH_WITH_MICROSOFT
default: 'false'
containers: [telweb]
- name: MICROSOFT_ENTRA_ID_CLIENT_ID
required_when: AUTH_WITH_MICROSOFT=true
containers: [telweb]
- name: MICROSOFT_ENTRA_ID_CLIENT_SECRET
required_when: AUTH_WITH_MICROSOFT=true
sensitive: true
containers: [telweb]
The two Microsoft variables only have to be set when the toggle is on. Otherwise fetch-env.sh skips the requirement check.
External-secret pattern
Use when the credential is not in ${NAMESPACE}/${SERVICE}/secrets but in another secret AWS owns — typically the JSON secret RDS creates for the cluster master user. The bootstrap places the ARN of that secret in /opt/deployment/.env:
- name: DB_MASTER_SECRET_ARN
source: local
required: true
- name: DATABASE_PASSWORD
required: true
sensitive: true
containers: [telapi]
secret:
arn_from: DB_MASTER_SECRET_ARN
type: json
key: password
- name: DATABASE_USERNAME
required: true
sensitive: true
containers: [telapi]
secret:
arn_from: DB_MASTER_SECRET_ARN
type: json
key: username
fetch-env.sh calls aws secretsmanager get-secret-value --secret-id $DB_MASTER_SECRET_ARN, parses the result as JSON, and pulls the named key. Set secret.type: plaintext when the secret is a single string instead of JSON.
Seed tooling skips SSM / SM creation for secret.arn_from entries — there is no per-service placeholder to populate. See Managed database secrets for the full pattern including a composed DATABASE_URL.
Composition pattern
Apps usually expect a single DATABASE_URL rather than separate parts. After every other variable has resolved, fetch-env.sh runs compose.template substitutions:
- name: DATABASE_HOST
required: true
containers: [telapi]
- name: DATABASE_PORT
default: '5432'
containers: [telapi]
- name: DATABASE_NAME
required: true
containers: [telapi]
- name: DATABASE_URL
required: true
sensitive: true
containers: [telapi]
compose:
template: 'postgresql://${DATABASE_USERNAME}:${DATABASE_PASSWORD}@${DATABASE_HOST}:${DATABASE_PORT}/${DATABASE_NAME}'
encode:
- DATABASE_PASSWORD
- DATABASE_USERNAME
compose.encode is the list of variable names to URL-encode before substitution (@, :, &, etc. in passwords and usernames are common). Seed tooling does not create an SSM / SM entry for composed variables — they exist only at runtime, after fetch-env.sh runs.
Validation
fetch-env.sh enforces:
- Every
required: truevariable resolved to a non-empty value, or - Every
required_when: VAR=valuewhose condition is satisfied resolved to a non-empty value.
Failure prints:
FATAL: Required variables missing from SSM/SM/bootstrap:
- DATABASE_URL
- REDIS_PASSWORD (required when REDIS_TLS_ENABLED=true)
…and exits non-zero, which aborts the calling init.sh before any container starts.
Conventions across the platform
- Secret bundle naming — use
${NAMESPACE}/<service>/secretsfor the per-service secret with all sensitive keys. Multiple secrets are supported but harder to audit. source: localis for instance / infrastructure facts —ENVIRONMENT,HOSTNAME,NAMESPACE,ECR_REGISTRY,ECR_TAG,AWS_REGION, AWS keys (when not using instance profile),BASTION_PUBLIC_KEY, the optionalDB_MASTER_SECRET_ARN. Everything else lives in SSM / SM.- Defaults are for sane fallbacks, not real values — production
LOG_LEVELbelongs in SSM, not as adefault:. Defaults exist so a developer running the same compose file locally can omit unimportant variables. containers:is documentation, not enforcement — the actual variable injection comes from${VAR}substitution indocker-compose.yaml. Keepcontainers:accurate soshow-env.shand seed tooling line up.
See also
- Environment resolution — the full resolution algorithm and namespace conventions.
- Managed database secrets — concrete RDS-style example.
- init.sh and update.sh — what calls
fetch-env.shand when.