YAML ↔ JSON for Docker Compose Without Silent Tab Traps
You copy a service block from docker-compose.yml into a script that expects JSON for the Docker Engine API. Ten minutes later jq fails on line 1, or worse — it succeeds and drops a comment that explained why mem_limit was set. Conversion is not a party trick; it is a lossy projection between two data languages that look related.
Where Compose YAML bites JSON
| YAML feature | JSON fate |
|---|---|
# comments | Gone |
Anchors / aliases (&, *) | Must be expanded first |
| Multiline ` | />` scalars |
Unquoted NO / off | May become booleans (YAML 1.1 traps) |
| Tabs for indent | Invalid YAML; editors hide this |
JSON has no comments. Teams that “temporarily” convert to JSON for an internal tool permanently lose the institutional knowledge in those comments unless they keep the YAML as source of truth.
Boolean footgun: in older YAML 1.1, country: NO (Norway) becomes false. Quote values that look like booleans or numbers when they are identifiers.
A safe conversion loop
- Validate YAML indentation (spaces only) in the editor.
- Convert with a library that reports line/column on parse errors.
- Expand anchors if present (
docker compose configcan render a resolved view). - Run
jq/ schema checks on the JSON. - Diff semantically (not only textually) against the original intent.
For Compose specifically, prefer docker compose config to normalize merges and extension fields before you hand-edit JSON. That catches duplicate keys and invalid interpolations early.
Engine API vs Compose file
The Compose specification file is YAML-first. The Engine HTTP API wants JSON bodies for containers and networks. Those schemas are related but not identical field-for-field. Blindly converting a full compose.yml and POSTing it will fail. Convert snippets you understand, or use Compose to deploy and reserve JSON for small docker run-equivalent payloads.
# compose-ish fragment
services:
api:
image: "ghcr.io/acme/api:1.4"
environment:
LOG_LEVEL: "info"
{
"services": {
"api": {
"image": "ghcr.io/acme/api:1.4",
"environment": {
"LOG_LEVEL": "info"
}
}
}
}
Looks trivial until environment values include : or #, or until someone pastes Windows paths with backslashes into JSON strings without escaping.
Tooling tips
Use a local YAML ↔ JSON converter when you need a quick round-trip without installing Python. For CI, pin a known parser (PyYAML, go-yaml, js-yaml) so developer laptops and pipelines agree. Reject tabs in pre-commit. Prefer quoting strings that are not obviously numbers.
When reviewing PRs, ask: is JSON the canonical format for this artifact, or a derivative? Derivatives should be generated, not hand-maintained in parallel. Parallel hand edits are how ports mappings drift and only one environment breaks.
Numbers, nulls, and empty strings
YAML may load PORT: 8080 as an integer while your JSON schema expects a string for environment maps. Compose often stringifies env values; a raw JSON blob for another tool might not. Empty values (KEY: vs KEY: "" vs KEY: null) diverge across parsers. After conversion, assert types for fields you know are sensitive — ports, booleans, and replica counts especially.
Unicode and emoji in labels survive most modern parsers but surprise older Java libraries. If your pipeline crosses JVM and Go, round-trip a fixture in CI that includes non-ASCII service names and comment-like characters inside strings.
Team workflow
Keep a golden compose.yaml as source. Generate JSON only in job steps that need it. Commit generated JSON only when a downstream system cannot read YAML and you need reproducible artifacts — then regenerate in CI so humans never edit the derivative by hand. Code owners for infra files should reject PRs that “fix a typo” only in the JSON copy.
YAML and JSON share curly-brace ancestry in hallway conversations. In production they share data only after you accept what conversion throws away — and you check the error line before the pipeline does.