YAML Config Best Practices — Structure, Validation, and Environment Variables
YAML is the dominant format for configuration files in modern software. Here's how to structure YAML configs, validate them with schemas, handle environment variables, and...
YAML configuration files are everywhere: Docker Compose, Kubernetes manifests, GitHub Actions, Ansible playbooks, and application configs all use YAML. Getting the structure right from the start prevents future refactoring and parsing surprises.
Use the YAML to JSON Converter to validate and inspect YAML configs.
Config file structure
Environment-specific config
Split config into base + environment-specific overrides:
# config/base.yaml
app:
name: "My App"
version: "1.0.0"
database:
max_connections: 20
timeout: 30
logging:
level: info
format: json
# config/production.yaml
database:
host: "${DB_HOST}"
port: 5432
name: "${DB_NAME}"
max_connections: 100
logging:
level: warn
output: /var/log/app/app.log
# config/development.yaml
database:
host: localhost
port: 5432
name: myapp_dev
max_connections: 5
logging:
level: debug
format: text
This pattern allows deep merging where dev/production values override base values.
Environment variable substitution
YAML itself doesn’t support environment variable interpolation — that’s handled by the application loading the config. Common patterns:
Node.js (js-yaml + dotenv)
import yaml from 'js-yaml';
import fs from 'fs';
import dotenv from 'dotenv';
dotenv.config();
function loadConfig(path) {
const rawYaml = fs.readFileSync(path, 'utf8');
// Substitute ${ENV_VAR} patterns:
const interpolated = rawYaml.replace(
/\$\{([^}]+)\}/g,
(_, varName) => process.env[varName] ?? ''
);
return yaml.load(interpolated);
}
const config = loadConfig('./config/production.yaml');
console.log(config.database.host); // "db.prod.example.com"
Python (pyyaml + os.environ)
import yaml
import os
import re
def load_config(path):
with open(path) as f:
raw = f.read()
# Substitute ${ENV_VAR} patterns:
def replace_env(match):
var_name = match.group(1)
return os.environ.get(var_name, '')
interpolated = re.sub(r'\$\{([^}]+)\}', replace_env, raw)
return yaml.safe_load(interpolated)
config = load_config('config/production.yaml')
Schema validation for YAML configs
Unvalidated configs cause confusing runtime errors. Validate at startup:
JSON Schema (ajv)
import Ajv from 'ajv';
import yaml from 'js-yaml';
const configSchema = {
type: 'object',
required: ['database', 'app'],
properties: {
app: {
type: 'object',
required: ['name', 'port'],
properties: {
name: { type: 'string' },
port: { type: 'integer', minimum: 1, maximum: 65535 }
}
},
database: {
type: 'object',
required: ['host', 'port', 'name'],
properties: {
host: { type: 'string' },
port: { type: 'integer' },
name: { type: 'string' },
max_connections: { type: 'integer', minimum: 1 }
}
}
}
};
const ajv = new Ajv();
const validate = ajv.compile(configSchema);
function loadAndValidateConfig(path) {
const config = yaml.load(fs.readFileSync(path, 'utf8'));
const valid = validate(config);
if (!valid) {
const errors = validate.errors.map(e => `${e.instancePath} ${e.message}`);
throw new Error(`Config validation failed:\n${errors.join('\n')}`);
}
return config;
}
Python (pydantic)
from pydantic import BaseModel, validator
from typing import Optional
import yaml
class DatabaseConfig(BaseModel):
host: str
port: int = 5432
name: str
max_connections: int = 20
@validator('port')
def valid_port(cls, v):
if not 1 <= v <= 65535:
raise ValueError('Port must be 1-65535')
return v
class AppConfig(BaseModel):
name: str
port: int = 8080
debug: bool = False
class Config(BaseModel):
app: AppConfig
database: DatabaseConfig
logging: Optional[dict] = None
def load_config(path: str) -> Config:
with open(path) as f:
data = yaml.safe_load(f)
return Config(**data)
config = load_config('config.yaml')
print(config.database.host)
Kubernetes and Docker Compose patterns
Docker Compose with anchors
version: '3.8'
x-common-env: &common-env
NODE_ENV: production
LOG_LEVEL: info
x-healthcheck: &healthcheck
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
services:
api:
image: myapp:latest
environment:
<<: *common-env
PORT: 8080
<<: *healthcheck
ports:
- "8080:8080"
worker:
image: myapp:latest
environment:
<<: *common-env
PORT: 8081
<<: *healthcheck
command: worker
Kubernetes ConfigMap
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
namespace: production
data:
APP_PORT: "8080"
LOG_LEVEL: "info"
DATABASE_MAX_CONNECTIONS: "100"
config.yaml: |
app:
name: "My App"
port: 8080
logging:
level: info
ConfigMap stores config as key-value pairs (data) or full file content (config.yaml: |).
Common YAML config mistakes
Storing secrets in YAML files:
# NEVER DO THIS:
database:
password: "super_secret_password_123" # Don't commit to git!
# Do this instead:
database:
password: "${DB_PASSWORD}" # Load from environment
Boolean ambiguity:
# In YAML 1.1, these are all booleans:
feature_enabled: yes # true
feature_enabled: on # true
feature_enabled: true # true
# If you mean the string "yes", quote it:
answer: "yes"
status: "on"
Number-looking strings:
# These parse as numbers (if unquoted):
version: 1.0 # float 1.0
zip_code: 01234 # integer 668 (octal parsing in some parsers!)
phone: 555-1234 # parsed correctly as string (has hyphens)
# Quote version numbers:
version: "1.0"
zip_code: "01234"
Leading zeros (octal):
In YAML 1.1, 01234 parses as octal (= 668 decimal). In YAML 1.2 (newer), it’s an error. Always quote strings that start with leading zeros.
Config loading order (recommended)
// Recommended config loading priority (later overrides earlier):
// 1. Compiled defaults
// 2. Base config file
// 3. Environment-specific config file
// 4. Environment variables
// 5. Command-line arguments
import { mergeDeep } from './utils';
import yaml from 'js-yaml';
const defaults = { app: { port: 8080 } };
const base = yaml.load(fs.readFileSync('config/base.yaml', 'utf8'));
const env = yaml.load(fs.readFileSync(`config/${process.env.NODE_ENV}.yaml`, 'utf8'));
const envVars = { database: { host: process.env.DB_HOST } };
const config = mergeDeep(defaults, base, env, envVars);
Related tools
- YAML to JSON Converter — validate and convert YAML
- YAML Syntax Guide — YAML syntax reference
- YAML vs JSON — choosing the right format
Related posts
- YAML vs JSON: Which to Use When (and Why It Matters) — JSON is for machines, YAML is for humans, and choosing the wrong one quietly cos…
- YAML Anchors and Aliases — Reusing Values with & and * — YAML anchors (&) define a reusable value; aliases (*) reference it. This elimina…
- YAML in Kubernetes — Writing Kubernetes Manifests — Kubernetes configurations are YAML files called manifests. Here's how Kubernetes…
- YAML Syntax Guide — Indentation, Types, and Common Patterns — YAML syntax uses indentation to define structure. Here's how YAML handles scalar…
- YAML to JSON Converter — Convert YAML Configuration to JSON — YAML to JSON conversion is lossless for most data types. Here's how the conversi…
Related tool
Convert between YAML and JSON formats with full fidelity.
Written by Mian Ali Khalid. Part of the Data & Format pillar.