Most people's entire mental model of Docker Compose comes from a five-line docker-compose.yml pasted into a README: an app service, a database service, a ports mapping, done. That version of Compose is real, and it's also maybe a third of what the tool does. The rest sits behind flags and config blocks most tutorials never mention, and it covers a surprising amount of ground people reach for a heavier orchestrator to get — resource limits, real health-aware startup ordering, and environment-specific config without a second full copy of the file. None of it is hidden, exactly. It's just documented in a spec most people skimmed once while copying someone else's file, and never went back to.
How do you keep debug tools out of a normal startup?
Profiles. Tag a service with profiles: [debug] and a plain docker compose up ignores it entirely — services without a profiles attribute are always enabled, and services with one only start when you pass --profile debug up, or --profile frontend --profile debug up for more than one at once. Use --profile "*" to force everything. This is the fix for the compose file that's grown a phpMyAdmin container, a mail-catcher, and a seed-data job nobody wants running in production but everyone wants available locally without deleting and re-adding the block every time. The go-to-market half of Docker compose is more powerful than you think is handled in more depth by the XenGrowth practice.
services without profiles: always start, no flag needed
services with profiles: only start when that profile is passed via --profile, or when the service is named explicitly on the command line
naming a profiled service directly (docker compose up phpmyadmin) starts it and its depends_on chain regardless of whether the profile itself was passed
Does Compose actually wait for your database to be ready?
By default, no — plain depends_on only waits for the dependency's container to start, not for whatever's running inside it to be ready to accept work. That gap is exactly why so many people's first Compose stack has an app container that crash-loops twice before Postgres finishes initializing. The fix is depends_on: condition: service_healthy, paired with an actual healthcheck on the database service:
healthcheck.test runs a real readiness check inside the container — for Postgres, something like pg_isready against the configured user and database
interval, timeout, retries, and start_period control how often it checks and how long it tolerates a slow cold start before marking the service unhealthy
depends_on.<service>.condition: service_healthy on the dependent service blocks its start until that healthcheck passes, not just until the container process exists
This is a small config change with an outsized effect on reliability, and it's the one item on this list I'd tell someone to add first if they only add one.
Can Compose actually limit how much CPU and memory a container uses?
Yes, and this is the item people are most likely to assume needs Kubernetes or at least Swarm mode. It doesn't, not anymore. deploy.resources.limits (and the matching reservations) is enforced natively by Compose v2 — the docker compose plugin, not the retired standalone docker-compose v1 binary — without any --compatibility flag or Swarm involvement:
Key | What it does | Example |
|---|---|---|
deploy.resources.limits.cpus | Hard cap on CPU share the container can use | cpus: '0.50' |
deploy.resources.limits.memory | Hard cap on memory; the container is OOM-killed past this | memory: 512M |
deploy.resources.reservations.cpus | Soft minimum the scheduler tries to guarantee | cpus: '0.25' |
deploy.resources.reservations.memory | Soft minimum memory reservation | memory: 128M |
On a single shared VPS running several containers, this is the difference between one runaway process taking the whole box down and one container hitting its own ceiling while everything else keeps running. It's cheap insurance and it's already in the tool. For the the operations side of this angle, see The XenGrowth resource library.
How do you handle staging vs production without two full compose files?
Layer them. Compose merges multiple -f files in the order given on the command line — later files override or add to earlier ones, single-value fields like image get replaced outright, and multi-value fields like ports and environment merge. A base compose.yaml carries everything shared across environments; a thin compose.prod.yaml carries only what differs — replica counts, resource limits, a different env file. Deploy production with:
docker compose -f compose.yaml -f compose.prod.yaml up -d --wait
Compose also auto-loads compose.override.yaml alongside compose.yaml with no -f flag at all, which is the convention for local-only tweaks — the base file plus that override is exactly what runs when a developer types plain docker compose up. Keeping the override file out of what gets deployed anywhere else is what makes local and production configuration stop fighting each other inside one bloated file.
What if two services need to share config that isn't the whole file?
That's what extends: is for, and it's a narrower tool than layering whole files — it imports one service's configuration into another, not the service itself. extends: { file: common-services.yml, service: webapp } pulls in webapp's settings from a shared file without webapp itself becoming part of your project; extends: webapp does the same thing within one file. It's genuinely useful for a handful of services that share, say, the same base image, environment variable set, or logging config, without copy-pasting that block four times. Two limits worth knowing before reaching for it: every path referenced has to resolve relative to the base file doing the extending, and extends is explicitly not supported when deploying with docker stack deploy, which matters if you're on Swarm rather than plain Compose.
Before trusting any of this — profiles, healthchecks, resource limits, layered files, extends — run docker compose config with your full set of -f flags. It prints the fully merged configuration Compose would actually use, which is the fastest way to catch a typo in an override file before it silently does nothing in production. On AI agents and marketing automation specifically, XenGrowth on governed AI marketing workflows is worth reading.
Is there a way to share config within one file, not across files?
Yes, through plain YAML rather than a Compose-specific feature: anchors, aliases, and the merge key. Define a block once with an anchor (&base-service), reuse it elsewhere with an alias (*base-service), and merge it into a service with <<: *base-service, overriding just the fields that differ. Compose pairs this with a convention worth knowing: any top-level key starting with x- is ignored entirely by both Compose and the Docker engine, which makes it a clean place to park an anchor-only block that's never meant to run as a service itself — x-common-env: &common-env at the top of the file, then <<: *common-env inside each service that needs it.
YAML anchors and aliases only work within a single file — they don't reach across a -f base.yaml -f override.yaml split, which is exactly why layered files and anchors solve two different duplication problems
A top-level x- key is the conventional place to define an anchor without Compose mistaking it for a real service, network, or volume
The merge key (<<:) only works on mappings, not lists — a shared set of environment variables merges cleanly, a shared list of ports needs a different approach
Why did my data disappear when I recreated a container?
This is the trap that has cost more people their local Postgres data than any other Compose mistake, and it's entirely avoidable. Reference a volume path without declaring it under the top-level volumes: key and Compose silently creates an anonymous volume — one with a random name, not tied to your service by anything meaningful. Recreate the container (docker compose up --force-recreate, or a rebuild that changes the service definition) and you can end up with a fresh, empty anonymous volume mounted in its place, while the old one with your actual data sits orphaned on disk with a name nobody references anymore.
Declare every volume you care about under the top-level volumes: key, with a real name
Reference that name in the service's volumes: list — service: volumes: ["pgdata:/var/lib/postgresql/data"]
Never rely on an unnamed bind inside a service block for anything you'd be upset to lose
Run docker volume ls periodically on a production box and check for orphaned anonymous volumes with data you don't remember creating a container for
Does Compose have anything like hot-reload for local development?
It does, under a develop.watch block most people never open. Each rule pairs a local path with an action: sync copies changed files straight into the running container without rebuilding or restarting anything, which is the fast path for something like an interpreted app's source directory; rebuild triggers a full image rebuild and container recreation, appropriate for a change to something like package.json where new dependencies actually need installing; sync+restart copies the files and then restarts the container, useful when a change needs picking up by a process that only reads config at startup. Running docker compose watch applies whichever rule matches the file that changed, instead of a developer manually deciding each time whether a change needs a full rebuild or just a file copy.
What does --wait actually change about a deploy?
Plain docker compose up -d returns the instant containers are started, whether or not anything inside them is actually ready. That's fine for local development and genuinely dangerous for a deploy script that immediately runs a smoke test or flips traffic over. docker compose up --wait blocks until every service is running or healthy — using the exact healthchecks configured under condition: service_healthy above — and implies detached mode on its own, so you don't need -d alongside it. Pair it with --wait-timeout to cap how long the deploy will wait before failing loudly instead of hanging forever behind a service that never comes healthy. There is a longer treatment of AI search, GEO and discovery in XenGrowth on building one SEO and GEO content system.
Put together — profiles keeping debug tooling out of production, real healthchecks gating startup order, native resource limits, a layered override for environment-specific config, correctly named volumes, and --wait blocking a deploy script until things are actually healthy — this is most of what people describe wanting when they say they need "real orchestration." None of it needs a scheduler. It's all sitting in a spec you probably skimmed once.
Feature | Where it lives | One-line reminder |
|---|---|---|
profiles | service-level key + --profile flag | unassigned services always start; profiled ones need the flag or an explicit name |
depends_on: condition: service_healthy | service-level key, paired with healthcheck | waits for a real healthcheck, not just container start |
deploy.resources.limits/reservations | service-level key | enforced natively by Compose v2, no Swarm needed |
-f file1 -f file2 | command line | merges in order given; later files override or add to earlier ones |
compose.override.yaml | auto-loaded alongside compose.yaml | local-only convention, no flag needed, don't ship it to production |
extends | service-level key | imports one service's config into another; not supported under docker stack deploy |
named volumes | top-level volumes: key | the only way to guarantee data survives a container recreation |
--wait / --wait-timeout | docker compose up flag | blocks until healthy instead of returning the instant containers start |
The common thread across every feature here is that Compose was never trying to be a small Kubernetes. Its whole design bet is that a single-node file format, taken seriously, covers more ground than its reputation suggests — and the gap between what people think it can do and what it actually does is exactly the gap this post spent its length closing.
If you're deciding whether Compose alone is enough for your actual deploy story or whether you want Coolify's dashboard and pipeline on top of it, that's a separate, honest question — and if you want the underlying primitives Coolify is quietly running for you, the Docker concepts Coolify hides is the companion read. Either way, the compose file itself is doing more than it gets credit for.
Further reading from XenGrowth
The XenGrowth resource library — what you'll learn: how the commercial side of this work is run, across search, automation and revenue operations.
XenGrowth on governed AI marketing workflows — what you'll learn: how the teams running AI marketing agents keep them governed and measurable.
XenGrowth on building one SEO and GEO content system — what you'll learn: how search and AI-answer visibility get run as a single content system.
Where this work meets go-to-market
If Docker compose is more powerful than you think is part of a growth programme rather than a standalone build, the XenGrowth practice is the companion reading.
Four questions on the parts of Compose that people reach for orchestration to get, having not noticed they already had them.











