Yasas Banuka
Docker Compose: Reading the File You've Been Copy-Pasting
Back to BlogDevOps

Docker Compose: Reading the File You've Been Copy-Pasting

June 25, 202610 min read·1,833 words
Share

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_defaultnetwork
docker volume create postgres-datavolume
docker run --name postgres ...container
docker run --name backend ...container
docker run --name frontend ...container

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

Tip

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

docker-compose.yml
Committed
.env
Ignored

Production Server

docker-compose.yml
+ Injected at runtime
.env(Keys, DB Passwords)

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.

Warning

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

0s
Postgres Container Starts
1s
Backend Container Starts
2s
Backend: Connection RefusedPostgres is still booting...
3s
Backend: Crashes

service_healthy

0s
Postgres Container Starts
10s
pg_isready → FAILEDStill booting (waiting...)
20s
pg_isready → SUCCESSService marked healthy
21s
THEN Backend Starts
22s
Connection Succeeds

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

Backend Container
jdbc:postgresql://
localhost:5432
Looks inside itself!
jdbc:postgresql://postgres:5432Docker DNS resolution
Postgres Container
Port5432

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.

#docker#compose#devops#containers#infrastructure
Share
← All Articles
Yasas Banuka Malavige

Written by

Yasas Banuka Malavige

DevOps Engineer · Building resilient infrastructure, automating pipelines, and documenting the quiet foundations that keep production systems alive.