

Sarthak Varshney is a Docker Captain, 5x C# Corner MVP, and 2x Alibaba Cloud MVP, with over six years of hands-on experience in the IT industry, specializing in cloud computing, DevOps, and modern application infrastructure. He is an Author and Associate Consultant, known for working extensively with cloud platforms and container-based technologies in real-world environments.
So you've got the basics of Docker Compose down. You can write a docker-compose.yml, you know how to spin up a container or two, and docker compose up doesn't scare you anymore. Nice. But here's the thing — the moment you try to build something that actually resembles a real application, you start running into questions that the beginner tutorials conveniently skipped.
"How do I run five copies of my web server?" "Why is my app crashing because the database isn't ready yet?" "How do I use one setup for my laptop and a different one for the production server?" "Where do I hide my passwords so they're not sitting right there in the YAML for everyone to see?"
Grab a coffee. Let's walk through all four of these, one at a time, like we're just chatting over a whiteboard.
Let me paint you a picture. Imagine you run a small pizza shop, and you've got one guy, Tony, taking phone orders. Business is slow, so Tony handles everything fine. Then one day you get featured on some food blog, and suddenly the phone won't stop ringing. Poor Tony is drowning. What do you do? You don't fire Tony and hire a superhuman — you just hire more Tonys. Three, four, five people all answering phones at once.
That's scaling. And in Docker Compose, it's almost embarrassingly easy.
Say you have a simple worker service in your docker-compose.yml:
services:
worker:
image: myapp/worker:latest
environment:
- QUEUE_URL=redis://redis:6379
redis:
image: redis:7
Now you want five workers chewing through your job queue instead of one. You could copy-paste the service five times with different names (worker1, worker2, ...) but please don't — that's the kind of thing that makes future-you hate present-you. Instead:
docker compose up --scale worker=5
Boom. Five worker containers, all running the exact same config, all pulling from the same queue. Docker names them myproject-worker-1, myproject-worker-2, and so on. They share the same network, so as far as your Redis is concerned, it's just got five hungry mouths to feed instead of one.
You can also bake a default into the file itself using the deploy key:
services:
worker:
image: myapp/worker:latest
deploy:
replicas: 3
Then a plain docker compose up gives you three by default. Handy.
Here's where beginners faceplant. Let's say your web service maps a port:
services:
web:
image: myapp/web
ports:
- "8080:80"
Now try docker compose up --scale web=3. You'll get an angry error, something along the lines of "port is already allocated." Think about it — you told Docker "take container port 80 and glue it to host port 8080." But you've got three containers now, and only one host port 8080. It's like trying to plug three lamps into a single wall socket. Physics says no.
The fix is to let Docker pick the host ports for you by giving it a range or just the container port:
services:
web:
image: myapp/web
ports:
- "80"
Now each container grabs a random free host port. In the real world though, you rarely scale like this by hand — you'd put a load balancer (like Nginx or Traefik) in front, and it distributes traffic across your web containers. But that's a story for another day. For learning purposes, just remember: fixed host ports and scaling don't mix.
depends_on — Teaching Your Containers Some PatienceOkay, this one trips up everybody, so listen closely because there's a subtle trap here.
Picture a relay race. Your app is a runner waiting to grab the baton, and the database is the runner ahead. The app can't do anything useful until the database hands over that baton. If your app starts sprinting before the database is even on the track, it face-plants — you get those lovely connection refused errors on startup.
The naive expectation is that depends_on fixes this. Let's look at it:
services:
app:
image: myapp/app
depends_on:
- db
db:
image: postgres:16
What this says is: "Start db before app." And Compose obeys — it starts the database container first, then the app container.
But here's the brutal truth that catches so many people: starting a container is not the same as that container being ready. Postgres takes a few seconds to boot up, run its initialization, and start accepting connections. depends_on only waits for the container to start, not for the app inside it to be ready to work. It's like your relay teammate showing up to the stadium versus actually being at the starting line with the baton in hand. Big difference.
So your app might still start firing database queries into the void while Postgres is still stretching its legs. Cue the crash.
Modern Compose lets you wait for a service to actually be healthy, not just started. You define a healthcheck on the database and tell your app to wait for it:
services:
app:
image: myapp/app
depends_on:
db:
condition: service_healthy
db:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
Now we're talking. That pg_isready command is Postgres's own way of saying "yep, I'm actually taking connections now." Compose keeps poking the database with it every 5 seconds, and only once it reports healthy does the app get the green light to start. The relay baton is genuinely ready.
The conditions you can use:
| Condition | What it means |
|---|---|
service_started | The dependency container has started (the default, weakest guarantee) |
service_healthy | The dependency passed its healthcheck — it's actually ready |
service_completed_successfully | The dependency ran and exited with code 0 (great for one-off setup jobs) |
That last one is gold for things like a database migration container that needs to finish its job before your app boots.
Writing depends_on: - db and assuming your ordering problems are solved. They're not. A ton of real-world "it works on my machine but breaks in CI" bugs come from exactly this — the CI server is slower or faster, and the timing race gets exposed. If order matters and readiness matters, use health checks. Don't rely on luck.
And honestly, even with all this, the gold-standard advice is to make your app resilient — have it retry the database connection a few times on startup instead of assuming everything's perfect. Compose ordering is a convenience, not a guarantee about the world.
Here's a situation you'll hit constantly. On your laptop, you want your database port exposed so you can poke at it with a GUI tool, you want live code reloading, and you don't care about performance. On the production server, you want none of that exposed, you want restart policies, resource limits, and the real image — not your dev build.
Same app, two very different needs. So do you keep two entirely separate compose files with 90% duplicated content? God, no. That way lies madness and copy-paste bugs.
Think of it like dressing for the weather. You've got your base outfit — jeans and a t-shirt — and then depending on conditions you layer on top. Rainy? Add a jacket. Cold? Add a scarf. You don't buy a whole new wardrobe for each type of day.
Compose lets you layer files the exact same way. Start with a base:
docker-compose.yml (the shared foundation)
services:
web:
image: myapp/web
environment:
- APP_ENV=base
db:
image: postgres:16
Then an override for development:
docker-compose.override.yml
services:
web:
build: .
volumes:
- ./src:/app/src
ports:
- "8080:80"
environment:
- APP_ENV=development
db:
ports:
- "5432:5432"
Here's a neat trick: Compose automatically merges docker-compose.yml and docker-compose.override.yml when you just run docker compose up. You don't have to tell it anything. So your default local experience already gets the dev goodies layered on top. That's the whole reason it's called override.
For production, make a separate file and pass it explicitly:
docker-compose.prod.yml
services:
web:
image: myapp/web:1.4.2
restart: always
environment:
- APP_ENV=production
deploy:
resources:
limits:
cpus: "0.5"
memory: 512M
And you run it like this:
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
The order matters — files listed later win when there's a conflict. So docker-compose.prod.yml overrides the base. Note that because you named the second file explicitly, the automatic override.yml is not loaded — that only happens when you don't specify -f at all.
This is the subtle part. When Compose merges files:
image, a scalar environment variable, restart) — the later file replaces the earlier one. Clean.ports, volumes, expose) — the later file's entries get added to the earlier ones. They don't replace!That second rule bites people hard. Say your base exposes port 80 and your override adds port 8080. You might expect just 8080, but you actually get both. If you were trying to change a port, layering won't overwrite the list — it appends. So keep environment-specific ports out of the base file entirely, and only put them in the layers where they belong.
A quick way to sanity-check what Compose actually computed after merging everything:
docker compose -f docker-compose.yml -f docker-compose.prod.yml config
That prints out the final, fully-merged configuration. Run this any time you're confused about "wait, what settings am I actually getting?" It has saved me more times than I can count.
.env Files — Keeping Your Secrets Secret (and Your Config Clean)Last stop. Right now your compose files probably have stuff hardcoded — image tags, ports, and cringe maybe even passwords sitting there in plain text. Committing a database password to Git is the kind of mistake that becomes a horror story at your next job interview. Let's not.
Imagine .env as a little notecard of settings you keep next to your compose file. Compose reads it automatically and plugs the values in wherever you reference them.
Create a file literally named .env in the same folder as your compose file:
.env
POSTGRES_PASSWORD=supersecret123
POSTGRES_USER=admin
WEB_PORT=8080
IMAGE_TAG=1.4.2
Then in your compose file, reference them with ${...}:
services:
web:
image: myapp/web:${IMAGE_TAG}
ports:
- "${WEB_PORT}:80"
db:
image: postgres:16
environment:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
Run docker compose up and Compose quietly substitutes those ${...} placeholders with the values from .env. Now your compose file is clean, shareable, and secret-free. Change the port or the password? Edit one line in .env, no touching the YAML.
You can also set defaults right in the YAML for when a variable isn't defined:
ports:
- "${WEB_PORT:-8080}:80"
That :-8080 means "use WEB_PORT if it's set, otherwise fall back to 8080." Great safety net so your app doesn't break because someone forgot to set a variable.
.env confusions everyone hasConfusion #1: The .env file vs. the environment: section. These are not the same thing, and mixing them up is super common.
.env file feeds variables into the compose file itself — the ${...} substitution happens on the host, before containers even start.environment: section sets variables inside the running container, for your app to read at runtime.So .env is for configuring your Compose setup; environment: is for configuring what's inside the box. Sometimes you pipe one into the other (like in the example above), but they operate at different layers.
Confusion #2: The default .env vs. --env-file. The file named exactly .env is auto-loaded. But you can keep multiple, like .env.dev and .env.prod, and choose one:
docker compose --env-file .env.prod up -d
.gitignore your .envThe whole point was to keep secrets out of version control. So add this to your .gitignore:
.env
.env.*
Then commit a .env.example with fake placeholder values so your teammates know what variables they need to fill in. That's the pattern every decent project uses:
POSTGRES_PASSWORD=changeme
POSTGRES_USER=admin
WEB_PORT=8080
IMAGE_TAG=latest
Real secrets stay on each person's machine; the shape of the config is shared. Everybody's happy, nobody leaks a password.
Let's zoom out and remember what these four tools actually do for you:
depends_on controls startup order, but only health checks guarantee a service is truly ready, not just started..env files keep your configuration and secrets out of your YAML and out of Git.None of these are complicated once you've felt the pain they solve. And you will feel that pain — the crashing-on-startup race condition, the "port already allocated" scream, the accidentally-committed password. When you do, you'll remember this chat and go "ohhh, that's what he was talking about."
Time to get your hands dirty. Build a small project with the following:
Create a base docker-compose.yml with two services: a web service (use any simple image, even nginx) and a db service using postgres:16.
Add a healthcheck to the db using pg_isready, and make web wait for the database to be service_healthy before starting. Then intentionally break it — remove the healthcheck, add a sleep to the DB startup, and watch the ordering race happen. Feel the pain, then fix it.
Move all config into a .env file — the Postgres user, password, the web port, and the Postgres image tag. Reference them with ${...} in your compose file. Confirm it works with docker compose config to see the substituted values.
Create a docker-compose.override.yml that exposes the database port for local access, then a docker-compose.prod.yml that adds restart: always and removes the exposed DB port. Run each combination and use docker compose config to prove to yourself the merge did what you expected.
Scale a worker service to 4 replicas with --scale, then break it on purpose by giving that service a fixed host port. Read the error, then fix it by removing the host port.
Bonus: Add a .gitignore that ignores .env, and create a .env.example with placeholder values. Congratulate yourself for being a responsible developer.
Once all of that works and you understand why each piece behaves the way it does, you're genuinely past the beginner stage. Go build something real.