← All posts
DevOps8 min readProject: Docker setups across my projects

One command, a whole stack: running many services with Docker Compose that start in the right order

Each of my backend projects needs a database, a cache, the app, and admin tools. With Docker Compose, all of it starts with one command, in the right order, on any machine. Here is how I structure those stacks: health checks, dependency order, networks vs. host mode, port planning on a shared server, and the project-name trap that made a production stack destroy the dev one.

#Docker#Docker Compose#PostgreSQL#Redis#DevOps#Infrastructure

The problem

Every project needed several services running together, and setting them up by hand on each machine and server was slow, fragile and different every time.

The result

Each project starts its full stack with one command, services wait for their dependencies to be healthy, and several stacks live side by side on one server without colliding.

Stack

DockerDocker ComposePostgreSQLRedispgAdminPrisma StudioNestJS

The problem

Almost every backend I build needs the same family of services:

  • PostgreSQL for the data,
  • Redis for locks, sessions or caching,
  • the app itself (NestJS, in most of my projects),
  • admin tools: Prisma Studio or pgAdmin to look inside the database.

Before Docker, that meant installing PostgreSQL and Redis on every laptop and every server, with different versions, different passwords and different ports. "It works on my machine" was a daily sentence. Onboarding someone new took a day. Moving to a new server took longer.

And I don't have one project. I have several, often running on the same server at the same time. Each one needs its own database, its own Redis and its own ports, without stepping on the others.

Docker Compose solved all of it, once I learned to structure the stacks properly.

The details

One file per stack

A typical project has a docker-compose.yml describing every service:

services:
  postgres:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}']
      interval: 5s
      timeout: 5s
      retries: 10

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    command: ['redis-server', '--requirepass', '${REDIS_PASSWORD}']
    volumes:
      - redis_data:/data
    healthcheck:
      test: ['CMD', 'redis-cli', '-a', '${REDIS_PASSWORD}', '--no-auth-warning', 'ping']
      interval: 5s
      timeout: 3s
      retries: 10

  app:
    build: .
    restart: unless-stopped
    env_file: .env
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy

  studio:
    build: .
    command: ['yarn', 'prisma', 'studio', '--port', '5555', '--browser', 'none']
    depends_on:
      postgres:
        condition: service_healthy

volumes:
  postgres_data:
  redis_data:

Then:

docker compose up -d      # the whole stack, in the background
docker compose logs -f app
docker compose down       # stop everything (data is kept, see below)

I also add the commands to package.json (docker:up, docker:down), so nobody has to remember flags.

The challenges

"Started" is not "ready"

Docker's basic depends_on only waits for a container to start. PostgreSQL takes a few seconds after starting before it accepts connections. An app that connects immediately crashes, restarts, crashes again… and the logs fill with connection errors that hide real problems.

Containers talking to each other

Inside Docker, localhost means the container itself, not your machine and not the database container. A connection string that works on the host fails inside a container.

Several stacks on one server

On a shared server, port 5432 is probably already taken by someone else's PostgreSQL, and port 3000 by someone else's app. Every stack needs its own ports, and every team member needs to know which ones are free.

Two compose files, one name

I keep separate files for local development and production (docker-compose.yml and docker-compose.prod.yml). One day, bringing up the production stack tore down the development containers on the same machine, and the other way around, even though the container names were different.

The solution

1. Health checks + condition: service_healthy

Every infrastructure service gets a health check that tests real readiness:

  • PostgreSQL: pg_isready -U <user> -d <db>, which asks the server whether it accepts connections,
  • Redis: redis-cli ping, which must answer PONG (with the password when Redis requires one).

And dependents wait for healthy, not just started:

depends_on:
  postgres:
    condition: service_healthy

Now docker compose up starts PostgreSQL and Redis, waits until both report healthy, and only then starts the app and Prisma Studio. No crash loops, clean logs, every time.

2. Talk to services by name

On Compose's default network, each service is reachable by its service name:

DATABASE_URL=postgres://user:pass@postgres:5432/app
REDIS_URL=redis://:pass@redis:6379

postgres and redis here are the service names from the YAML, resolved by Docker's internal DNS. Two related rules saved me hours:

  • Inside a container, localhost is the container. Use the service name for other containers.
  • To reach something running on your host (for example another project's service that isn't in this compose file), use host.docker.internal, not 127.0.0.1. I leave a comment next to that variable every time, because everyone falls into it once.

3. Reuse the app image for tools

Prisma Studio doesn't need its own image. The studio service builds from the same Dockerfile as the app and simply runs a different command. Same Prisma version, same schema, no extra maintenance, and a database UI one URL away whenever the stack runs.

For projects where I want a full SQL tool, pgAdmin joins the stack (see "Where the data lives" for how it comes preconfigured).

4. Ports: publish only what you need, and plan them

Containers on the same network talk to each other without published ports. A port only needs to be published (ports:) if something outside Docker must reach it: your browser, a reverse proxy, a tool on the host.

On a shared server, I keep a small reserved range per project and write it in the compose file:

# Bridge network with explicit port publishing: this host already has other
# tenants bound to 5432 and 3000. 7101/7102 are this project's reserved ports.
ports:
  - '7102:5432'

The left side (host port) is unique on the server; the right side (container port) stays the default. Inside the network, the app still connects to postgres:5432.

5. Bridge network vs. host network

Compose supports network_mode: host, where a container shares the host's network directly. It's tempting because "everything is just localhost". I used it in one project and moved away from it for new stacks:

Bridge network (default)Host network
Isolationeach stack has its own networkeverything shares the host's ports
Port conflictsonly on published portsevery port the app opens
Service names (postgres, redis)✔ work✘ use localhost / host IPs
Explicit, readable config✔ ports listed in YAMLports hidden in app config

On a shared server, the bridge network with a named network per stack is safer and easier to read:

services:
  postgres:
    networks: [myapp_prod_net]
  app:
    networks: [myapp_prod_net]
networks:
  myapp_prod_net:

6. The project-name trap

Here's the bug where production destroyed development. Compose groups containers into a project, and by default the project name is the folder name. My docker-compose.yml (dev) and docker-compose.prod.yml (prod) lived in the same folder, so both were the same project. And both defined services called postgres and redis.

So when I ran docker compose -f docker-compose.prod.yml up -d, Compose saw "project X, service postgres, new configuration" and replaced the dev containers with the prod ones. Running the dev file did the reverse.

The fix is one line at the top of the production file:

# Explicit project name. Without it, Compose uses the folder name, which is
# exactly what docker-compose.yml (local dev) also uses. Two files sharing one
# project name AND the same service keys make Compose treat one as an update
# of the other. This keeps the two stacks fully separate.
name: myapp_prod

Different project names give completely separate stacks, networks and volumes, even in the same folder. The long comment is there on purpose: the bug is invisible until it deletes something.

7. Restart policies

Long-running services use restart: unless-stopped (or always on dedicated servers). After a server reboot or a crash, the whole stack comes back by itself, still in the right order thanks to the health checks.

Lessons learned

  • Describe the whole stack in one file. Database, cache, app and tools: one command to start, one to stop.
  • Wait for healthy, not started. Health checks + condition: service_healthy remove startup crash loops.
  • Use service names, not localhost. Inside a container, localhost is the container itself.
  • host.docker.internal reaches the host. Comment it every time you use it.
  • Reuse images for tools. Prisma Studio from the app image costs nothing to maintain.
  • Publish only the ports you need, from a planned range. Especially on shared servers.
  • Prefer bridge networks over host mode. Isolation and readable config beat convenience.
  • Name your Compose projects explicitly. Two files in one folder share a project name by default, and one can replace the other.

FAQ

How do I make a container wait for PostgreSQL to be ready in Docker Compose?

Add a health check to the PostgreSQL service (for example with pg_isready) and use depends_on with condition: service_healthy in the dependent service. Compose then waits until the database reports healthy before starting it.

Why can't my container connect to localhost?

Inside a container, localhost refers to the container itself. Use the other service's name (like postgres) on the Compose network, or host.docker.internal to reach services running on your host machine.

Why did docker compose up with another file stop my other containers?

Both files probably share the same project name (by default the folder name) and define services with the same names, so Compose treats them as one project. Set name: at the top of one file, or use -p, to keep them separate.

Should I use network_mode: host in Docker Compose?

Usually not. Host mode removes network isolation and can cause port conflicts with everything else on the machine. The default bridge network with explicit published ports is safer, especially on shared servers.

Facing a similar problem?

I help teams fix performance, backend and reliability issues in production.

Let's talk
Welcome to My WebSite please enjoy and have funWelcome to My WebSite please enjoy and have funWelcome to My WebSite please enjoy and have funWelcome to My WebSite please enjoy and have fun

Componentity

Thanks for visiting my portfolio. Every project here reflects my passion for building and learning. If you’re interested in working together, have a question, or just want to connect—don’t hesitate to reach out. I'm always open to meaningful conversations and new ideas.

© 2026 Zakariya Zouazou. All Rights Reserved.

Contact Us