Course Lessons
Docker Compose
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.
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.

Compose.yml
Let's look create our compose.yml
and break it down.
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.
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.
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
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.
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.
- Postgres will get pulled
Dockerfile
will be built- Network created
- Volume created (as needed)
- Containers for web and db created

-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 logConfirming Things
We could simply open our browser to localhost:3000
and see that Rails is, in fact, running.

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.
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.

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


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.