Guide

Compose a Multi-Service App

Part 3 of 7By Amith Kumar7 min read
Part 3 of 7Docker in Production: A Hands-On Guide
  1. 1The Docker Engine and Your First Container
  2. 2Images and Dockerfiles: Build a Small App Image
  3. 3Compose a Multi-Service App
  4. 4Volumes and Networks That Persist and Connect
  5. 5Resource Limits and Restart Policies
  6. 6Logging and Observability
  7. 7Security and Hardening
Verified 22 Jul 2026 · Ubuntu 24.04 LTS · Docker 29.1.3

Run an app and its datastore with Docker Compose

Docker Compose describes a multi-service stack in one YAML file and starts the whole thing with a single command. This chapter uses Docker Compose to run the cadence app beside a Redis datastore on Ubuntu 24.04 LTS: two services, one private network, health checks on both, and the app reachable on loopback. Every status and response below came off the live server.

Key takeaways
  • A compose.yaml file declares each service, its image or build, ports, and dependencies in one place you can commit.
  • docker compose up -d creates the network, the volume, and every container in dependency order.
  • A health check plus depends_on: condition: service_healthy makes the app wait for Redis to actually accept connections, not just start.
  • Services reach each other by service name over the private network Compose creates, so the app connects to redis:6379 with no IP addresses hard-coded.

Running each container by hand with a long docker run line works for one service. With two or more it becomes a script you have to keep in your head. Compose moves that description into a file, so the stack is reproducible from git and readable by the next person.

Prerequisites

Before you start
  • An Ubuntu 24.04 LTS server with Docker 29.1.3 and the Compose plugin (docker compose version).
  • The cadence:1.0 image from the previous chapter, or its Dockerfile to build.

Write the compose file

The file lists two services. The app builds from the local Dockerfile and publishes port 3000 to loopback. The redis service uses the official image and turns on append-only persistence. Both carry a health check, and the app waits for Redis to report healthy before it starts.

services:
  app:
    build: .
    image: cadence:1.0
    environment:
      REDIS_ADDR: redis:6379
    ports:
      - "127.0.0.1:8090:3000"
    depends_on:
      redis:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
      interval: 10s
      retries: 3
  redis:
    image: redis:7-alpine
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - redisdata:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      retries: 3

volumes:
  redisdata:

The REDIS_ADDR: redis:6379 line is the whole trick of service networking. The app connects to the name redis, and Compose resolves it to the Redis container on the private network. No IP address appears anywhere.

Bring the stack up

One command creates the network, the named volume, and both containers in the right order.

docker compose up -d
docker compose up creating the named volume and network, starting Redis first, waiting for it to be healthy, then starting the app

Compose creates redisdata and the cadence_default network, starts Redis first, waits for it to report healthy, then starts the app. Because the app declared depends_on with condition: service_healthy, it never boots against a Redis that is still loading. The ordering is in the file, not in a sleep you had to guess.

Confirm both are healthy

Ask Compose for the state of the stack.

docker compose ps
docker compose ps showing both the app and Redis services Up and healthy, the app mapped to 127.0.0.1 port 8090 and Redis on 6379 with no host mapping

Both rows read Up (healthy). The app shows its port mapped to 127.0.0.1:8090, and Redis shows 6379/tcp with no host mapping, because only the app needs to reach it. A health check is more honest than a running flag: it runs a real command inside the container, wget against the app's own /health and redis-cli ping for Redis, so healthy means the service actually answered.

Checkpoint

Both rows in docker compose ps should read Up (healthy), not just Up. If the app is stuck starting, it is waiting on Redis by design, so check docker compose logs redis first: the app only boots once Redis passes its health check.

Curl the app through its published port

The app is on loopback port 8090. Ask it two questions.

curl http://127.0.0.1:8090/api/status
curl http://127.0.0.1:8090/health
The app answering on loopback with JSON naming the container and a hit counter read from Redis, and a health route returning ok with redis ok

The status route returns JSON naming the app, its environment, the container ID, and a hit counter it reads from Redis. The health route returns ok with redis: ok, which means the app opened a connection to the redis service and got a reply. That round trip, app to datastore by name, is the thing Compose set up for you.

See it live in a browser

Now the payoff. Open the slice in a browser, and here it is, live: the containerized app serving its own page. The page-view counter climbs on every refresh because the app reads and increments it in Redis, the container ID it shows is the app container's own hostname, and the stand-up notes come straight from the datastore over the private network. This is the destination from chapter one, now running for real: a small app and its datastore, both in containers, brought up from a single file you can commit.

Checkpoint

The app's page loading in your browser, with a counter that goes up each time you refresh, is the from-zero payoff of this series so far: a multi-service app you brought up from one Compose file, serving live. If the page loads but the counter never changes, the app is not reaching Redis, so check that REDIS_ADDR names the redis service and that docker compose ps shows Redis healthy.

The Cadence stand-up board served by the app container, showing its Redis-backed page-view counter, the container ID, and live stand-up notes, open in a browser at cadence.example.com, the series payoff running live

Change the stack and re-apply

The file is the source of truth, so you change the stack by editing it and running docker compose up -d again. Compose compares the file to what is running and recreates only the services that changed, leaving the rest alone. Edit the app's environment and re-run, and Redis keeps serving while just the app container is replaced.

Two more commands round out daily use. docker compose logs -f app follows one service's output as it happens, and docker compose down stops and removes the whole stack when you are done, keeping the named volume unless you add -v. Everything you did by hand for a single container now applies to the stack as a unit, driven from one file you can review in a pull request.

Frequently asked questions

What is the difference between docker run and Docker Compose?

docker run starts one container from one image. Docker Compose reads a file that declares several services, their networks, and their volumes, and manages them together. For anything beyond a single container, Compose keeps the description in version control instead of in a shell history you cannot reproduce.

How do containers find each other by name?

Compose puts every service on a private user-defined network and runs an embedded DNS server on it. A service name resolves to that container's current address, so the app connects to redis:6379 and always reaches Redis even after the container is recreated with a new IP.

Why does the app wait for Redis to be healthy?

Starting is not the same as ready. A datastore may be up as a process while it still loads data and refuses connections. A health check plus condition: service_healthy makes Compose hold the app back until Redis answers redis-cli ping, which removes a whole class of race conditions on boot.

Is the app exposed to the internet on port 8090?

No. The mapping is 127.0.0.1:8090, so only the server itself can reach it. A public deployment puts a reverse proxy in front to add TLS and forward inward, which keeps the app off the open network the same way a single container does.

What you have, and what comes next

You wrote one Compose file, brought up an app and a Redis datastore with a single command, watched the app wait for Redis to be healthy, and curled a working service that talks to its datastore by name. The stack is reproducible from the file alone.

There is a gap, though. Redis keeps its data in a volume, and you have not yet proved that data survives the stack being torn down. The next chapter tests exactly that, and shows how a user-defined network lets services find each other by name.


Run your containers on a slice

Run Docker in production on a slice

Containers on a server, not just your laptop, need an always-on host. Spin up a slice, run your Compose stack with the volumes, limits and hardening from this guide.

Get early access

This guide is provided for educational purposes and offered as is, without warranty of any kind. The commands change the configuration of a server you control, and some can lock you out if run out of order. Test on a non-production machine, keep a second session open, and take a snapshot or backup before you begin. You are responsible for changes made to your own systems; ServerCake accepts no liability for any loss, downtime, lockout, or damage arising from following this guide or running the accompanying script.

Was this guide helpful?
Share

Spotted something out of date or have a question?

Built by the ServerCake team

Cloud that speaks India.

ServerCake is India's KYC-verified cloud: VMs and managed databases priced in ₹, billed with GST, on India-resident infrastructure. Reserve your spot for early access.

Reserve your spot