Containers are becoming ever more prevalent in the world of software development, but cloud-hosted container orchestration engines like Kubernetes can be overkill for smaller projects, or early development. So, there is something of a gap between running single containers, and running a series of containerized services in a complex orchestration engine.

This is where Docker-Compose shines. It is a relatively simple way to get multiple containers to talk to each other on a default network. Today, we’ll go over some basics to get you started using Docker-Compose locally.

If you aren’t familiar with Dockerfiles, you’ll want to brush up. This blog post may be helpful.

Local and Remote Images

Just as when you work with single Docker containers, the images that define containers in Docker-Compose can either be built locally or pulled from a remote container registry. By default, Docker-Compose tries to pull images from DockerHub, just like Docker does.

Oftentimes, your project will include both locally built images and those pulled from a remote registry. For example, say you have an API that needs to interact with a database. Odds are that you will need to build your API from local code (so that it reflects local changes made during development) but the image defining your database will probably not change, so it’s a great candidate for storing in a remote registry, and simply having Docker-Compose pull it down as needed.

Concrete Example

Say we have our API defined in a file called main.py, and our local filesystem looks like this:

root
├── api
│   ├── Dockerfile
│   └── main.py
└── docker-compose.yml

Our (very simple) api/Dockerfile might look like this:

FROM python:latest
COPY . .
CMD python main.py

Because we’re copying our local code into the image when it builds, we can easily update the local main.py file and reflect those changes in our container when we start it.

That is, if we ran the following from within the api directory, we would have a locally built image:

docker build -t example-image .

And we could then run the container based on this image by running:

docker run example-image

But what if we need to include that database we were talking about? Where does that get defined? As it turns out, it lives in the docker-compose.yml file that is listed above as well.

Let’s say that our API needs a Postgresql database to store stuff in. We can make that available to our running API on a default network by including it as a service in Docker-Compose YAML file, like so:

---
version: '3'
services:
  db:
    image: postgres:latest
    ports:
      - 5432:5432
    environment:
      - POSTGRES_USER=admin
      - POSTGRES_PASSWORD=admin
      - POSTGRES_DB=example_db
  api:
    restart: always
    build: ./api/
    ports:
      - 5000:5000
    command: python main.py
    depends_on:
      - db

Here, we have two services defined – our locally built api image, and the publicly available postgres:latest image that will be pulled from Dockerhub. We can tell the difference, because the db service defines an image to be pulled. Conversely, the api service has substituted that tag for a build tag which takes a filepath to the directory in which the Dockerfile lives that defines the image we want to include.

We can also see that the api service can’t run until the db service has started, because of the depends_on tag included in the service definition. This tells Docker-Compose what order to spin up the containers in.

NOTE: the command tag in the api service definition will overwrite the CMD value in the Dockerfile being built.

Port Mapping

We have also mapped ports from the processes running inside of containers to the containers themselves, so other services outside of a given container can talk to those inside of it. For example, Postgres listens on port 5432 by default, so we map that port to the container, so it will forward any requests to that address on the network to the running service.

Notice that we’ve done the same with our locally built api service – it is listening on port 5000 and that is mapped to the same port on the container it is running in.

For example, our API will need to have a connection string defined in order to connect to the Postgres instance. If we were running the whole thing directly on our local development machine, the string might look like this:

db = PostgresqlDatabase('example_db', user='admin', password='admin', host='0.0.0.0', port=5432)

But this is pointing to 0.0.0.0 or localhost, which won’t work when both the api and db are containerized. Thankfully, Docker-Compose has made this simple to update to allow the services to communicate, like so:

db = PostgresqlDatabase('example_db', user='admin', password='admin', host='db', port=5432)

Notice that we changed the IP address for the service to the service name as it is defined in the docker-compose.yml file. Now the api service will be able to connect to the database.

TL;DR

Docker-Compose fills the gap between running single Docker containers and enterprise-grade container orchestration engines like Kubernetes. With it, you can run multiple containers as services on a default network, so the services can communicate. It is defined with a YAML file, and can include both locally built images and images pulled from a remote container registry like DockerHub, as shown above.

Share this article

Logo

Orka, Orka Workspace and Orka Pulse are trademarks of MacStadium, Inc. Apple, Mac, Mac mini, Mac Pro, Mac Studio, and macOS are trademarks of Apple Inc. The names and logos of third-party products and companies shown on the website are the property of their respective owners and may also be trademarked.

©2023 MacStadium, Inc. is a U.S. corporation headquartered at 3525 Piedmont Road, NE, Building 7, Suite 700, Atlanta, GA 30305. MacStadium, Ltd. is registered in Ireland, company no. 562354.