Skip to main content

Docker Compose for a single container

Before looking at how to run multiple containers, let’s see how Docker Compose can help us even when we’re working with just a single container.

Docker Compose is an official Docker tool that can be installed as a plugin for Docker Engine. Its commands start with docker compose in the commandline, though you may see older sources that use the standalone docker-compose binary. By authoring a Docker Compose configuration file, you can specify not just how to build images (as Dockerfiles do), but also how these images should be used to start containers at runtime.

Working directory
docker-compose.yml
web-app/
Dockerfile
package.json
package-lock.json
node_modules/
...
src/
server.js

Let’s move our web application into the ./web-app directory (which will contain Dockerfile, package.json, src/, etc.). Then, in our working directory, we’ll make a docker-compose.yml configuration file.

docker-compose.yml
version: '3.8'

services:
web:
build: ./web-app
ports:
- '3000:3000'

After we specify our configuration version, the main block in a Docker Compose configuration file is services, which specifies all of the containers that should run when we start our application. For now, we’ve just got our web app, so we can start with just one key in our services dictionary called web. This is an arbitrary name; you can pick whatever you’d like, but we will see it come up in a few spots down the road.

The first entry in web is build, which tells Docker Compose where it can find the Dockerfile and build materials for the specified image. We give it the path for our web application. You’ll also see, though, that Docker Compose lets us specify our host-to-container port mapping right there in the configuration file. If we check docker-compose.yml into our repository, anyone who pulls down our code will be able to use Docker Compose to match our runtime configuration without having to construct a docker run command manually.

We run our composed containers with a docker compose command, making sure we’re in the same directory as our configuration file:

$ docker compose up

Now, we’ll see that the web server is running. We can access http://localhost:3000 in our host machine’s browser, because Docker Compose set up the port mapping for us based on the docker-compose.yml configuration file. We can Ctrl+C this when we’re done (fortunately, Docker Compose doesn’t need any hackery to make this work), and try running in “detached” mode, as we did with docker run before:

$ docker compose up --detach # or just docker compose up -d

One helpful thing about Docker Compose is that its commands are scoped to only the containers configured in the current working directory’s docker-compose.yml file. So, for example, if we run docker compose ps, we’ll see entries only for the webcontainer we just created, not for any other Docker containers running on our system. We can also refer to containers by their name, like by using docker compose logs web. Even if other docker-compose.yml files on our host machine use the web container name, Docker Compose will pick the right one based on the folder we’re currently working in. It’s always possible to fall back to normal Docker commands like docker ps or docker stop, but I prefer the Docker Compose commands when we’re in a Compose environment.

As before, we can also poke into a service with Bash if we’d like:

$ docker compose exec web bash

Docker Compose automatically gives us an interactive terminal, so no need for -it. Keep in mind that not every image will have bash installed, but you can normally find ways to poke around in a container (like with /bin/sh) if you really want.

Let’s create a volume mount, too, while we’re here. First, we’ll change our server a little to add a page view counter:

$ npm install --save better-sqlite3
server.js
import express from 'express';
import sqlite3 from 'better-sqlite3';

const pageViewDb = new sqlite3('/data/page-views.sqlite3');

pageViewDb.exec(`
CREATE TABLE IF NOT EXISTS page_views (
id INTEGER PRIMARY KEY AUTOINCREMENT,
time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
path TEXT
)
`);

const app = express();

app.get('/', (req, res) => {
const statement = pageViewDb.prepare('INSERT INTO page_views (path) VALUES (?)');
statement.run(req.path);

const pageViewCount = pageViewDb.prepare('SELECT COUNT(*) AS count FROM page_views').get().count;

res.status(200).send(`Hello! You are viewer number ${pageViewCount}.`);
});

app.listen(3000, '0.0.0.0', () => {
console.log('Server listening');
});

I’ve just added some code to initialize a SQLite3 database and use it to track page views. It’ll store its page view database in a folder called /data, which doesn’t exist yet. We could just create that directory in our container by adding a mkdir command in our Dockerfile, but we don’t really want the database to exist only inside our container; it would be reset every time our container got rebuilt. Instead, we can specify a volume mount in our docker-compose.yml file:

docker-compose.yml
version: '3.8'

services:
web:
build: ./web-app
ports:
- '3000:3000'
volumes:
- './sqlite-data:/data'
Working directory
docker-compose.yml
web-app/
Dockerfile
package.json
package-lock.json
node_modules/
...
src/
server.js
sqlite-data/
page-views.sqlite3

This will bind the host’s sqlite-data directory in the current working directory to the /data directory in the container, creating that folder on both sides if it doesn’t exist yet. Fortunately, Docker Compose lets us use relative paths, so we can just write ./sqlite-data on the host side of the volume. Now, if someone pulls our code repository and runs docker compose up, they’ll have both their volume binds and port mappings automatically configured. It’s still possible they’d want to make some changes to their docker-compose.yml file, like if they want to store their data somewhere else or bind to a different port, but at least they have a base configuration to get themselves started. If we’re working in a version control repository, it’d be a good idea to make sure the data directory isn’t committed to the repository (e.g. by adding it to .gitignore).

If we just run docker compose up right now, we won’t actually see the new version of our application, since we already built an older version of the web container’s image. We need to tell Docker Compose that it should try rebuilding any images that may be needed by our containers:

$ docker compose up --build --detach

If there’s an old version of the container running, Docker Compose will shut that one down and spin up the new one with minimal downtime. It’s normally not a problem to just use --build all the time, since Docker’s build cache will make builds go quickly if nothing has changed. Just remember that this will destroy any old containers if something did change that requires a rebuild, so make sure that anything you care about is copied out of the container or stored persistently (like with a bind mount).

$ sqlite3 data/page-views.sqlite3 'SELECT * FROM page_views;'

Just to prove that the volume mount is persistent, let’s make a change to the server. We’ll add some bold text on line 22:

...
res.status(200).send(`Hello! You are <strong>viewer number ${pageViewCount}</strong>.`);
...

Before rebuilding, checking http://localhost:3000 gives us the old version; my view counter is currently at 31. I’ll run docker compose up again:

$ docker compose up --build --detach

The command took about ten seconds to run for me, but the server went down for only about a second in there. When it popped back up, it began serving responses with the bold text and with the view counter continuing at 32!

We've seen that Docker Compose can help us document our containers' runtime configurations and avoid re-typing long docker run commands each time we want to use our containers. Now let's use Docker Compose to manage a setup that needs to run multiple containers at the same time.