21.14. Multi-Container Applications
Table of Contents
Why Multi-Container Matters
Most real backend systems are not a single container. A typical production setup might include:
- An application container (FastAPI)
- A database container (PostgreSQL)
- A cache container (Redis)
- A message broker container (Redis or RabbitMQ)
- A reverse proxy container (Nginx or Traefik)
Running each part in its own container gives you:
- Clear separation of responsibilities
- Independent scaling of each service
- Easier updates and rollbacks
- Consistent local and production environments
In this chapter you will learn how to make multiple containers work together as one application.
Important rule: In Docker, a multi-container application is a group of containers that:
- Share a network so they can talk to each other by name.
- Use volumes or other storage to keep important data.
- Are started and managed together, usually with Docker Compose.
Single Container vs Multi-Container
The single-container approach
When you start, you might try to put everything in one container:
- Application code
- Database server
- Task queue workers
- Reverse proxy
- Cron jobs
For example, a big Dockerfile that installs PostgreSQL, Redis, Nginx and your app, then runs a shell script that starts them all. This works for a demo, but it has serious problems:
- If PostgreSQL crashes, the whole container may fail.
- You cannot scale the app without also scaling the database.
- Logs from all processes are mixed together.
- Updating one part forces you to rebuild and redeploy everything.
The multi-container approach
Instead, you run each piece as its own container. For example:
| Service | Image | Role |
|---|---|---|
web | myorg/myapp:latest | FastAPI application |
db | postgres:16 | PostgreSQL database |
redis | redis:7 | Cache and background jobs |
worker | myorg/myapp:latest | Celery or RQ background worker |
nginx | nginx:stable | Reverse proxy and static files |
Each container:
- Runs one main process.
- Can have its own resource limits.
- Can be restarted or replaced independently.
Important rule: A container should run one main process. If you need many different services, use many containers.
Docker Networks in Multi-Container Setups
To let containers communicate, they must share a Docker network.
Default bridge network vs custom network
When you start containers with docker run and do not specify a network, Docker uses the bridge network. Containers on that network can talk to each other, but DNS names can be tricky to manage manually.
For multi-container applications you usually create a user-defined bridge network:
docker network create myapp-networkThen run containers on that network:
docker run -d --name db --network myapp-network postgres:16
docker run -d --name web --network myapp-network myorg/myapp:latest
Inside the web container, you can now connect to PostgreSQL with:
host = "db"
port = 5432because Docker gives each container a hostname equal to its container name.
Networks and Docker Compose
With Docker Compose, you usually do not create networks manually. Compose creates a network automatically and attaches all services to it.
For a project named myapp, the default network might be named myapp_default. Each service is reachable by its service name:
- Service
dbis reachable asdb - Service
redisis reachable asredis
Example environment values inside the app container:
DATABASE_URL=postgresql://postgres:postgres@db:5432/mydb
REDIS_URL=redis://redis:6379/0
You never need to use localhost inside a container to reach another container. localhost always means "this same container".
Important rule: To connect from one container to another, use the service or container name on the shared Docker network, not localhost.
Introducing Docker Compose
Managing many docker run commands is painful. Docker Compose solves this problem.
A simple docker-compose.yml:
version: "3.9"
services:
db:
image: postgres:16
environment:
POSTGRES_USER: myuser
POSTGRES_PASSWORD: mypassword
POSTGRES_DB: mydb
volumes:
- myapp-db:/var/lib/postgresql/data
web:
build: .
environment:
DATABASE_URL: postgresql://myuser:mypassword@db:5432/mydb
ports:
- "8000:8000"
depends_on:
- db
volumes:
myapp-db:One command to start everything:
docker compose upand to stop everything:
docker compose downYou will use Docker Compose for almost all local multi-container development.
A Typical 3-Container Setup
Let us build a very common setup:
- FastAPI app
- PostgreSQL database
- Redis cache
Directory layout example
myapp/
app/
main.py
...
Dockerfile
requirements.txt
docker-compose.ymlExample `Dockerfile` for FastAPI
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]Example `docker-compose.yml`
version: "3.9"
services:
db:
image: postgres:16
environment:
POSTGRES_USER: myuser
POSTGRES_PASSWORD: mypassword
POSTGRES_DB: mydb
volumes:
- db-data:/var/lib/postgresql/data
redis:
image: redis:7
volumes:
- redis-data:/data
web:
build: .
environment:
DATABASE_URL: postgresql://myuser:mypassword@db:5432/mydb
REDIS_URL: redis://redis:6379/0
ports:
- "8000:8000"
depends_on:
- db
- redis
volumes:
db-data:
redis-data:Now:
webconnects to PostgreSQL atdb:5432.webconnects to Redis atredis:6379.- Data for PostgreSQL and Redis is stored in named volumes.
Run:
docker compose up --build
Then open http://localhost:8000 in your browser.
`depends_on` and Startup Order
depends_on defines start order, but it does not wait for the service to be "ready". It only guarantees that the container is started.
For example:
web:
depends_on:
- db
This means Docker will start db before web, but PostgreSQL might still be initializing when the app tries to connect.
To handle this, you can:
- Use retry loops in your application startup.
- Use a "wait for" script that tries the connection until it succeeds.
- Use healthchecks and conditional start in more advanced setups.
Example simple retry in Python (pseudo-code):
import time
import psycopg2
for attempt in range(10):
try:
conn = psycopg2.connect("postgresql://...")
break
except Exception:
print("Database not ready, waiting...")
time.sleep(2)
else:
raise RuntimeError("Database not available")
Important rule: depends_on does not guarantee that a service is ready, only that the container has been started.
Sharing Data with Volumes
In multi-container applications, you usually need:
- Persistent storage for databases and other stateful services.
- Shared access to files between services, for example logs, uploads, or code in development.
Named volumes for persistent data
Named volumes are managed by Docker. They work well for database storage:
services:
db:
image: postgres:16
volumes:
- my-db-data:/var/lib/postgresql/data
volumes:
my-db-data:If the container is removed, the volume still exists and you do not lose your data.
Bind mounts for local development
During development, you often want to edit code on your machine and see changes instantly without rebuilding the image every time. Use a bind mount:
services:
web:
build: .
volumes:
- ./app:/app/app
Now, any changes you make in the local ./app directory are visible inside the container at /app/app.
Example: App + Worker + Broker
A common multi-container pattern:
webserves HTTP requests.workerprocesses background jobs.redisor another message broker passes jobs from web to worker.
Example docker-compose setup
version: "3.9"
services:
redis:
image: redis:7
web:
build: .
environment:
REDIS_URL: redis://redis:6379/0
ports:
- "8000:8000"
depends_on:
- redis
worker:
build: .
environment:
REDIS_URL: redis://redis:6379/0
command: ["python", "-m", "app.worker"]
depends_on:
- redis- Both
webandworkeruse the same image. webuses the defaultCMDfrom the Dockerfile to run Uvicorn.workeroverridesCMDand runs a separate worker process.- Both connect to Redis using the service name
redis.
Inside your code, you can have:
# app/main.py
import redis
r = redis.from_url("redis://redis:6379/0")# app/worker.py
import redis
r = redis.from_url("redis://redis:6379/0")Environment Configuration in Multi-Container Apps
Each service can have its own environment variables. You can:
- Define them inline in
docker-compose.yml. - Load them from an external
.envfile.
Inline environment example
web:
environment:
DATABASE_URL: postgresql://myuser:mypassword@db:5432/mydb
REDIS_URL: redis://redis:6379/0
APP_ENV: developmentUsing a `.env` file with Compose
Create a .env file:
POSTGRES_USER=myuser
POSTGRES_PASSWORD=mypassword
POSTGRES_DB=mydb
APP_ENV=development
Then in docker-compose.yml:
services:
db:
image: postgres:16
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
web:
build: .
environment:
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
APP_ENV: ${APP_ENV}
Compose will read variables from the .env file when you run docker compose up.
Important rule: Do not hardcode secrets directly in images. Use environment variables or secret management tools for sensitive data.
Scaling Services
One of the biggest advantages of multi-container setups is that you can scale different parts independently.
For example, you might want:
- 1 PostgreSQL container
- 1 Redis container
- 3 FastAPI containers
- 2 worker containers
With Docker Compose, you can scale a service:
docker compose up --scale web=3 --scale worker=2or define replicas in the file (supported in some Compose versions and in Swarm/Kubernetes).
When you scale web, Docker:
- Starts multiple containers from the same image.
- Connects them all to the same network.
- Assigns them different internal IPs.
If you have a reverse proxy like Nginx or Traefik, it can route traffic across all web containers.
You will see containers with names like:
myapp-web-1myapp-web-2myapp-web-3
Inside the network, they are all reachable by the common service name web through load balancing that your proxy handles.
Adding a Reverse Proxy Container
A common 4-container setup:
webFastAPIdbPostgreSQLrediscachenginxreverse proxy
Example:
version: "3.9"
services:
db:
image: postgres:16
environment:
POSTGRES_USER: myuser
POSTGRES_PASSWORD: mypassword
POSTGRES_DB: mydb
volumes:
- db-data:/var/lib/postgresql/data
redis:
image: redis:7
web:
build: .
environment:
DATABASE_URL: postgresql://myuser:mypassword@db:5432/mydb
REDIS_URL: redis://redis:6379/0
expose:
- "8000"
depends_on:
- db
- redis
nginx:
image: nginx:stable
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- web
volumes:
db-data:
In nginx.conf you can point to web:8000 as the upstream. The user only accesses Nginx on port 80, and Nginx forwards requests to the FastAPI service.
Debugging Multi-Container Issues
Common problems and how to diagnose them:
1. Container cannot reach another container
Symptoms:
- Connection errors like
Connection refusedorName or service not known.
Checklist:
- Are both containers on the same Docker network?
Usedocker compose psanddocker network inspectfor Compose setups. - Are you using the service name as the host, not
localhost? - Is the target service actually running?
Usedocker compose logs dbordocker ps.
2. Database or Redis loses data after restart
Likely cause: missing or misconfigured volume.
Checklist:
- Does the service have a
volumessection? - Is the volume declared in the top-level
volumessection? - Did you run
docker compose down -vwhich removes volumes?
3. Environment variables not loaded
Checklist:
- Are you using the right syntax
${VAR_NAME}indocker-compose.yml? - Does the
.envfile exist in the same directory where you rundocker compose? - Did you restart the containers after changing environment variables?
4. Service restarts in a loop
Run:
docker compose logs <service-name>Look for Python errors, connection issues, or misconfigurations. Fix the underlying problem, then restart.
Summary
In this chapter you learned the core ideas of multi-container applications with Docker:
- Split your application into separate containers for each service.
- Use Docker networks so containers can communicate by service name.
- Use Docker Compose to define and run your entire stack with one command.
- Use volumes to keep database and other important data persistent.
- Use environment variables to configure each service.
- Scale services independently when you need more capacity.
- Add extra containers like workers and reverse proxies when your app grows.
These patterns are the foundation for more advanced orchestration tools, such as Kubernetes, that you may use later in production.
Views: 6
KAHIBARO