Tutorial 3: Compose Configs¶
In Tutorial 2, you ran webmon from the command line with a single config file. But real projects don't stay that simple for long. You end up with a dev environment, a staging environment, a production environment, and they share 80% of the same config with a few differences: database host, check interval, who gets notified.
Copy-pasting configs and keeping them in sync is the road to subtle bugs. This tutorial shows how to layer configs instead.
Time: ~15 minutes.
The problem¶
Here's what you need for webmon across two environments:
| Setting | Dev | Prod |
|---|---|---|
| database.host | localhost | db.prod.internal |
| database.port | 5432 | 5432 |
| check_interval | 60 | 15 |
| notify_email | (empty) | ops@example.com |
| log_level | DEBUG | WARN |
Most of this is the same. The database port, the site list, the database name -- all shared. Only a handful of values change per environment.
File layout¶
Set up a config directory like this:
config/
base.yaml
env/
dev.yaml
prod.yaml
notifications/
slack.yaml
local-overrides.yaml # (optional, gitignored)
The base config¶
Start with the shared defaults:
# config/base.yaml
sites:
- https://example.com
- https://status.example.com
check_interval: 60
log_level: INFO
notify_email: ""
database:
host: localhost
port: 5432
name: webmon
password: ${getenv('WEBMON_DB_PASSWORD', 'dev-pass')}
Nothing fancy. This is the config that works for local development as-is.
Environment overrides¶
Now write the prod overlay. It only contains the values that differ:
# config/env/prod.yaml
check_interval: 15
log_level: WARN
notify_email: ops@example.com
database:
host: db.prod.internal
password: ${getenv('WEBMON_DB_PASSWORD')}
<<{>+}: !include file:$DIR/../base.yaml
The last line does the work. Let's unpack it:
<<:is the merge key. It says "merge another mapping into this one."!include file:$DIR/../base.yamlloads the base config.$DIRis always the directory of the current file, so this resolves toconfig/base.yamlregardless of where you invoke things from.{>+}is the merge strategy. The>means "the existing values (prod.yaml) win conflicts." The+means "merge dictionaries recursively instead of replacing them." So prod.yaml'sdatabase.hostoverrides the base, butdatabase.portanddatabase.nameare kept from the base.
Without the +, the entire database mapping in prod would replace the base one, and you'd lose port and name. With +, they merge field by field.
Tip
Think of {>+} as "I win, merge deep." You'll use this one a lot.
Loading multiple files¶
There are two ways to compose configs. The merge-key approach above (putting <<: inside the file itself) is one. The other is to pass multiple files when loading:
When you pass a list, files are merged left to right. Later files override earlier ones. This is equivalent to what the <<{>+}: line does, but controlled from the loading side instead of inside the YAML.
From the CLI, it's the same idea:
Both approaches work. The in-file merge key is better when a file always needs its base. The multi-file approach is better when the caller decides what to layer.
Including fragments¶
Say your notification settings are complex enough to warrant their own file:
# config/notifications/slack.yaml
slack:
webhook: ${getenv('SLACK_WEBHOOK_URL', '')}
channel: "#ops-alerts"
mention_on_failure: "@oncall"
Pull it into your prod config:
# config/env/prod.yaml
check_interval: 15
log_level: WARN
notify_email: ops@example.com
notifications: !include file:$DIR/../notifications/slack.yaml
database:
host: db.prod.internal
password: ${getenv('WEBMON_DB_PASSWORD')}
<<{>+}: !include file:$DIR/../base.yaml
!include file:... replaces itself with the contents of the included file. The $DIR variable always refers to the directory of the file that contains the !include, so relative paths work correctly even when files include each other across directories.
Optional includes¶
Not every developer on your team needs the same local tweaks. You can use !include? (note the ?) to include a file only if it exists:
# config/env/dev.yaml
<<{>+}: !include file:$DIR/../base.yaml
<<{<+}: !include? file:$DIR/../local-overrides.yaml
If local-overrides.yaml is there, it gets merged in. If it's missing, nothing happens, no error. This is good for things like personal debug settings or machine-specific paths that you .gitignore.
A typical local-overrides.yaml might look like:
# config/local-overrides.yaml (gitignored)
database:
host: 192.168.1.100
password: my-local-pass
log_level: DEBUG
The layering pattern¶
The general pattern for environment configs looks like this:
base.yaml -- shared defaults
env/dev.yaml -- dev overrides (merges base)
env/prod.yaml -- prod overrides (merges base)
env/staging.yaml -- staging overrides (merges base)
Each environment file includes the base with <<{>+}: and overrides only what it needs. When you add a new shared setting to base.yaml, every environment gets it automatically.
Precedence¶
When using @dracon_program and the CLI (Tutorial 2), values come from multiple sources. Here's the order, from lowest to highest priority:
| Priority | Source | Example |
|---|---|---|
| 1 (lowest) | Model field defaults | check_interval: int = 60 |
| 2 | Auto-discovered configs | config_files=[ConfigFile("~/.webmon.yaml")] |
| 3 | +file positional args |
+config/prod.yaml |
| 4 (highest) | --flag CLI overrides |
--check-interval 10 |
Higher priority sources override lower ones. So if your model defaults check_interval to 60, your config file sets it to 15, and you pass --check-interval 10 on the command line, you get 10.
When using dracon.load() directly (no CLI), it's simpler: you just get the result of merging the files you passed in, left to right.
Verifying composition¶
Before you wire things up, check what the final composed config looks like:
This loads both files, merges them, and prints the result as YAML. You'll see the merged output with all includes resolved.
Add -r to also resolve interpolations (like ${getenv(...)}):
Output (roughly):
sites:
- https://example.com
- https://status.example.com
check_interval: 15
log_level: WARN
notify_email: ops@example.com
notifications:
slack:
webhook: ''
channel: '#ops-alerts'
mention_on_failure: '@oncall'
database:
host: db.prod.internal
port: 5432
name: webmon
password: ''
This is your "what does prod actually look like?" sanity check.
Putting it together¶
Here's the full dev workflow:
- Edit
base.yamlfor shared settings. - Edit
env/prod.yaml(ordev.yaml,staging.yaml) for environment-specific overrides. - Run
dracon show config/base.yaml config/env/prod.yaml -rto check. - Run your app:
python webmon.py +config/env/prod.yaml
Or, if your environment files already include the base via <<{>+}:, you only need one file:
What you've learned¶
- Use
<<{>+}: !include file:$DIR/base.yamlto merge a base config into an override file, with recursive dict merging and existing values winning - Pass multiple files to
dracon.load(["base.yaml", "prod.yaml"])or+base.yaml +prod.yamlfor caller-controlled layering !include file:$DIR/...pulls in fragments;$DIRis always the current file's directory!include?silently skips missing files, good for optional local overrides- Precedence: model defaults < auto-discovered configs < +files < --flags
dracon show file1.yaml file2.yaml -rlets you inspect the composed result
Next up: Tutorial 4: Dynamic Configs, where you use variables, conditionals, and loops to generate config programmatically.