Stop Writing Mediocre Docker-Compose Files

Learn to use Docker-Compose like a pro with this actionable cheat sheet

Meysam
SkillUp Ed

--

Credit to Dots and Brackets

Writing a docker-compose can be pretty easy. Just an image attribute and if your service needs no volume or network, you can just start it using the easiest solution:

docker-compose up

The other solution would be to run using docker stack, but that’s not what this article is about.

I don’t recommend using the above command without a -d, which detaches after starting the services, and let’s you follow the logs afterwards using:

docker-compose logs -ft

The -f will follow the logs and the -t would write a timestamp before each log as well.

The problem with “docker-compose up” without a “-d” is that upon accidentally hitting Ctrl + c you’d be burned by stopping all the services inside a compose.

To get the most out of docker-compose it’s a good idea to include some other attributes to take advantage of the cool features docker has to offer.

Image by PinClipart

What else is available?

Well, a lot. But you aren’t going to need all. So I’m here to narrow it down to the bare minimum of the least requirements you’d expect your docker containers to have.

First things first: version

You most definitely want to start with the latest version. Because guess what? After a while has passed, even the latest version is not so latest. Cool projects update on a daily basis, and they release a new version every time they feel like doing so.

Not specifying a version is not a good idea because you want to narrow the production features to the ones you are developing, to avoid the next library release breaking your app.

That being said you should start your docker-compose.yml with the following line and WITH THE LATEST VERSION.

version: "3.8"

At the time of writing this article, version 3.8 is the latest. But that’s not going to last long, so make sure to check it out for yourself. Because you also want to make sure that your docker engine version is compatible with the version of your docker-compose.yml.

Boy oh boy: the service

The most important part of any docker-compose file is the service section, which describes the manner in which services should boot up. So without further ado, let’s get to it.

Every service starts with a name, and it’s very likely that you want your services to have a related name to what they are doing. They should also be unique in the scope of all the services inside a docker-compose.yml.

You can also run multiple compose files at once using the-f flag when running the command docker-compose up. but for simplicity’s sake, we assume all your services are inside a single docker-compose.yml.

So:

services:
postgres:
...
...
cool_app:
...
...

image

This attribute is self-explanatory, and unless you’re using the build attribute, it’s a must-have. I don’t need to tell you to ALWAYS tag your images, do I? You never want to run a service using the latest tag (which is the default, if you don’t mind me saying).

This attribute can also be used to name an image after building it (we talk about this later in this article).

So:

services:
postgres:
image: postgres:12.1
...
...

container_name

This is very useful for your own sake. Has it ever happened to you that you’ve queried the containers of a system using docker ps? Then I guess you’re familiar with the name great_yonath, or perhaps youthful_black etc. Now let me tell you that I’m not racist, and these are just the names that docker engine assigned to my containers when I didn’t explicitly specify the name using the container_name attribute. Therefore, Always specify.

So:

services:
postgres:
...
container_name: cool_app-postgres
...

Do I need to tell you that I chose an arbitrary name and you can choose your own too? I don’t think so.

hostname

When trying to craft multiple services into one, your main reason for doing that is not because you’re lazy to write a new docker-compose.yml file (I hope so 😉), but rather because the services are going to have communications to accomplish a certain task.

If that is the case, then you surely need this attribute, because when a web app running in a container trying to connect to a database asks for the name cool_app-database, docker engine would resolve that name to the IP address assigned to the service above. So always specify this if you want your database connection to work.

So:

services:
postgres:
...
hostname: cool_app-database
...

build

If you want to specify the location of your Dockerfile, you surely can, and the docker engine would build your image using the information in build attribute, if no such image is found on the system. And the name of the image would come from image attribute mentioned above.

This attribute has some important children that should be mentioned here:

  1. context: This specifies the location in which you would otherwise cd into before using docker build.
  2. target: This is most useful for people who use multi-stage builds. You can specify which stage you’re interested in building with this.
  3. dockerfile: Self-explanatory I hope, this attribute is the path (absolute, or relative to context) of the Dockerfile if it’s name is not the typical Dockerfile e.g. Dockerfile.dev. It’s optional and uses Dockerfile by default.

So:

services:
cool_app:
...
build:
context: .
target: prod
dockerfile: Dockerfile.prod
...

You can figure out that . is relative to the location of the docker-compose.yml.

If you want to find out the best practices of writing a Dockerfile, take a look at my other article about the topic:

volumes

This is an array, as I suppose you would know, and I recommend that you refrain from specifying anything other than the name of the docker volume. Because if you specify a path in here, every file in that directory would change permission and your host’s user would lose access to them. And on critical occasions you would need to ask for the help of Superuser to get your hands on the files.

So:

services:
postgres:
...
volumes:
- "./db_data:/var/lib/postgresql/data" # NOT RECOMMENDED
- "cool_app-database:/var/lib/postgresql/data" # BETTER
...
...
volumes:
cool_app-database: # just a declaration, nothing more needed

When specifying a name for your volume, you should also define your volumes somewhere outside services, which will be discussed further below.

You can also mount a path on the container with read-only permission, to keep the container from changing the owner of the files and therefore losing access to them, leading to asking for the help of Superuser again.

So:

services:
cool_app:
...
volumes:
- "./models:/service/models:ro" # example AI service
...
Image by nickjanetakis.com

restart

This is useful to dictate how you want your containers to restart. Available options include “no”, “always”, “on-failure”, “unless-stopped”. The last one is my favorite, which is a super set of “on-failure”.

So:

services:
postgres:
...
restart: unless-stopped
...

There is also a restart_policy under the deploy attribute, but that is not useful unless you’re running your services inside a swarm (I might write about it later).

depends_on

I use this attribute a lot, when I want one of my containers to be tied to another service, operationally & developmentally speaking. This attribute helps docker engine distinguish which container to start first. You can use it to start your database before your application.

So:

services:
postgres:
...
cool_app:
...
depends_on:
- postgres
...

environment

This helps you pass environmental variables to the docker container. For example when you want to specify the connection string of a database for your application, or when you want to change the default port to be published inside the container etc. This can be achieved in one of the following two ways.

  1. Using pair-values explicitly:
services:
postgres:
...
environment:
PORT: "8000"
...

Also possible using this notation:

environment:
- PORT="8000"

2. Passing the environmental variables from the host’s context — which is possible through one of the following two ways:

  • If an environmental variable is set in the current shell e.g. using the command export PORT=8000.
  • Having a .env file in the same location as the docker-compose.yml and specifying the key-value pairs inside that:
# .env
PORT=8000

And then you can simply pass that using the following pattern:

services:
cool_app:
...
environment:
- PORT
...

This would fetch the value of a variable named PORT either in .env, or in the variables from the context of the current shell.

environment is a very cool feature of docker, make sure to use it a lot.

ports

No need further explanation, but there are 2 ways you can use this. Either short syntax, or long syntax. Most of the times you don’t need anything more than short syntax — it’s concise, elegant and right to the target.

So:

services:
cool_app:
...
ports:
# either publish on all interfaces:
- "8000:8000"
# or only publish on localhost:
- "127.0.0.1:8000:8000"

According to the documentation, try to ALWAYS specify ports inside double quotations.

When mapping ports in the HOST:CONTAINER format, you may experience erroneous results when using a container port lower than 60, because YAML parses numbers in the format xx:yy as a base-60 value. For this reason, we recommend always explicitly specifying your port mappings as strings.

networks

Some people ignore using this attribute, as docker assigns all the services inside a docker-compose.yml into the same network, and they don’t feel the need to explicitly specify it.

But doing so has it’s own advantages:

  1. No ugly default network name which happens by default when you don’t specify one.
  2. You don’t take any chance and you don’t let anything surprise you. This is most desirable in production environments, when you’re narrowing down your uncontrolled parameters to the bare minimum.

So:

services:
cool_app:
...
networks:
- cool_app
...

And there you have it, all the necessary stuff you need to take your services inside docker-compose to the next level presented to you.

After the services, there are volumes and networks.

volumes

Every volume you specified inside every service, needs to be defined in this section, and it’s sufficient just to specify the name of the volume without anything else.

So:

services:
postgres:
volumes:
- "cool_app-database:/var/lib/postgresql/data"
...
...
volumes:
cool_app-database:

And that’s it, just specifying the name is good enough for your docker to work.

networks

This also is the same as volumes outside services. You specify it to “define” your networks, and you don’t have to provide anything other than the name.

So:

services:
postgres:
volumes:
- "cool_app-database:/var/lib/postgresql/data"
...
...
networks:
cool_app:

All done

That was all there is to it — everything you need to provide while writing a docker-compose.yml was provided in this article, and if you feel like you can still do more, check out this reference for more information.

Photo by Windows on Unsplash

Conclusion

In this article I have provided the bare minimum that you’d need to write a docker-compose.yml that is above average, i.e. providing just enough arguments for the docker engine to do it’s job better.

Docker has a lot of cool features and by ignoring to use some, you deprive yourself of taking advantage of its blessings.

Though you don’t have to, you can check this reference for further study on the topic if you want.

The complete example of a cool docker-compose.yml is provided below if you’re interested.

Acknowledgment

I thank you for the time you put in to reading this article. I hope you get something from it.

Keep the fire burning, and keep the learning process flowing in every bit of a byte.

If you’re interested in the above content, perhaps you might enjoy my other articles as well.

Follow me and stay tuned, as I am interested in writing and sharing what I know, hoping that someone will benefit from it.

--

--