Linux DevOps · Lesson 2 · 35 min

Multi-container apps with compose

Real apps are rarely one container. A typical web app is a web server, the app itself, a database and maybe a cache: four containers that need the right settings, the right ports, a private network to talk over, and somewhere to keep their data. Starting them by hand with long podman run commands gets old fast. Compose describes the whole app in one YAML file, and one command starts, stops or rebuilds all of it.

You will learn

  • Reading and writing compose.yaml: services, build, image, ports, environment, depends_on, volumes
  • up -d, ps, logs, exec, down, with podman-compose and docker compose
  • How containers find each other by service name, and why localhost doesn't work between them
  • Named volumes: data that survives down (and what down -v does)
  • Keeping settings in a .env file

One file, the whole app

The practice app in ~/counter-app is a web page that counts its visitors. The counter program runs in one container and stores the count in Redis (a fast in-memory database) in another:

compose.yaml
services:
  web:                          # a service = one kind of container
    build: .                    # build the image from ./Containerfile
    ports:
      - "8080:8000"             # HOST:CONTAINER, just like podman run -p
    environment:
      REDIS_HOST: db            # "db" is the other service's name!
    depends_on:
      - db                      # start db first

  db:
    image: docker.io/library/redis:7-alpine
    volumes:
      - redis-data:/data        # Redis saves its data in /data: keep it in a volume

volumes:
  redis-data:                   # a named volume, managed by podman/docker

That's everything podman run would need, written down once, and it lives in git next to the code. The file used to be called docker-compose.yml, and older projects still use that name. Both tools find either.

Getting compose

Rocky / RHEL
sudo dnf install podman
sudo dnf install epel-release
sudo dnf install podman-compose

podman-compose comes from EPEL. Once it's installed, podman compose (with a space) runs it too.

Ubuntu / Debian
sudo apt install podman podman-compose
# or, with Docker:
sudo apt install docker.io docker-compose-v2
sudo docker compose up -d

Docker's compose is a plugin: docker compose with a space. The old Python docker-compose (with a dash) is retired.

Same file, same subcommands. podman-compose names containers counter-app_web_1, and Docker names them counter-app-web-1. The project name comes from the folder name.

The everyday commands

Run these in the folder that holds compose.yaml
podman-compose up -d            # build/pull, create and start everything, in the background
podman-compose ps               # what's running
podman-compose logs web         # one service's logs (all services if you leave out the name)
podman-compose exec db sh       # a shell inside the running db container
podman-compose exec db redis-cli get hits
podman-compose restart web
podman-compose up -d            # after editing compose.yaml: recreates only what changed
podman-compose up -d --build    # after changing the code: rebuild images first
podman-compose down             # stop and remove the containers (volumes are kept)
podman-compose down -v          # ...and delete the volumes too: DATA IS GONE

How containers find each other

Compose creates a private network for the project, and every container on it can reach the others by service name. The web container connects to db:6379, and a built-in DNS server turns db into the Redis container's IP address. That's why REDIS_HOST: db works.

localhost means “me”

Inside a container, localhost is that container, not the server and not the other containers. Setting REDIS_HOST: localhost makes the web container look for Redis inside itself, and fail with Connection refused. It's one of the most common container mistakes, and you'll make it on purpose in the practice.

Also notice that db has no ports:. It doesn't need any: the web container reaches it over the private network. Only publish the ports that people outside need. Your database shouldn't be reachable from the internet.

Where the data lives

Stored…Survives restart?Survives down + up?Survives down -v?
inside the container only✓✗ (new container, empty)✗
in a named volume (redis-data:/data)✓✓✗
in a bind mount (./data:/data)✓✓✓ (it's a folder on the server)

Containers are meant to be thrown away and recreated. Anything worth keeping, like databases, uploads and certificates, belongs in a volume. And back volumes up like any other data (Linux Sysadmin's backups lesson applies here too).

SELinux and bind mounts (Rocky)

On Rocky, a bind mount of a folder from your home directory is blocked by SELinux until you relabel it for containers. Add :Z to the volume: ./data:/data:Z. Named volumes are labelled correctly automatically, which is one more reason to prefer them.

Settings in .env

Compose reads a file called .env next to compose.yaml and fills in ${NAME} placeholders. It's handy for things that differ between your laptop and the server:

# .env
WEB_PORT=8080

# compose.yaml
    ports:
      - "${WEB_PORT:-8080}:8000"     # :-8080 = the default if WEB_PORT isn't set

Passwords and API keys often end up in .env too, so add it to .gitignore. That's the next-but-one lesson.

Practice: run a two-container app 🧩

Start the counter app, find how its two containers talk, prove the volume keeps the count, break the networking on purpose, and fix it.

Quick check

1. In the web container, the app connects to db:6379. What is db?

2. After podman-compose down and up -d, the count continued from where it was. Why?

3. Why doesn't the db service publish port 6379?

4. You changed the counter program's code. What rebuilds and restarts it?

Finished the missions and the quiz? Mark it done to track your progress.