Screen Too Small

This course material requires a larger screen to properly display the terminal and editor interfaces.

Please use a tablet, laptop, or desktop computer for the best learning experience.

You can continue browsing, but the experience will be significantly limited.

8/8
Free Trial: 2 lessons remaining
Unlock Full Access

Docker Compose

Estimated time: 5 minutes

You might be wondering, if the purpose of containers is isolation, then how would we ever run an application? An application needs a database, maybe Redis, who knows – maybe Elasticsearch.

Regardless of what the services are, they are plural. How do we get these services to communicate with each other? How do we do it without losing all this isolation that we talk up so much?

Orchestration

The answer is in setting the stage for all the services we need and providing a means to let them communicate in specific ways. Best of all, it's all done with a simple yaml file called compose.yml.

An Example

We're going to spin up a Ruby on Rails monolith as an easy example of a codebase that requires multiple services. We need a service for running Rails (aka: web) and another for Postgres.

πŸ‘‹
Hand-wavingβ€”We think a hands-on example is important to understand how these pieces fit together. We may introduce multiple examples in different languages and frameworks as this course develops. For now, we'll show how this comes together using Rails.

The Base Application

To get a simple boiler-plate Rails application, we'll run the following.

rails new docker-compose -d postgresql

Setup the base application

This command will create a new project called docker-compose, using the database postgresql. When it's done (there's a lot of output), you'll see something like this.

Rails project setup

Compose.yml

Let's look create our compose.yml and break it down.

πŸ’‘
As of Docker Compose 1.27.0, the typical file used for composition is compose.yml as opposed to docker-compose.yml. Additionally, the version is not required as it will automatically use the latest version of the Compose specification.

We'll cd docker-compose, create the file with touch compose.yml and add the following code to it.

services:
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5
  web:
    build: .
    command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails s -b '0.0.0.0'"
    volumes:
      - .:/rails
    ports:
      - 3000:3000
    depends_on:
      db:
        condition: service_healthy
    environment:
      DATABASE_HOST: db
      DATABASE_USER: ${POSTGRES_USER}
      DATABASE_PASSWORD: ${POSTGRES_PASSWORD}
      DATABASE_NAME: ${DATABASE_NAME}

volumes:
  pgdata:

compose.yml

So much to unpack here. Let's go through the finer points, one by one.

Services

Services is one of the top-level keys in a compose file. Others include volumes, configs, networks, and secrets. Values that live under this key are individual services. In our case, we have a db service and a web service.

Defining a service

Build vs image

Within a service definition, you can select an image, like postgres:16 (postgres at tag 16) or you can specify a build command. The build command will take a path. In this case, the web service's Dockerfile is in the same directory as the compose.yml.

Command

What is the command key in the web service? Here, we define what command should be run when spinning up the container. Without this, the CMD in the Rails Dockerfile would run. It's important that this is here so the application is available to all interfaces due to the -b 0.0.0.0 option.

πŸ’‘
Still confused about when to use a command? Here are some quick tips. You should typically use a custom command if the service needs to be accessible to the localhost unless the image itself already spins up listening to 0.0.0.0. In our case, web needs to be accessible. However, db does not as it is only accessible to the web container.

Environment variables

You can define env variables easily for each container within the environment key's values. In our example, we use env's given by our .env file for the Postgres user, password, and database. For values that come from .env, we will use ${ENV_NAME} to set it.

πŸ’‘
Notice that within the web service's environment, we use db as our DATABASE_HOST. By default, Docker compose creates a network for our services that can communicate with each. They do this by the name of the service. You can define custom networks, however.

Volumes

Just like our Postgres example, we want to keep our data off the container and within a Docker volume. We handle this with the volumes key.

volumes:
      - pgdata:/var/lib/postgresql/data

Not all volumes are Docker volumes, however. For example, when we run our application (Rails, Laravel, etc.), we need our code to be present in the container. How can we accomplish this? We do this with a bind mount. By specifying a path instead of a Docker volume, we connect our local directory to the container path listed. For web, we take the current directory and mount it to /myapp.

.:/rails

Mounting the current directory . with /myapp

❗
In the case of Rails, using rails as the mount point is important. The Dockerfile provided by Rails specifies the WORKDIR as rails. If we don't do this, creation of new files on the host system will not show up in the application.

Healthcheck

For many web applications, a database is a requirement. How do we know if our database is running? How do we know if it fails? A healthcheck helps solve this by defining a way for the container and others to know if it's healthy. Each service will have it's own mechanism for healthchecks. Refer to the addendum for healthchecks for other services.

Depends On

Our application requires the database to run. By using the depends_on key, we can specify services we need and any conditions. In our case, web, needs db. Most importantly, we need the db to be healthy. The Docker daemon manages the states of each container and will know if a container is starting, healthy or unhealthy. By stating that it must be service_healthy, web will know if db has spun up as expected.

❗
If a service, like db, becomes unhealthy after it starts successfully, the web services will continue to run. Check the addendum for more information on handling service crashes.

Dockerfile

Since this is Rails 7, we get a Dockerfile for free. You can review this file but minus some additional wrinkles, it serves the same purpose as our amazing ASCII art Dockerfile. It is responsible for building our container. In this case, our web container.

So how do we run this?

We can simply run docker compose up. You're going to see a lot of things happen.

  1. Postgres will get pulled
  2. Dockerfile will be built
  3. Network created
  4. Volume created (as needed)
  5. Containers for web and db created
Things are happening!
πŸ’‘
You may notice that we're seeing the logs for our services. That's great for understanding what goes on under the hood, but we wouldn't do this in a typical development environment. Instead, we can pass the option -d to start the application in a detached state. Want to see the logs? You can still do so by running docker compose logs web. Use -f if you want to follow the log

Confirming Things

We could simply open our browser to localhost:3000 and see that Rails is, in fact, running.

Eureka β€” or eeeeek if you don't like Rails.

But how do we know the database is connected? Let's extend our application to verify.

Scaffolding

With Rails, adding a simple CRUD setup for blog posts is extremely easy. With just a few commands, we'll get there.

First, we'll generate our scaffolding with the command rails generate scaffold Post title:string content:text.

This will generate the necessary HTML and database changes to make this work. It also adds a route so we can navigate to the pages to manage our posts.

We'll need to update our database with the following.

docker compose exec web bin/rails db:migrate

Migrating a database

Let's unpack what this command does.

docker compose exec web tells docker to execute the command in the context of the web container.

⚠️
You can run this command from within the directory where compose.yml exists OR any subdirectory therein.

bin/rails db:migrate is a Rails-specific command to run any changes to the database that have not yet been applied. In this case, it's the creation of the posts table.

Does It Work?

We can navigate to localhost:3000/posts to see if we do in fact have a database connection.

Slow down folks, it's 2005 all over again.

Looks like we're in business! But can we persist data?

Create the post
See the post!

Takeaways

Wow, the rabbit hole keeps going deeper. Let's recap where we've been and what you've accomplished.

Services are isolated but the application that use these services need to have access to one another. The solution to this is a compose.yml file.

Within this file, we can define individual services like db and web. These services can outline how they start, how they connect to each other, and environment variables for each.

We also learned how each service can depend on others to ensure the application can start successfully.

Finally, we learned how to start up the application, run arbitrary commands, and verify that our services are talking.

You've completed this lesson!

You completed this lesson less than a minute ago.