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
localhostdoesn't work between them - Named volumes: data that survives
down(and whatdown -vdoes) - Keeping settings in a
.envfile
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:
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
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.
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
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).
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?
✓ Service names are hostnames on the project's network.
2. After podman-compose down and up -d, the count continued from where it was. Why?
✓ Containers are disposable. Volumes are where state lives.
3. Why doesn't the db service publish port 6379?
✓ Publish only what outsiders must reach.
4. You changed the counter program's code. What rebuilds and restarts it?
✓ restart reuses the old image, and down -v would delete your data!