Be honest for a second.
How many times have you grabbed a docker-compose.yml from a tutorial, a GitHub repo, or an AI tool, changed a few values and run docker compose up without fully understanding what most of it actually does?
If the answer is "more times than I'd like to admit," you're in good company. Almost every developer starts with Docker Compose this way. The file works, the containers start, the app runs. Why dig deeper?
Here's why: the day it breaks is the day you need to understand it.
And it will break. In production, at the worst possible time, with the least helpful error message. When that happens, the difference between a developer who blindly copy-pasted and one who actually understands the file is the difference between a 10-minute fix and a 3-hour incident.
Let's close that gap right now.
The Two-Person Origin Story
Before we touch any YAML, here's a fact that puts Docker Compose in a completely different light.
Docker Compose wasn't built by Docker.
It started as a side project called Fig, built by a two-person startup in London called Orchard Labs. Just two developers: Aanand Prasad and Ben Firshman. They were frustrated with running multiple Docker containers manually, each with long docker run commands that had to be typed in the right order with the right flags. So they built a tool that let you describe your whole stack in one YAML file and start everything with one command.
The developer community went wild for it. Fig spread fast. Docker noticed.
In July 2014, Docker acquired Orchard Labs, their very first acquisition. Aanand and Ben flew to San Francisco, and Fig became Docker Compose. The tool you use every day to run your entire application stack was originally the weekend project of two developers in London who were just tired of typing long commands.
That's the kind of origin story that makes you appreciate the tool differently.
What Compose Actually Does (It's Simpler Than You Think)
Here's the mental model most people are missing.
Docker Compose is not a special Docker technology. It's a translator.
It reads your YAML file, translates every section into Docker API calls, and executes them in the right order. That's it. Every service becomes a docker run. Every network block becomes a docker network create. Every volume block becomes a docker volume create.
The Compose Translator
docker-compose.yml
Declarative config
Compose Engine
Translates & Orders
docker network create myapp_defaultnetworkdocker volume create postgres-datavolumedocker run --name postgres ...containerdocker run --name backend ...containerdocker run --name frontend ...containerIf you could type fast enough, you could recreate everything Compose does manually. Compose just does it reliably, in order, every time, from one file.
The one thing Compose adds that plain docker run doesn't: it automatically creates a dedicated network for your project and connects every service to it. That's why your backend can reach your database by typing postgres:5432, they're both on the same custom network, and Docker's embedded DNS resolves service names automatically.
Now let's read an actual file and understand every line.
Reading a Real Compose File - Line by Line
Here's a realistic production-style compose file. The kind you've probably seen but maybe not fully understood:
services:
postgres:
image: postgres:16-alpine
container_name: myapp-postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: myapp_db
volumes:
- postgres-data:/var/lib/postgresql/data
ports:
- "127.0.0.1:5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
backend:
image: myapp/backend:1.2.0
container_name: myapp-backend
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
DB_URL: jdbc:postgresql://postgres:5432/myapp_db
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
JWT_SECRET: ${JWT_SECRET}
ports:
- "127.0.0.1:8080:8080"
volumes:
postgres-data:
Let's go through every pattern here.
restart: unless-stopped
restart: unless-stopped
Four options exist. Here's what each actually means:
no- never restart. Container dies, it stays dead.always- restart no matter what, even if you manually stopped it, even after a server reboot.on-failure- restart only if the container exits with an error code.unless-stopped- restart always except if you deliberately stopped it.
unless-stopped is the right choice for production services. Your postgres crashes at 3am, it restarts automatically. But when you run docker compose stop for planned maintenance, it stays stopped.
always sounds better but it's actually annoying. If you stop a container intentionally to debug something and the server reboots, always starts it again automatically. unless-stopped respects your intentions.
The environment variable patterns - three very different behaviors
environment:
POSTGRES_USER: ${DB_USER} # pattern 1
DB_PASSWORD: ${DB_PASSWORD} # pattern 1
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET must be set} # pattern 2
NODE_ENV: production # pattern 3
Pattern 1 - ${VARIABLE} with no fallback. If DB_USER isn't set in your .env file or shell environment, Compose substitutes an empty string. Your app starts with a blank database user. Silent failure.
Pattern 2 - same syntax, but for secrets like JWT_SECRET this is actually the right choice. You want it to fail loudly if the secret is missing, not silently use an empty value. Pair this with ${JWT_SECRET:?JWT_SECRET must be set} if you want Compose to outright refuse to start when it's missing.
Pattern 3 - hardcoded value. Fine for things that genuinely don't change between environments.
The .env file lives in the same folder as your compose file. Compose loads it automatically. It never gets committed to git, that's the whole point. Your compose file is safe to push to GitHub. The actual secrets live only on the server.
The Separation of Secrets
Git Repository
Production Server
The depends_on trap that has crashed real production systems
This is the most misunderstood thing in Docker Compose. And it has caused real outages.
# what most people write
backend:
depends_on:
- postgres
This looks right. Backend won't start until postgres starts. But "postgres starts" means the container starts, not that postgres is ready to accept connections. PostgreSQL takes several seconds to initialize after the container starts. Your backend starts, immediately tries to connect, postgres is still warming up so connection refused, crash.
On a fast developer laptop this sometimes works by luck, postgres initializes quickly enough. On a slower CI server or a fresh production deployment, it fails every time.
One developer documented this exact scenario in a legacy project where the database initialization script had to import 500MB of test data, that process alone took 40 seconds. With basic depends_on, the application container crashed and restarted at least 5 times before it could connect.
The fix is two parts working together:
# part 1: teach postgres to report when it's actually ready
postgres:
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myuser -d mydb"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s # give postgres grace time before checks begin
# part 2: make backend wait for that signal
backend:
depends_on:
postgres:
condition: service_healthy # not just "started" — actually healthy
pg_isready is a built-in postgres utility that returns success only when the database is genuinely accepting connections. start_period gives postgres a grace period before healthcheck failures start counting, crucial because postgres always looks unhealthy for the first few seconds while it's initializing.
The Depends_On Trap vs Solution
Naive depends_on
service_healthy
This one pattern eliminates an entire class of production startup failures.
127.0.0.1:5432:5432 - the security detail hiding in the ports
ports:
- "127.0.0.1:5432:5432" # secure
- "5432:5432" # not secure
The difference is one IP address prefix, but the security implication is significant.
Without 127.0.0.1, Docker publishes the port on all network interfaces, including your server's public IP. Anyone on the internet who knows your server's IP can attempt to connect to your postgres database. Your firewall probably blocks it, but Docker's iptables rules can bypass firewall rules entirely.
With 127.0.0.1, Docker only creates a NAT rule for loopback traffic. The port is only reachable from processes running on the same machine. Your nginx reverse proxy on the host can reach it. The internet cannot.
Databases, admin panels, internal APIs - anything that shouldn't be publicly accessible should always bind to 127.0.0.1. Your frontend nginx is the only thing that earns a public port.
Think About It
Look at this setup:
services:
backend:
environment:
DB_URL: jdbc:postgresql://localhost:5432/mydb
The backend is trying to connect to the database. On the surface this looks fine; postgres is running, port 5432 is published, localhost should work.
Will this connect successfully?
No. And this exact mistake has cost teams hours of debugging.
The localhost Trap
localhost:5432
localhost inside a container means the container itself - its own loopback interface. Not the host machine. Not another container. The backend is looking for a postgres process running inside itself, finding nothing, and failing.
The correct connection string inside Docker:
DB_URL: jdbc:postgresql://postgres:5432/mydb
postgres, the service name gets resolved by Docker's embedded DNS to whatever IP the postgres container currently has. This works even when containers restart and get new IPs, because the DNS always stays current.
If your connection string says localhost and it's in a container, that's the bug.
The Three Patterns in Production Setup
Pattern 1 - Healthchecks on every stateful service, conditions on every dependent.
If a service stores data (database, cache, queue), it needs a healthcheck that actually tests readiness. Every service that connects to it needs condition: service_healthy.
Pattern 2 - Secrets without fallbacks, configs with fallbacks.
NODE_ENV: ${NODE_ENV:-production} # fine, has a safe default
JWT_SECRET: ${JWT_SECRET} # no default — fail loudly if missing
DB_PASSWORD: ${DB_PASSWORD} # no default — fail loudly if missing
The moment you add a fallback to a secret, you create a silent security hole. A developer forgets to set JWT_SECRET on the server, it falls back to an empty string, authentication silently breaks in a completely confusing way.
Pattern 3 - Explicit named networks and 127.0.0.1 port bindings.
networks:
app-network:
driver: bridge
services:
backend:
networks:
- app-network
ports:
- "127.0.0.1:8080:8080"
Named networks give you intentional topology. 127.0.0.1 bindings give you intentional exposure. "It works" and "it's secure" are different things.
The Compose File as Documentation
Your docker-compose.yml is not just a configuration file. It's living documentation of your entire application architecture.
Every service is listed. Every dependency is declared. Every port, every volume, every environment variable; it's all there. A new developer joining your team can read your compose file and understand your entire system in 10 minutes.
This is why naming matters. container_name: myapp-postgres instead of the auto-generated myapp_postgres_1. Explicit network names instead of the default. Volume names that describe what they store.
The best compose files read like a clear description of the system. The worst ones read like a pile of configuration that only the original author understands.
What's Next
In the next article we're going into Docker security. Running containers as root by default, what Linux capabilities actually are, and the practical patterns that separate a container setup that "works" from one that won't get you in serious trouble if something goes wrong.
Have you ever spent hours debugging a compose file because of a localhost trap or missing healthchecks? What was the actual cause once you found it? Drop it in the comments.



