You can orchestrate three containers with Docker Compose by defining each service in a single
docker-compose.yml file, assigning them unique names, and connecting them to a shared custom network. This approach allows services to resolve each other by hostname automatically, enabling seamless communication without exposing unnecessary ports to the host machine.
Defining the Services
The foundation of any Docker Compose setup is the
docker-compose.yml file. To orchestrate three distinct containers, you must define each one under the
services key. Each service represents a running container and requires at least an image or build context. For this example, we will create a simple architecture consisting of a web frontend, an API backend, and a database.
Start by creating a directory for your project and adding the
docker-compose.yml file inside it. The structure should look like this:
version: '3.8'
services:
frontend:
image: nginx:latest
ports:
- "8080:80"
depends_on:
- api
api:
image: node:14-alpine
command: npm start
environment:
- DB_HOST=database
- DB_PORT=5432
depends_on:
- database
database:
image: postgres:13
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: password
POSTGRES_DB: mydb
volumes:
- db_data:/var/lib/postgresql/data
volumes:
db_data:
In this configuration, the
frontend service uses Nginx to serve static files. It exposes port 8080 on the host to port 80 in the container. The
depends_on directive ensures that the API starts before the frontend, although it does not wait for the API to be ready. The
api service runs a Node.js application and connects to the database using environment variables. Crucially, the
DB_HOST is set to
database, which matches the service name defined below. The
database service uses PostgreSQL and persists data using a named volume called
db_data.
Networking and Communication
One of the most powerful features of Docker Compose is its default networking model. By default, all services defined in the same
docker-compose.yml file are placed on the same network. This allows containers to communicate with each other using their service names as hostnames. You do not need to manually configure IP addresses or use complex routing rules.
When the
api container attempts to connect to
postgresql://user:password@database:5432/mydb, Docker resolves
database to the internal IP address of the
database container. This DNS resolution happens automatically within the Compose network. If you require more control, you can define a custom network explicitly:
networks:
app_network:
driver: bridge
services:
frontend:
networks:
- app_network
api:
networks:
- app_network
database:
networks:
- app_network
Using a custom network provides better isolation if you plan to add other services later that should not have access to the database. It also makes the intent of your architecture clearer. For most small applications, however, the default bridge network created by Compose is sufficient and easier to manage.
Managing Dependencies
The order in which containers start matters, especially when services rely on each other being available. Docker Compose handles basic startup ordering through the
depends_on instruction. In our example, the frontend depends on the API, and the API depends on the database. This ensures that the database starts first, followed by the API, and finally the frontend.
However,
depends_on only waits for the container to start, not for the application inside to be ready. A database might take several seconds to initialize after the container process begins. If your API tries to connect immediately upon starting, it might fail. To handle this, you can implement retry logic in your application code or use a tool like
wait-for-it as an entrypoint script. Alternatively, you can use health checks to ensure dependencies are truly ready before dependent services start.
Adding health checks improves reliability significantly. You can define a health check for the database service:
healthcheck:
test: ["CMD-SHELL", "pg_isready -U user -d mydb"]
interval: 10s
timeout: 5s
retries: 5
Then, update the
depends_on clause to include the health condition:
depends_on:
database:
condition: service_healthy
This ensures that the API will not start until the database is actively accepting connections. This pattern is essential for production-like environments where timing issues can cause cascading failures.
Running and Debugging
Once your
docker-compose.yml file is configured, you can start the entire stack with a single command. Navigate to the directory containing the file and run:
docker compose up -d
The
-d flag runs the containers in detached mode, allowing you to continue using your terminal. You can monitor the logs of all services combined by running:
docker compose logs -f
If a service fails to start, you can view the logs for a specific container using:
docker compose logs api
To stop the services without removing them, use:
docker compose stop
To completely remove the containers and networks, run:
docker compose down
If you need to rebuild images after changing code, add the
--build flag to the up command. This is particularly useful during development when you frequently update source code.
For troubleshooting connectivity issues, you can enter a running container using:
docker compose exec api sh
Inside the container, you can use tools like
ping or
curl to test connectivity to other services. For example, running
curl http://database:5432 from the API container helps verify that the database is reachable. If you encounter persistent connection refused errors, check the health status of the database container and ensure the correct port is exposed internally.
Frequently Asked Questions
How do I expose a database port to the host?
Add a
ports section to the database service definition in your
docker-compose.yml file. Map the host port to the container port, such as
"5432:5432" for PostgreSQL. Be cautious with this in production, as it exposes the database directly to the network.
Can I use different versions of Docker Compose?
Yes, but version 3.8 is widely supported and recommended for most use cases. Newer versions may introduce features like deploy configurations for swarm mode, which are not needed for local development. Stick to version 3.x unless you have specific requirements for newer syntax.
What happens if a container crashes repeatedly?
Docker Compose will attempt to restart the container according to the default restart policy. You can customize this by adding a
restart policy, such as
always or
on-failure, to the service definition. Check the logs to determine the root cause of the crash.
How do I share files between containers?
Use volumes to mount directories from your host machine into the containers. This allows multiple containers to read and write to the same files. For example, you can mount a shared code directory for both the frontend and API services to facilitate synchronized development.
Is it safe to commit changes to Docker Compose files?
Yes,
docker-compose.yml files are meant to be version-controlled. They define the infrastructure as code. However, avoid committing sensitive information like passwords or API keys. Use environment variables or Docker secrets instead to keep credentials secure.