X Xerobit

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...

Mian Ali Khalid · · 5 min read
Use the tool
YAML ↔ JSON Converter
Convert between YAML and JSON formats with full fidelity.
Open YAML ↔ JSON Converter →

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.

// 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 posts

Related tool

YAML ↔ JSON Converter

Convert between YAML and JSON formats with full fidelity.

Written by Mian Ali Khalid. Part of the Data & Format pillar.