Docker Compose Is More Powerful Than You Think
Tutorial

Docker Compose Is More Powerful Than You Think

Most people learn Compose from a five-line docker-compose.yml in a README and stop there. Profiles, real healthchecks, resource limits, and layered override files cover most of what people reach for Kubernetes to get — you're probably one flag away from using the tool you already have.

Published September 10, 202611 min readUpdated Sep 6, 2026

Written by · Full-Stack Agentic AI Software Engineer — AI Agents, Automation & Revenue Systems for GTM/RevOps teams

In brief

What can Docker Compose actually do beyond a basic docker-compose up?

A lot more than the tutorial version most people learn from. Profiles let you keep debug-only services out of a normal startup. depends_on with condition: service_healthy makes a dependent service actually wait for a real healthcheck, not just a container existing. deploy.resources.limits caps CPU and memory per service natively in Compose v2, no Swarm required. Layering a base compose.yaml with compose.prod.yaml or compose.override.yaml gives you real environment-specific config without duplicating the file. Named volumes, done correctly instead of relying on Compose's implicit anonymous volumes, are what actually keeps your data across a redeploy. And --wait turns docker compose up from fire-and-forget into a command that blocks until services are genuinely healthy — which is most of what people reach for an orchestrator to get.

  • profiles let you tag services (debug tools, seed jobs) so they only start when explicitly requested with --profile, keeping a normal docker compose up lean
  • depends_on: condition: service_healthy makes Compose wait for a service's actual healthcheck to pass, not just for the container process to start — the difference between an app booting before Postgres can accept connections and one that doesn't
  • deploy.resources.limits (cpus, memory) is enforced natively by Compose v2 without Swarm mode, letting you cap what a runaway container can consume on a shared box
  • Multiple compose files (-f base -f override) merge in the order given, letting one base file plus a thin production or staging override replace maintaining two nearly-identical full files
  • Named volumes declared under top-level volumes: persist correctly across container recreation; the default anonymous volume you get from forgetting to name one is the single most common cause of Compose data loss

Evidence notes

Compose healthcheck dependency conditions

condition: service_healthy under depends_on delays starting a dependent service until the referenced service's healthcheck passes, per Docker's Compose startup-order documentation, checked September 2026.

Compose profiles syntax and default behavior

Services without a profiles attribute are always enabled; services tagged with one only start when their profile is passed via --profile, or when named explicitly on the command line, per Docker's Compose profiles documentation, checked September 2026.

deploy.resources enforcement in Compose v2

Compose v2 (the docker compose plugin) enforces deploy.resources.limits natively without Swarm mode or a --compatibility flag, unlike the legacy standalone docker-compose v1 binary, checked September 2026.

--wait flag

docker compose up --wait waits for services to reach running or healthy state and implies detached mode, with --wait-timeout capping how long it waits, per Docker's CLI reference, checked September 2026.

Multiple compose files merge order

Compose merges files in the order specified with -f, with later files overriding or adding to earlier ones; compose.yaml plus compose.override.yaml load automatically if both exist, per Docker's multiple-compose-files documentation, checked September 2026.

extends key and its limits

extends imports a referenced service's configuration (not the service itself) from the same or another file, with paths resolved relative to the base file, and is explicitly not supported under docker stack deploy, per Docker's extends documentation, checked September 2026.

develop.watch actions

The sync action copies changed files into a running container without a rebuild; rebuild triggers a full image rebuild and recreation; sync+restart copies files then restarts the container — per Docker's Compose Develop specification, checked September 2026.

Continue with purpose

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.

  1. Declare every volume you care about under the top-level volumes: key, with a real name

  2. Reference that name in the service's volumes: list — service: volumes: ["pgdata:/var/lib/postgresql/data"]

  3. Never rely on an unnamed bind inside a service block for anything you'd be upset to lose

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

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.

How much of Compose are you using?

Four questions on the parts of Compose that people reach for orchestration to get, having not noticed they already had them.

1 / 4
depends_on lists the database before the app. Does the app wait for the database to be ready?

Apply this article

How to turn insights into execution

A practical sequence for teams turning concepts into production outcomes.

Docker ComposeDockerDevOpsSelf-HostingTutorialstutorials

Audit your current state

Map the bottlenecks and constraints connected to the article’s core problem.

Choose one bounded change

Test the most useful recommendation on one workflow before widening the scope.

Measure what changed

Keep the parts that improve the work, document what failed, and make the next decision from evidence.

Next step

Need help applying this in your stack?

I can translate these patterns into a concrete implementation plan for your team.

Discuss implementationBack to blog

Replies usually within 24 hours.

Next Steps

Continue reading

How to Self-Host Next.js With Coolify: A Complete 2026 Walkthrough

Every Coolify tutorial stops at "push to deploy." This one covers the parts that actually break a real app: standalone output, a build that OOMs on a small box, env vars baked in at the wrong time, and a health check that would have caught all of it.

Navigate

How I Back Up PostgreSQL to S3-Compatible Storage on a Schedule

pg_dump, pg_basebackup and WAL archiving aren't three ways to do the same thing — they answer three different questions about how much data you're willing to lose. Here's which one to run, from a container that has no business having shell access to your host, pushed to storage that makes restoring free instead of expensive.

Navigate

The Docker Concepts You Need Before Coolify Hides Them From You

Coolify's whole pitch is that you shouldn't have to think about Docker, and most days that's true. The days it isn't are the days a container is 'running' but unhealthy, a redeploy quietly ate a volume, or an env var vanished — and the dashboard doesn't explain any of it.

Navigate

Setting Up a Firewall for Self-Hosted Apps (and Docker's Nasty UFW Surprise)

ufw status can say everything's locked down while a container you published with -p 5432:5432 sits wide open to the internet. This isn't a misconfiguration — it's Docker rewriting your firewall's decisions before ufw ever gets a vote, and the only way to know for sure is to check from a machine that isn't the one you're worried about.

Navigate

10 Mistakes That Break a Self-Hosted SaaS

None of these ten show up as a single dramatic outage. They show up as a disk that quietly fills, a rollback that turns out to be impossible, a backup nobody ever restored. Here's the mechanism behind each one, and the fix.

Navigate

Docker Compose vs Coolify vs Kubernetes: Picking the Right Machinery

Most of this argument is decided before anyone opens a terminal, by team size and how much downtime you can actually survive. Here's the honest threshold for each tier, and why Kubernetes pays for itself much later than the posts selling it will tell you.

Navigate

How I Restore an Entire Server From Backup (And How Long It Takes)

The order matters more than people expect, and the answer to "how long will this take" isn't a number I can hand you — it's dominated by your database size, your download bandwidth, and one DNS setting most people only think to change after they needed to.

Navigate

How to Host Unlimited Domains on One VPS With Free SSL

"Unlimited domains, one server" sounds like a sales pitch, but it's a genuinely accurate description of what SNI-based virtual hosting and Let's Encrypt automation do together. The real ceiling isn't domain count. It's RAM.

Navigate

Wildcard Domains With Coolify and Cloudflare

A wildcard certificate can't be proven the way a normal one is — there's no single page to fetch for a domain that doesn't exist yet. That's why it needs a DNS record instead of an HTTP request, and why Cloudflare's API has to be involved at all.

Navigate

How I Get Alerted the Moment a Self-Hosted App Goes Down

A monitor that checks the wrong thing, sends to a channel you don't watch, and pages you for every 30-second blip is worse than no monitor — it trains you to ignore it. Here's how to build alerting that actually works, for one person who eventually has to sleep.

Navigate