Community
0
HostiServer
2026-09-21 11:18

Docker Compose: Multiple Services Together

⏱️ Reading time: ~11 minutes | 📅 Updated: September 2026

Docker Compose: multiple services together

In the first article of the series, we learned to run a single container with the docker run command. A real application rarely lives alone: it comes with a database, a cache, and often a message queue too. Three services already means three separate docker run commands, each with up to a dozen flags, plus a network you need to create by hand beforehand: docker network create app-net. Startup order matters here, but nothing guarantees it.

The problem becomes especially noticeable after a server reboot. Containers with restart: unless-stopped come back up on their own, but Docker doesn't know which service is supposed to start first. If the app comes up before the database, the very first request fails with ECONNREFUSED — and you're left manually restarting the app once the database is ready.

Docker Compose solves this by changing the way of thinking itself. Instead of a sequence of commands, you describe the desired state in a docker-compose.yml file: which services should exist, how they relate, what depends on what. Compose takes over startup order, networks, and volumes.

One technical point is worth clearing up right away. The modern command is docker compose (two words, a Go plugin built into Docker Engine since version 20.10+). The old docker-compose (with a hyphen, a separate Python utility, the v1 line) was officially deprecated in May 2023. To check which version you have:

docker compose version

The expected output is something like Docker Compose version v2.x.x. If the command isn't found, the plugin installs separately: apt install docker-compose-plugin.

2. The structure of docker-compose.yml

2.1 About the version: line

Most older tutorials start the file with version: "3.8". In Compose v2, this line is ignored with a deprecation warning — you can safely remove it, and the file gets shorter with no extra noise in the logs.

2.2 services: — a key, a name, and DNS all at once

The name of each service under the services: key plays a double role: by default it forms the container's name (<project>-<service>-1), and at the same time it's the DNS name other containers on the same network see it by. Setting container_name: my-app explicitly makes the name predictable, but then the service can't be scaled with docker compose up --scale app=3 — Compose can't create multiple containers with the same fixed name.

2.3 image: or build:

image: postgres:16-alpine pulls a ready-made image from a registry. build: . builds an image from the Dockerfile in the current directory. If the Dockerfile isn't at the project root, it's specified explicitly:

build:
  context: ./app
  dockerfile: Dockerfile.prod

2.4 environment: — two ways to write it

Environment variables can be written as a map (DATABASE_URL: postgres://...) or as a list (- DATABASE_URL=postgres://...). The map format reads more easily when there are a lot of variables. The ${VAR} expression in either format is substituted from a value in the .env file or from the shell environment at the moment Compose runs.

2.5 ports: — only what genuinely needs to be external

The format "3000:3000" means host port : container port. Without a ports: section, a service stays unreachable from outside, but still reachable by other services on the same Compose network. For a database in production, ports: is generally unnecessary and unwanted — there's no reason to expose a DBMS port to the internet unless it's absolutely required.

2.6 volumes: on a service

The short form is pgdata:/var/lib/postgresql/data. The long form lets you specify the type explicitly — type: volume for a named volume or type: bind for a bind mount. A named volume used in a service needs a matching block at the top level of the file: volumes: pgdata: — this is exactly where Compose takes over managing its lifecycle.

2.7 networks: — your own default network

Without an explicit declaration, Compose creates a network named <project>_default and connects every service to it. A custom network is set up like this: in the service, networks: [app-net], and at the bottom of the file, networks: app-net: (an empty block means a bridge network with default settings).

2.8 restart: — four options

Value Behavior
no The default — never restarts
always Always restarts, even after docker compose stop
unless-stopped Restarts, except when stopped manually
on-failure Only if the process exited with a non-zero code

For a production default, unless-stopped is the one worth picking: the service survives a host reboot, but won't try to come back up if you stopped it deliberately.

2.9 command: and entrypoint:

Both directives override whatever's set in the Dockerfile via CMD or ENTRYPOINT. A typical use is a separate dev mode: command: npm run dev instead of the production startup command, with no need to maintain two different Dockerfiles.

3. A real stack: Node.js + PostgreSQL + Redis

Let's put it all together in one working example — a Node.js app, a PostgreSQL database, and a Redis cache:

services:
  app:
    build: .
    ports: ["3000:3000"]
    env_file: .env
    environment:
      DATABASE_URL: postgres://user:${DB_PASSWORD}@db:5432/mydb
      REDIS_URL: redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
    networks: [app-net]
    restart: unless-stopped

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: mydb
    volumes: [pgdata:/var/lib/postgresql/data]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user -d mydb"]
      interval: 5s
      timeout: 5s
      retries: 10
    networks: [app-net]
    restart: unless-stopped

  cache:
    image: redis:7-alpine
    command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru
    networks: [app-net]
    restart: unless-stopped

volumes:
  pgdata:

networks:
  app-net:

A Docker Compose stack: app, db, and cache on one app-net network with the pgdata named volume

3.1 The POSTGRES_* variables

The official postgres image reads POSTGRES_USER, POSTGRES_PASSWORD, and POSTGRES_DB only once — during the very first cluster initialization on an empty volume. On subsequent runs, once the volume already exists, these variables are ignored. Changing the password later by changing the variable won't work — you'll need to run ALTER USER inside the already-running database.

3.2 depends_on with the service_healthy condition

The depends_on: db: condition: service_healthy section means app won't start until the database's healthcheck reports success. A plain depends_on: [db], with no condition, only waits for the database container's process to start — and at that moment PostgreSQL may still be initializing its cluster and not accepting connections. This is exactly what causes ECONNREFUSED on the first request after the stack comes up.

3.3 Why retries: 10, not 5

With interval: 5s and retries: 10, Compose is willing to wait up to 50 seconds total for the database to become healthy. That's not overkill: PostgreSQL's first cluster initialization alone can take 10-20 seconds, and with retries: 5 (25 seconds), Compose might mark the database unhealthy before it's actually come up.

Docker Compose stack startup order: app waits for db to become healthy before starting

3.4 redis:7-alpine and the memory limit

The redis:latest tag means the image's contents can change at any moment; redis:7-alpine pins a major version and weighs around 40 MB. The --maxmemory 128mb flag in command: is critically important in production: without an explicit limit, Redis tries to use all the host's available memory by default, and with active caching this can trigger an OOM kill on neighboring containers.

3.5 How services find each other by name

The @db:5432 segment in DATABASE_URL works thanks to Compose's built-in DNS: the service name db resolves to the internal IP address of that container. This only works for services on the same networks: section, and doesn't work from the host — from the server's terminal, the name db means nothing.

4. .env — variables and secrets

4.1 Automatic pickup

A .env file in the same directory as docker-compose.yml is picked up by Compose automatically, with no extra configuration:

DB_PASSWORD=supersecret123
NODE_ENV=production

4.2 Two different mechanisms — don't mix them up

It's easy to confuse two similar but distinct ways values reach a container:

  • ${DB_PASSWORD} inside the compose file itself — the substitution happens while Compose parses the YAML; the value ends up in the configuration, but not automatically in the container's environment variables.
  • env_file: .env in a service section — the whole file is passed into the container as environment variables, accessible to the app via, say, process.env.DB_PASSWORD.

4.3 Verifying the substitution

docker compose config

This command prints the final compose file after all ${VAR} substitutions have happened — a convenient way to confirm the values from .env actually got picked up, before the stack even starts.

4.4 Priority of value sources

From highest priority to lowest: a shell environment variable → a value from the .env file → a default in the expression itself, if set as ${VAR:-default}.

4.5 .gitignore

The .env file must be added to .gitignore. A sensible pattern is *.env and .env*, which also excludes .env.local and .env.production, files that often get added later.

4.6 .dockerignore too

.env needs to be in .dockerignore as well — otherwise a COPY . . in the Dockerfile will copy the secrets straight into the image. Easy to check:

docker run --rm my-app cat /app/.env

If the command prints the file's contents, the secret is already "baked" into the image, and deleting the file in the next commit won't fix it: the previous image layer still contains it.

4.7 File permissions

chmod 600 .env restricts reading the file to its owner only — enough for a single VPS. For scenarios more complex than one server (Swarm, several teams with different trust levels), it's worth looking at Docker Secrets or HashiCorp Vault.

5. Networks: how services find each other

5.1 Isolation per project

Each Compose project — typically a separate directory — gets its own isolated network. The app service from one project can't see the db from another project, even if both are running on the same host.

5.2 The project name

By default, the project name comes from the directory name. It can be set explicitly with the docker compose -p myproject up flag, or with a name: myproject line at the top level of the compose file — this affects container and network names, and thus whether two stacks can "see" each other.

5.3 The minimum of open ports

The rule is simple: ports: only goes on services that genuinely need access from outside. In the example above, that's app (3000:3000); db and cache have no such block. An extra "5432:5432" line on the database section exposes PostgreSQL to the entire internet — one of the most common causes of a database getting compromised on a VPS.

5.4 Communication between different stacks

If two independent Compose projects need to talk to each other — say, a separate monitoring stack that needs to see the app's metrics — you connect to an already-existing network:

networks:
  monitoring-net:
    external: true

6. Key commands

Command What it does
docker compose up -d Start all services in the background; on first run it creates networks and volumes, pulls images, and runs build:; on subsequent runs it only restarts changed services
docker compose up -d --build Force a rebuild of images before starting — needed after changes to the Dockerfile or code, otherwise Compose uses the cached image
docker compose up -d --no-deps app Restart only app, without touching db and cache — handy when deploying a new version of the app
docker compose down Stop and remove containers and networks; named volumes stay — the data in pgdata is preserved
docker compose down -v The same, plus removing named volumes — irreversible in production: pgdata disappears along with all the database's data
docker compose stop / start Stop or start without removing containers — faster than down+up, volumes and networks stay in place
docker compose restart app Restart a specific service; the compose file isn't re-read in the process — applying config changes needs up -d
docker compose ps Status of all services: name, image, state, ports, health
docker compose logs -f app Follow logs for a specific service; with no service name, logs for everything at once with color-coded prefixes; --tail=100 limits the output to the last lines
docker compose exec db psql -U user -d mydb Open psql inside the database container for debugging
docker compose run --rm app npm run migrate Run a one-off command in a new container of the app service and remove it right after — typical for DB migrations
docker compose pull && docker compose up -d Update images from the registry and restart — the core deployment pattern for CI/CD (article 4 of the series)

7. Conclusion

Now the entire stack — the app, the database, the cache — is described in a single file. git clone plus docker compose up -d reproduce an identical environment on any server with Docker in a minute or two, with no manual step and no "what order do I start these in" question.

A few things remain that docker-compose.yml doesn't solve on its own. HTTPS and SSL certificates — the stack needs a reverse proxy like Nginx or Traefik in front of it. Multiple domains on one IP address is also handled at the proxy level, not by Compose. Resource limits (mem_limit: 512m, cpus: "0.5") are still missing — and without them, one service that spirals out of control can eat up all the server's memory from the others. And logs are currently only accessible through docker compose logs, with no rotation and nothing preserved once a container is removed.

These exact gaps are the topic of the next article: Nginx or Traefik goes in front of the stack, a Let's Encrypt certificate shows up, and autostart moves from a manual docker compose up -d to a systemd unit that brings the stack back up after a server reboot on its own, with no human involved.

📚 Series navigation:
You're reading part 2 of 5, "Docker Compose: multiple services together."
Previous: Part 1. Docker on a VPS: installation and your first container ←
Next: Part 3. Docker in production →

🚀 A VPS for your Compose stack

Several containers at once isn't "one or two cores just in case" anymore: the app, the database, and the cache run in parallel, each holding its own memory. Hostiserver gives you resources sized exactly for a stack like this.

🖥️ Dedicated Servers

  • From $90/mo, full control over the hardware for heavier production stacks
  • No resource neighbors: the database's healthcheck doesn't depend on someone else's load
  • 24/7 support: we'll help with networks, volumes, and system-level stack autostart

💻 Cloud (VPS) Hosting

  • From $19.95/mo, KVM isolation, dedicated vCPU and RAM
  • NVMe disks: PostgreSQL and Redis with disk that never becomes the bottleneck
  • Enough RAM from the first plan: app + db + cache running together with no swapping
  • Easy to scale: more memory for a new service, right in the same compose file

💬 Not sure which option you need?
💬 Reach out — we'll help you figure it out!

Frequently Asked Questions

What's the difference between docker compose (v2) and docker-compose (v1)?

docker-compose (with a hyphen) is a separate Python utility, the v1 line, officially deprecated in May 2023. docker compose (two words) is a Go plugin built into Docker Engine starting from version 20.10+, and that's the one being actively developed now. The compose file syntax is essentially unchanged; the difference is mostly in the run command itself and in performance.

The app crashes with ECONNREFUSED right after starting — what's wrong?

The most common cause is a plain depends_on: [db] with no condition: Compose only waits for the database container's process to start, not for it to be ready to accept connections. The fix is depends_on: db: condition: service_healthy together with a configured healthcheck: on the database service itself.

Does a modern compose file need the version: line?

No. In Compose v2 it's ignored with a deprecation warning and has no effect on how the file works. It can be removed from any project with no consequences — the file just gets shorter.

docker compose down vs down -v — what's the difference?

down removes containers and networks but leaves named volumes in place — the database's data is preserved. down -v additionally removes the volumes, and with them all the data inside. On a production server, -v should only be used deliberately, after confirming a backup genuinely exists.

How do I pass a database password without storing it in git?

The password goes into an .env file next to docker-compose.yml, and that file gets added to .gitignore (and to .dockerignore, so it doesn't end up in the image). In the compose file, the value is substituted via ${DB_PASSWORD}. For scenarios spanning multiple servers or teams, it's worth considering Docker Secrets or an external store like HashiCorp Vault.

Can I open the database port externally for debugging?

Technically yes, by adding ports: ["5432:5432"], but it's not advisable in production — the port becomes reachable from the entire internet. A safer route for debugging is docker compose exec db psql -U user -d mydb, which opens a psql session right inside the container without exposing a port, or a temporary SSH tunnel for the duration of the work.

How much memory should I allocate to each service?

In a basic docker-compose.yml with no explicit limits, any service can use all of the host's available memory — that's exactly why Redis in the example above runs with --maxmemory 128mb. For system-level limits at the Compose level, use mem_limit and cpus on each service; more on this in the series' next article, dedicated to production configuration.

Contents

Share this article

MANAGED VPS STARTING AT

$19 95 / mo

NEW INTEL XEON BASED SERVERS

$80 / mo

CDN STARTING AT

$0 / mo

 

By using this website you consent to the use of cookies in accordance with our privacy and cookie policy.