Docker Compose Patterns I Wish I’d Learned Three Years Ago Instead of Figuring Out the Hard Way

I once spent four hours debugging a container that wouldn’t talk to another container on the same host. Same docker-compose.yml. Same network block. Everything looked right. The problem turned out to be a single missing networks: declaration at the service level that I’d copied from a Stack Overflow answer written in 2019 that predated Compose V2. Four hours. One omission.

That’s the thing nobody warns you about with Docker Compose. The syntax is forgiving enough to let you think you understand it, right up until it quietly does something different from what you intended.

I’m not a developer. I’m a systems engineer who builds tools for himself because future-me gets tired of doing repetitive work. I’ve deployed HomeBase, Cookslate, and most of my homelab stack on Docker. What I know about Compose I learned by breaking things, reading logs at unreasonable hours, and occasionally yelling at my monitor in the office while Oakley watched from the doorway with zero sympathy.

Here’s what actually stuck.


Your Compose File Is a Contract, Not a Script

This took me too long to internalize. A docker-compose.yml isn’t procedural. You’re not telling Docker what to do. You’re describing a desired state and letting Docker figure out how to get there. The moment I stopped reading it like a shell script and started reading it like a declaration, things made more sense.

The practical consequence: order within the file doesn’t guarantee execution order. A service listed first doesn’t necessarily start first. And that brings me to the one that caught me worst.


depends_on Does Not Mean What You Think It Means

Early on I had a PHP app container that kept crashing on startup. The MySQL container was right there in the same Compose file. I had depends_on: - db set up correctly. Still crashed, every time, with a “can’t connect to database” error.

What depends_on actually guarantees is that the container starts before yours does. It does not guarantee the service inside that container is ready to accept connections. MySQL takes a few seconds to initialize. Your app container launches, immediately tries to connect, gets nothing, and dies.

The fix is a healthcheck on your database service combined with condition: service_healthy in your depends_on block:

depends_on:
  db:
    condition: service_healthy

And on the db service itself:

healthcheck:
  test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
  interval: 5s
  timeout: 3s
  retries: 5

Now Docker waits until MySQL is actually responding before it lets your app container start. That’s the behavior you thought you had from the beginning.


Named Networks: Stop Letting Docker Name Things for You

By default, Docker Compose creates a network named something like projectfolder_default. That sounds fine until you have multiple Compose stacks that need to communicate, like a reverse proxy container that needs to reach app containers defined in separate files.

My Caddy container lives in its own Compose stack. So does every app I run. For Caddy to proxy traffic to an app, both need to be on the same Docker network. If you let Docker auto-name everything, you end up playing naming guessing games.

The pattern I use now: define an external network by hand once, then reference it in every stack that needs to participate.

Create it manually:

docker network create proxy

Then in each Compose file that needs Caddy to reach it:

networks:
  proxy:
    external: true

And attach each relevant service to it:

services:
  myapp:
    networks:
      - proxy

Clean, predictable, and you stop wondering why your proxy can’t reach your app.


Environment Variables Belong in .env, Not Inline

I used to write credentials directly into Compose files. Passwords, API keys, database names, all of it sitting in plain text in a YAML file that I’d sometimes paste into a chat to ask for help. That’s bad for obvious reasons.

Compose natively reads a .env file in the same directory. You define your variables there, reference them in the Compose file with ${VARIABLE_NAME}, and now your credentials aren’t in the file you’re version-controlling or sharing.

# .env
DB_PASSWORD=someactualpassword
DB_NAME=myapp
# docker-compose.yml
environment:
  MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
  MYSQL_DATABASE: ${DB_NAME}

Add .env to your .gitignore and keep a .env.example with placeholder values so you remember what variables the stack needs. It takes five minutes to set up and it’s the kind of discipline that saves you later.


Volumes With No Name Are a Ticking Clock

Anonymous volumes are Docker’s version of a temp file you forget about. You define a volume inline without naming it and Docker creates one with a long UUID as the identifier. It works fine right up until you tear down the stack, bring it back up, and notice your data is gone because you didn’t know which volume to preserve.

Always name your volumes explicitly:

volumes:
  db_data:

services:
  db:
    volumes:
      - db_data:/var/lib/mysql

Now docker volume ls shows you something readable, docker volume inspect db_data tells you exactly where it lives, and you don’t lose a database because you ran docker compose down -v without thinking.


Restart Policies Are Not Optional

For anything I care about staying up, I set restart: unless-stopped. Not always, because that restarts even when you intentionally stop the container during maintenance. unless-stopped means it comes back after a host reboot but stays down if you manually stopped it.

restart: unless-stopped

One line. I forgot it once on Cookslate and came back from a weekend to find the container had crashed and stayed down since Friday. Lesson learned.


The Pattern Underneath All the Patterns

Every one of these lessons has the same root. Compose is quiet about its assumptions. It doesn’t warn you that depends_on has different behavior modes. It doesn’t tell you that your unnamed volume is short-lived. It doesn’t flag that two stacks can’t see each other because their networks have different auto-generated names.

The documentation is thorough if you know exactly what to search for. But when you’re standing up a homelab stack on a Saturday afternoon because you want the thing running, you’re not reading documentation. You’re copying examples and seeing what happens.

So you learn these things the way I learned them. By breaking stuff, reading logs, and filing the lesson away somewhere that actually sticks: your own skin.

If you’re just getting started with Compose, go ahead and burn a few hours on these the first time. You’ll remember them a lot better than if someone just handed you a checklist. But if you want to skip the 3 AM debugging session, now you’ve got the checklist anyway.

Leave a Reply