Docker Compose YAML File Explained
Duplicate keys, mixed env syntax, unquoted booleans - none of them throw an error. A structural walkthrough of docker-compose.yml with YAML anchors to cut repetition and the docker compose config habit that catches mistakes before they reach a running container.

Docker Compose YAML File Explained
Your docker-compose.yml ran without a single error. The stack came up clean, every container shows running. And one service is still pulling the wrong image tag - because a duplicated key further down the file silently overwrote the first one, and YAML never said a word about it.
services:is the only required top-level key in a compose file;networks:,volumes:,configs:andsecrets:are optional declarations people reach for before they need to, per the Compose file reference.- The syntax mistakes that actually bite - duplicate keys, mixed list/map env syntax, unquoted port strings - don't throw errors. They just silently do the wrong thing.
- YAML anchors (
&) and aliases (*) let you define a block once and reuse it across services, which removes the copy-paste step that causes most of those mistakes in the first place. - The
version:top-level key is obsolete in Compose v2 and should be deleted outright. docker compose config -qvalidates the file and fails loud, beforeupever runs - run it as a habit, not a last resort.
What a Compose File Is Actually Built From
A compose file has exactly one required top-level key: services. Everything else - networks, volumes, configs, secrets - is an optional declaration that most people reach for before they need to, because tutorials show them side by side as if they're equally mandatory. They aren't. The official services reference is explicit that services is where containers are actually defined; the rest exist to be referenced from a service, not to stand alone.
That distinction matters for debugging: if docker compose up fails with something like "service must be a mapping," the problem is almost always indentation under services, not a missing networks or volumes block. Start there before touching anything else.
The Syntax Mistakes That Don't Throw Errors
The mistakes that actually waste an evening aren't the ones Compose rejects outright - those at least point you at a line number. It's the ones that parse fine and run fine, just not the way you meant:
| Mistake | What happens | The fix |
|---|---|---|
Duplicate key in the same mapping (e.g. pasting a second environment: block into one service) |
The last one silently wins; the first is discarded with no warning | Run docker compose config and read the resolved output, not the source file |
Mixing list and map syntax for environment: in the same service |
Compose accepts either form individually but errors or misparses when they're combined | Pick one form (- KEY=value list, or KEY: value map) per service and stay consistent |
Unquoted port mapping like 8080:8080 |
Usually fine, but 8080:8080:extra or a value containing a # can be parsed as a comment or mapping, not a string |
Quote port strings: "8080:8080" |
yes / no / true / false as a bare value |
YAML 1.1 parsers coerce these to booleans even when you meant the literal string | Quote them: "yes" |
| Tabs for indentation | YAML's spec forbids tabs; some parsers silently fail, others error with a line number that doesn't point at the actual tab | Use spaces only, consistently (2-space is the de facto standard) |
None of these produce a stack trace. That's the common thread - a compose file with one of these mistakes looks, to docker compose up, exactly like a correct one.
Warning: the duplicate-key mistake is the one most likely to cost you time, because it happens silently during a copy-paste edit - duplicating a whole service block to create a second one, then forgetting to remove an old key from the original. Nothing in the logs flags it. The only way to catch it is to look at the resolved config, not the file you wrote.
Stop Repeating Yourself: YAML Anchors and Aliases
In my homelab compose file, three services used to share almost the same environment: block - timezone, log level, a shared DB host - copy-pasted into each one. Every time I changed the timezone, I had three places to remember to update, and the duplicate-key mistake above happened to me exactly this way once, when I pasted a fourth service and left a stray leftover key behind.
YAML anchors fix this at the file level, not the Compose level - they work because Compose files are plain YAML, and a walkthrough of anchors and extensions in Compose builds on the same x- prefix convention the wider ecosystem uses for anchor-only blocks:
x-common-env: &common-env
TZ: Europe/Berlin
LOG_LEVEL: info
services:
app:
image: myapp:latest
environment:
<<: *common-env
SERVICE_NAME: app
worker:
image: myapp-worker:latest
environment:
<<: *common-env
SERVICE_NAME: worker
The x-common-env block is ignored by Compose itself - any top-level key starting with x- is - which is exactly why it's safe to use purely as an anchor target. &common-env names the block; <<: *common-env merges its keys into each service's environment:, and a key defined locally (like SERVICE_NAME) overrides the anchored value if the names collide. Change the timezone once, in one place, and both services pick it up.
Note: the << merge-key syntax is a YAML extension, not part of the strict YAML 1.2 core spec - but Compose's parser supports it, which is why this pattern works reliably in docker-compose.yml even though some generic YAML tools reject it.
Delete the version: Key
If your compose file still starts with version: "3.8", delete that line. In Compose v2 the schema is unified and versionless - the key is ignored, and recent releases print a deprecation warning rather than respecting the value at all. There's no behavior it still controls; keeping it only adds a line that looks load-bearing and isn't.
Validate Before You Deploy: docker compose config
docker compose config renders the fully resolved configuration - every anchor expanded, every ${VAR} interpolated, every short-form shortcut written out in full - without starting a single container. That resolved output is what actually gets sent to the Docker engine, which makes it the fastest way to catch a duplicate key, a bad interpolation, or a typo'd service name before up ever runs.
Run it in validate-only mode, which prints nothing on success and a real error on failure - scriptable, unlike squinting at the full rendered YAML:
docker compose config -q
echo $? # 0 = valid, non-zero = broken, with a message explaining why
For the anchor example above, intentionally breaking it by duplicating the SERVICE_NAME key inside one service's environment: block still parses as valid YAML - but running docker compose config (without -q) shows you the merged result, and the duplicate collapses to whichever value came last, exactly as the table above describes. Reading that resolved output is how you catch it before the container ever starts with the wrong value.
Tip: add docker compose config -q as a pre-commit or pre-deploy step if you keep your compose files in git - it costs under a second and catches the exact class of mistake that otherwise surfaces as "why is this container doing the wrong thing" twenty minutes later. If the container starts but misbehaves anyway, our guide to debugging a broken Compose stack covers the next layer down.
A Pre-Deploy Checklist
- Run
docker compose config -q- exit code 0 means the file at least parses and resolves cleanly. - Run
docker compose config(no-q) and scan for any value that doesn't match what you wrote in the source file - that mismatch is a duplicate key. - Confirm there's no
version:line left at the top. - Confirm every
environment:block in the file uses one syntax (list or map), not both. - Confirm port mappings and any value that could be misread as
yes/no/trueare quoted.
docker compose up - my own test on this homelab stackNone of the three - duplicate keys, mixed env syntax, unquoted booleans - fail loudly. They fail by doing something other than what you wrote, which is a worse failure mode because nothing tells you to go look. Catching them is a habit (run config before up), not a one-time fix. Once your file validates cleanly, our guide to Docker Compose restart policies is a reasonable next stop - it covers what happens when a container does start correctly but keeps dying anyway.
FAQ
What is a docker-compose.yml file used for?
It defines one or more containers (services) and how they're built, configured, and connected - including optional shared networks and persistent volumes - so docker compose up can start the whole stack from one file instead of a long sequence of docker run commands.
Why is my docker-compose.yml not working?
Most failures that produce an actual error point at indentation or a missing services key. The failures that don't produce an error - a duplicate key, mixed environment: syntax, an unquoted value Compose reads as a boolean - are the ones worth checking for specifically, because the stack starts fine and misbehaves anyway.
How do I validate a docker-compose file before deploying?
Run docker compose config -q. It resolves the full configuration - anchors expanded, variables interpolated - without starting any container, and exits non-zero with a message if anything is invalid.
Is the version key required in docker-compose.yml?
No. In Compose v2 it's obsolete - the schema is unified and versionless, and the key is ignored (with a deprecation warning on recent releases). It's safe to delete.
Can I use YAML anchors in a docker-compose file?
Yes. Define a block once under an x--prefixed key (ignored by Compose itself), anchor it with &name, and merge it into any service's mapping with <<: *name. It's the standard way to share config like timezone or log level across services without copy-pasting it.
A compose file earns trust the same way any config file does: by being boring. If it only ever does exactly what the source text says, you stop needing to re-read it every time something looks off. For the broader setup this fits into, see our guide to Docker Compose for self-hosting.
More from Self-Hosting & Privacy

A new phone demanded a Google Password Manager PIN before your logins synced. Here's exactly what it protects, what it doesn't, and what happens if you lose it.

depends_on orders container starts, not readiness — Postgres can still be mid-recovery seconds after its container 'started'. Real log timestamps proving the gap, plus the condition: service_healthy fix that closes it.

A README lists 40-plus Docker Compose subcommands. In practice, running several stacks on one homelab host means using about eight of them daily, plus one gotcha: pull, logs, restart, and exec all scope to a single service if you name one.
Stay in the loop
Get the latest articles delivered to your inbox. No spam, unsubscribe anytime.
Google Password Manager PIN: What It Protects
A new phone demanded a Google Password Manager PIN before your logins synced. Here's exactly what it protects, what it doesn't, and what happens if you lose it.
Continue Reading