Docker Compose in Practice: Networks, Volumes, and Environment Variables
A complete docker-compose.yml walkthrough for how containers talk to each other over a network, how volumes keep your data alive, and where environment variables belong so they don't leak or get mixed up.
A side project rarely stays down to a single container for long. You end up with one for the frontend, one for the API, one for the database, maybe a Redis for caching on top. Spin each of those up by hand with docker run and you have to keep a pile of details in your head: what's the database's IP, which port needs to be open, which environment variable holds the password, which folder the data should mount to. Miss one and either the service won't start or your data quietly disappears.
That's exactly the problem Docker Compose solves: it takes everything you'd otherwise have to remember about wiring multiple containers together and puts it in a single docker-compose.yml, so one command brings the whole stack up. But just knowing image and build under services isn't enough. What actually determines whether a multi-container project stays maintainable comes down to three things people tend to gloss over: how containers find each other (networks), how data survives a container getting rebuilt (volumes), and which layer your config values belong in so they don't leak or collide (environment variables). This post works through all three and builds up to a working multi-container project.
Starting from the smallest working Compose file
Here's about as bare-bones as it gets — an API service plus PostgreSQL:
services:
api:
build: ./api
ports:
- '3000:3000'
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: exampleRun docker compose up -d and both containers come up. Looks simple enough, but two questions are already lurking under the surface: how does api actually reach db? And if the containers get torn down and rebuilt on a reboot, does the data in db survive? Let's take these one at a time.
Networks: how containers find each other
When you start containers by hand with docker run, they can't see each other by default — you'd have to attach them to the same network with --network yourself, then figure out IPs or extra config just to get them talking. Compose skips all of that: as long as services are defined in the same docker-compose.yml, they land on the same auto-created bridge network by default, and each service's name doubles as a DNS hostname you can use directly.
So in the example above, api doesn't need to know db's actual IP — it just connects using the name db:
postgresql://postgres:example@db:5432/postgres
That db isn't an arbitrary variable — it's literally the service name from docker-compose.yml, and Compose registers it as a DNS record on the network automatically. That's also why you'll rarely see a hardcoded IP anywhere in a multi-container setup — everything just addresses everything else by service name.
As a project grows, you'll usually want to split services into groups so containers that shouldn't talk to each other actually can't. That means declaring your own networks instead of letting everything land in the default one:
services:
web:
build: ./web
ports:
- '80:80'
networks:
- frontend
api:
build: ./api
networks:
- frontend
- backend
db:
image: postgres:16
networks:
- backend
networks:
frontend:
backend:web only sits on frontend, db only sits on backend, and the two can't reach each other. api is the only one attached to both, acting as the bridge in between. Even if web gets compromised, the attacker still can't reach db — they're not even on the same network. That's a much cleaner guarantee than trying to patch the hole with firewall rules after the fact.
Volumes: keeping data from vanishing into thin air
Containers are stateless by design — that's core to how Docker works, and it's also the easiest thing to trip over. Run docker compose down and up again, or rebuild an image, and the container's filesystem starts fresh: database files, uploaded images, all of it resets to zero by default. To make data outlive the container itself, you need a volume.
The one you'll reach for most is a named volume, where Docker manages the actual storage location for you. It's a good fit for databases, where you don't need to poke around the files directly:
services:
db:
image: postgres:16
volumes:
- db-data:/var/lib/postgresql/data
volumes:
db-data:db-data:/var/lib/postgresql/data mounts a volume named db-data at the path where PostgreSQL actually keeps its data inside the container. Delete and rebuild the db container entirely, and as long as db-data still exists, the data is still there.
The other kind is a bind mount, which maps a folder on the host directly into the container. It's what you want during development, when you'd rather see code changes reflected instantly instead of rebuilding every time:
services:
api:
build: ./api
volumes:
- ./api:/appDon't mix these two up: a named volume is managed by Docker — you never see where it actually lives on the host — and it's the right choice for databases. A bind mount is a mirror of a host folder, and it's the right choice for mounting code during development. One gotcha worth calling out specifically: docker compose down leaves volumes alone, but adding -v (docker compose down -v) wipes named volumes along with everything else. Fat-finger that flag while cleaning up an environment and your database data is genuinely gone — think twice before you run it.
Environment variables: which layer they belong in
Environment variables in a Compose project actually serve two completely different purposes, and conflating them is an easy way to confuse yourself. Let's separate them up front: one substitutes values into the docker-compose.yml file itself, the other is what actually gets injected into the container for your program to read.
If your project root has a file named .env, Compose reads it automatically and uses its values to fill in any ${...} references inside docker-compose.yml:
# .env
POSTGRES_PASSWORD=example
API_PORT=3000
services:
api:
ports:
- '${API_PORT}:3000'
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}For variables that actually need to be injected into the container so your code can read them, you can list them directly under environment like above, or point to a separate file with env_file to bring in a whole batch at once — handy when you have a lot of variables, or you'd rather keep them separate from the .env that Compose itself uses:
services:
db:
image: postgres:16
env_file:
- .env.db# .env.db
POSTGRES_PASSWORD=example
POSTGRES_DB=myapp
POSTGRES_USER=myapp
Whichever approach you use, one rule doesn't bend: any file holding passwords or keys (.env, .env.db, and the like) belongs in .gitignore and nowhere near version control. For team projects, commit a .env.example instead — list the variable names with no real values, so everyone knows what to configure and fills in their own values locally.
Putting it all together
Combine all three pieces — layered networks, persistent data, and separated environment variables — and a full project looks something like this:
services:
web:
build: ./web
ports:
- '80:80'
networks:
- frontend
depends_on:
- api
api:
build: ./api
env_file:
- .env.api
networks:
- frontend
- backend
depends_on:
- db
- redis
db:
image: postgres:16
env_file:
- .env.db
volumes:
- db-data:/var/lib/postgresql/data
networks:
- backend
redis:
image: redis:7
networks:
- backend
networks:
frontend:
backend:
volumes:
db-data:web only talks to api; api can reach both db and redis; db's data survives rebuilds thanks to the db-data volume; and passwords live in .env.api and .env.db instead of being hardcoded into docker-compose.yml. This file itself is safe to commit — there isn't a single real secret in it.
A few traps worth knowing about
Once you've got the basics down, a handful of gotchas are worth knowing in advance, so you don't burn an afternoon debugging them later:
depends_ononly guarantees start order, not that a service is actually ready.apidepending ondbmeansdbstarts first — it doesn't mean PostgreSQL inside it is already accepting connections. There's a gap while it initializes. If you actually need to wait for readiness, pairdepends_onwithhealthcheckandcondition: service_healthy— relying ondepends_onalone is a common way to see the API fail to connect to the database on first boot..envis only picked up automatically when it sits right next todocker-compose.yml. Move it into a subfolder or rename it, and Compose won't find it on its own — you'll need--env-fileto point at it explicitly.- Volume name collisions across projects can cross-contaminate data. Compose prefixes volumes with the folder name by default, but if you manually give two different projects the same volume name, they can end up unintentionally sharing data. When running multiple projects, use
COMPOSE_PROJECT_NAMEto keep them explicitly separated.
Wrapping up
What Docker Compose actually solves isn't just "start a bunch of containers with one command" — it turns three things you'd otherwise have to keep in your head — how containers reach each other, how data survives a rebuild, and where sensitive config lives — into rules written down in a file, one that can go through version control and be shared across a team. The throughline to remember: networks decide who can reach whom, volumes decide whether data survives a container rebuild, and environment variables decide which layer your sensitive config sits in so it doesn't leak.