on this page

There’s a moment, the first time your app grows past one piece, where your terminal turns into a cockpit. One tab running your API. One tab running Postgres. One running Redis. A fourth tab for actually typing commands, except you forgot which docker run flags you used yesterday, so you’re scrolling up through history like an archaeologist. Then you close your laptop, lose the whole arrangement, and get to rebuild it tomorrow from memory.

Docker Compose is the tool that ends that. One file describes every container your app needs. One command starts all of them, wired together. One command stops all of them. You get to go back to writing code.

If “image,” “container,” and “Dockerfile” are still fuzzy, read Docker without the buzzwords first. This guide assumes you know what a container is and picks up from there. The short reminder: an image is the recipe, a container is one running instance of it, and a Dockerfile is the script that builds the image. Compose is the layer on top that runs several of those containers as a group.

What Compose actually is

Compose is a way to write down, in one text file, the containers you’d otherwise start by hand, plus how they connect. Instead of three long docker run commands and a fragile memory of the flags, you get a file named compose.yaml that lives in your repo, gets committed to Git, and works the same on your machine and your teammate’s.

You’ll see the file called two things. The current name is compose.yaml. The older name, still everywhere in READMEs and tutorials, is docker-compose.yml. They’re the same format and Docker reads both, so don’t get rattled when a project uses one and a blog post uses the other. New projects should use compose.yaml.

The other thing Compose quietly does is put all your containers on one private network and let them find each other by name. That single behavior is what makes multi-container apps stop being painful, and it’s the part beginners trip on hardest, so we’ll come back to it.

The shape of a compose file

A compose file is mostly one big block called services. Each service is one container. You give it a name (api, db, redis, whatever makes sense), and under that name you describe how to build or pull it and how to run it.

Here are the keys you’ll use ninety percent of the time:

KeyWhat it does
imagePull a prebuilt image from a registry, like postgres:16 or redis:7
buildBuild an image from a local Dockerfile instead of pulling one
portsMap a port on your machine to a port in the container ("3000:3000")
environmentSet environment variables inside the container, written inline
env_fileLoad environment variables from a file (usually .env) instead
volumesPersist data or mount a folder so it survives the container being recreated
depends_onTell Compose to start one service before another

A service uses image or build, not usually both. image pulls something someone else made (your database, your cache). build is for your own code, the service whose Dockerfile you wrote. We’ll use image for Postgres and Redis, and build for the API.

A real compose file

Here’s a working setup: a Node and Express API you build from your own Dockerfile, a PostgreSQL database using the official image with its data kept in a named volume, and a Redis cache. This is close to what a real small project actually runs locally.

First, the API needs a Dockerfile. Nothing fancy:

# Dockerfile for the Express API
FROM node:20-alpine

WORKDIR /app

# Copy package files first so npm install is cached
# when only your source code changes
COPY package*.json ./
RUN npm ci

# Now copy the rest of the source
COPY . .

EXPOSE 3000
CMD ["node", "server.js"]

Now the compose file that ties the three services together:

# compose.yaml
services:
  api:
    build: .                      # build from the Dockerfile in this folder
    ports:
      - "3000:3000"               # your machine's 3000 -> container's 3000
    environment:
      # "db" and "redis" below are the service names, used as hostnames
      DATABASE_URL: postgres://postgres:devpassword@db:5432/appdb
      REDIS_URL: redis://redis:6379
    depends_on:
      - db
      - redis

  db:
    image: postgres:16            # official Postgres image, no Dockerfile needed
    environment:
      POSTGRES_PASSWORD: devpassword
      POSTGRES_DB: appdb
    volumes:
      - pgdata:/var/lib/postgresql/data   # keep the data when the container dies
    ports:
      - "5432:5432"               # optional: only so you can connect from your laptop

  redis:
    image: redis:7

# Named volumes have to be declared down here too
volumes:
  pgdata:

Read the DATABASE_URL line slowly, because it’s the whole point. The host in that URL is db, not localhost. That db matches the service name two blocks down. Compose sees both containers on the same network and makes db resolve to the database container. Same story with redis://redis:6379. Your API talks to other containers by their service names.

The ports entry on db is there only so you, sitting at your laptop, can connect to Postgres with a tool like psql or TablePlus at localhost:5432. The API does not use that. The API reaches the database over the internal network at db:5432. If you only ever touch the database from inside the app, you can delete that line entirely and nothing breaks.

Where the password and URL come from

Writing POSTGRES_PASSWORD: devpassword straight into the file is fine for a throwaway local database that holds fake data. The moment anything matters, move secrets out of the committed file. That’s what env_file is for.

Make a .env file (and add it to .gitignore so it never gets committed):

# .env
POSTGRES_PASSWORD=devpassword
DATABASE_URL=postgres://postgres:devpassword@db:5432/appdb
REDIS_URL=redis://redis:6379

Then point the services at it:

services:
  api:
    build: .
    ports:
      - "3000:3000"
    env_file:
      - .env
    depends_on:
      - db
      - redis

  db:
    image: postgres:16
    env_file:
      - .env
    volumes:
      - pgdata:/var/lib/postgresql/data

Same containers, but now the secret lives in one ignored file instead of in the file you push to GitHub. If you want the full version of why this matters and how it scales, see Environment variables, explained for real.

The five commands you’ll actually use

You can run a real project with a handful of commands. Here’s what each one does, because running them without knowing what they do is how you end up afraid of your own tools.

# Build any images that need it, then start every service.
# Logs from all containers stream into your terminal.
docker compose up

# Same thing, but detached: starts everything and gives you
# your prompt back. The containers keep running in the background.
docker compose up -d

# Stop and remove the containers and the network Compose created.
# Named volumes survive, so your database data is still there.
docker compose down

# Show the logs from all services. Add -f to follow them live,
# or a service name to see just one.
docker compose logs
docker compose logs -f api

# List the services in this project and whether they're up.
docker compose ps

A couple of things worth knowing. up rebuilds an image only when it thinks it needs to, so if you change your Dockerfile or dependencies and nothing happens, force it with docker compose up --build. And down deliberately leaves your named volumes alone. If you actually want a clean slate, database and all, that’s docker compose down -v, where -v means “remove the volumes too.” Run that one on purpose, never by reflex.

Classic traps

Every one of these has been pasted into a panicked search bar at 1 a.m. Knowing them ahead of time saves you the trip.

The trapWhat you seeThe fix
Using localhost to reach another serviceECONNREFUSED from the API trying to hit the databaseUse the service name: db:5432, not localhost:5432
Forgetting the volume on the databaseYour data vanishes every time the container is recreatedAdd a named volume under db, and declare it in the top-level volumes block
Trusting depends_on too muchAPI starts, but its first query fails because Postgres isn’t ready yetdepends_on waits for the container to start, not for the database to accept connections (see below)
Editing the compose file and not restartingYour change does nothingRe-run docker compose up (add --build if you changed the Dockerfile)

That third one deserves a real explanation, because it surprises people who did everything right. depends_on controls start order. It makes Compose launch the db container before the api container. What it does not do is wait until Postgres has finished booting and is ready to answer queries. A database container can be “started” a full second or two before it actually accepts connections. So your API can come up, fire its first query into a database that isn’t listening yet, and crash.

The honest fix is to make your app survive a database that isn’t ready: retry the connection a few times with a short pause before giving up. Most database client libraries can do this, and a few lines of retry logic is more reliable than any startup-ordering trick. Compose does support a fancier depends_on with condition: service_healthy tied to a health check, but a beginner is better served by an app that simply retries. Either way, the lesson is the same: “the container started” and “the database is ready” are two different events, and your app has to handle the gap.

Going deeper

This guide gave you the working version of two big topics on purpose. Volumes are how data outlives a container, with real depth underneath: named volumes versus bind mounts, where the bytes live on disk, how to back them up. Networking is how containers find each other, and the service-name trick here is only the surface. Both get their own full treatment in the volumes guide and the networking guide in this series, for when you want the why behind the keys you just used.

Tying it back to shipping

The compose file you wrote is more than a local convenience. It’s the clearest description anywhere of what your app actually needs to run: this API, this version of Postgres, this cache, connected this way. That description is exactly what you reach for when it’s time to ship.

A platform like Railway

Heads up. You're leaving raindev.fyi

This link heads to railway.app, an external site we don't control. Cool to keep going?

Continue
or Render

Heads up. You're leaving raindev.fyi

This link heads to render.com, an external site we don't control. Cool to keep going?

Continue
will host your API alongside a managed Postgres and Redis, and your compose file tells you exactly which environment variables and connections to recreate over there. Push your repo to GitHub

Heads up. You're leaving raindev.fyi

This link heads to github.com, an external site we don't control. Cool to keep going?

Continue
, connect it to a host, and you’re rebuilding the same wiring in production. The stack you start with one command is the stack you ship, minus the three hours you spent fighting your terminal to get there.