HostiServer
2026-09-21 11:18
Docker Compose: Multiple Services Together
📚 "Docker on a VPS" series, part 2 of 5:
- Docker on a VPS: installation and your first container
- Docker Compose: multiple services together ← you are here
- Docker in production
- Docker + CI/CD: auto-deploy with GitHub Actions / GitLab CI
- Gitea / Forgejo in Docker: a full self-hosted stack
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:
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.
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: .envin 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 isdepends_on: db: condition: service_healthytogether with a configuredhealthcheck: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?
downremoves containers and networks but leaves named volumes in place — the database's data is preserved.down -vadditionally removes the volumes, and with them all the data inside. On a production server,-vshould 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
.envfile next todocker-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 isdocker 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.ymlwith 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, usemem_limitandcpuson each service; more on this in the series' next article, dedicated to production configuration.