Understanding Docker Compose Files
For the rest of this article I will be refering to the docker compose below for my example. It is for a really useful program called paperless-ngx and if you’ve never heard of it before, I highly recommend it. Since I’m covering docker compose in this article, I’ll get right into it.
services:
broker:
image: docker.io/valkey/valkey:9-alpine
restart: unless-stopped
volumes:
- redisdata:/data
db:
image: docker.io/library/postgres:18
restart: unless-stopped
volumes:
- pgdata:/var/lib/postgresql
environment:
POSTGRES_DB: paperless
POSTGRES_USER: paperless
POSTGRES_PASSWORD: paperless
webserver:
image: ghcr.io/paperless-ngx/paperless-ngx:latest
restart: unless-stopped
depends_on:
- db
- broker
- gotenberg
- tika
ports:
- "8000:8000"
volumes:
- data:/usr/src/paperless/data
- media:/usr/src/paperless/media
- ./export:/usr/src/paperless/export
- ./consume:/usr/src/paperless/consume
env_file: docker-compose.env
environment:
PAPERLESS_REDIS: redis://broker:6379
PAPERLESS_DBHOST: db
PAPERLESS_DBENGINE: postgresql
PAPERLESS_TIKA_ENABLED: 1
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
gotenberg:
image: docker.io/gotenberg/gotenberg:8.37
restart: unless-stopped
# The gotenberg chromium route is used to convert .eml files. We do not
# want to allow external content like tracking pixels or even javascript.
command:
- "gotenberg"
- "--chromium-disable-javascript=true"
- "--chromium-allow-list=file:///tmp/.*"
tika:
image: docker.io/apache/tika:3.3.1.0
restart: unless-stopped
volumes:
data:
media:
pgdata:
redisdata:
The Basic File Structure
The first thing to understand about docker compose files, is that structure within is very important. Docker compose uses yaml files that require specific formatting and if the layout of the file is wrong, docker compose will complain and will not start your containers. I’m not going to cover all the possible yaml formatting, since that is whole other topic on it’s own, but the reason I brought it up, is to point out the biggest gotcha. That is the spacing. The indentation on compose files is very important. Each sections spacing needs to be consistent. In this file there are 2 spaces from the services to broker. Another 2 spaces from broker to image and another 2 spaces from volumes to the hyphen below it. This can be any spacing as long as it’s the same throughout the document, although, 2 spaces is typical. If compose complains about your file, the spacing is the first thing to check. Just don’t use tabs.
The next thing to understand is that yaml uses a nested parent/child structure. Each indentation layer is a child of the parent directly above. Using the same example as before, broker is a child setting of services and image is a child setting of broker. To make things a little weirder with the family reference, looking a little lower, you can see that db would be a sibling setting to broker. I found this pretty intuitive, but if you find it confusing, don’t worry, it will click after you’ve gone through a few compose files.
Contents Layer by Layer
Understanding a little about the file structure is great and all, but it means nothing if you don’t know what the configuration is. To simplify the explanation I’m going to start with a couple of the top layer “parent” elements that you might see and move through the services and it’s children settings. Even thought services is at the top of the example and pretty much every docker-compose file, this is where the containers are defined and it utilizes the other top level settings, so that’s why I’m covering it last.
Volumes
volumes:
data:
media:
pgdata:
redisdata:
For volumes to make sense, you need to understand that docker containers are inherently stateless. That is a complicated way of saying that nothing can be saved directly in the container. This means that every time a container is updated or restarted, it would behave like a whole new fresh install, and the user settings and configurations would have to be set all over again. For containers to retain anything through a restart, storage needs to be added. Docker volumes is one of the ways to add this storage.
The elements under the volumes “parent” is to tell docker to create these storage spots. This will create a directory with the same name as as the volume in the docker working root file system. These storage volumes are managed by docker and are pretty “set it and forget it” in their use.
Networks
The docker compose example at the top of the page doesn’t include a networks section and although they are not really common, it is still worth talking about, since it gives me an opportunity to discuss how networking works for docker containers. When you start containers using a docker compose file, Docker will create a bridge network that has all of the containers declared in the compose file. This means that the “stack” of containers will be allowed to communicate with each other, but will be behind a type of firewall, with NAT, that will not allow other containers or any other devices, for that matter, to reach into this network.
services:
traefik:
networks:
- proxy
networks:
proxy:
external: true
In the above example, the container is being added to a network named proxy and it was created before this compose file is used to start up the container. Hence the line external: true. I’ve broken the order of steps I was following, since this example also has a declaration in the services, but I thought it made more sense to place it here since it really is for networking. Using these settings, a container from one compose stack can reach a container from another, as long as they were added to the same network. I’ve only ever seen this used for a reverse proxy, but it illustrates how you can have control over what containers can reach each other.
services:
homeassistant:
network_mode: host
Now to really break format, the above example is strictly in the services section. Here the compose file is declaring that this container should run in host network mode. That means that the container will share the network device that the host machine is using, without the firewall and NAT, just like software installed natively on the host. This should only be used if your container needs to interact with devices on the hosts local network. The lines above are from a homeassistant compose file and that is a perfect example, since it’s an application that would be used to control devices on the local network.
Services
The services section is the only one that will be in required in every docker compose file. This is where the container declarations and the majority of their configuration live. It’s the meat and potatoes, if you will. I am only going to cover the things that are here at a high level, since many of the things are container dependant, as you’ll see, but it should be enough to help you understand the document. To help break this down I’m going to go through the file line by line and explain what it is.
Service Names
services:
broker:
db:
webserver:
gotenberg:
tika:
The first indentation in, from services, is the name of the service that will be running. It is used as the address for containers to reach each other within the bridge network that is created for this compose stack. For this reason, while the names can be set to anything you want, you will need to take note of them , because it will often affect other things in the compose file, as you will soon see.
Image
image: docker.io/valkey/valkey:9-alpine
The next indentation in brings us to the image line. This is where the container image is specified. In this example there are 4 separate parts. docker.io is the container registry, or in other words docker hub. You will sometimes see ghcr.io which is GitHub’s version. The first or left rather, valkey is the user account that owns the image. In this case it probably belongs to an organization that manages this software. The next valkey or the rightmost one is the image repository. This is normally named after the program that is running in the container image. Everything on this side of the colon tells docker where to get the container. The right side of the colon (9-alpine) is the tag, which is essentially the version number. Many compose files use the tag “latest” which will always grab the most recent version. This is the default tag, so if don’t see any colon or tags at all, you will get the latest release from this repository.
Restart Policy
restart: unless-stopped
The restart line is pretty straight forward. The unless-stopped policy tells docker to restart this container, unless it was explicitly stopped. If either the container crashes and stops, or the docker service or the host is restarted, then this policy will restart the container. If you stop the container with a docker command, then it will stay down. This is by far the most useful policy, since “on-failure” will restart only if docker detects a crash, “always” will restart the container, even if you give the container a stop command (it does this on next reboot, docker restart or until you change the restart policy), and “no” is the default, so there is no declaration is needed, unless changing the “always” policy.
Storage
volumes:
- data:/usr/src/paperless/data
- media:/usr/src/paperless/media
- ./export:/usr/src/paperless/export
- ./consume:/usr/src/paperless/consume
I jumped a bit further down the file for this example and grabbed the volumes section from the webserver service. I did this because there are 2 types of storage listed here. The first 2 lines, you will notice, have a volume name, a colon, then a file path. These are telling docker how to use the storage volume that was asked for in the volumes section. The first of these 2 lines is telling docker to mount the data storage volume (left side of the colon) into the container at /usr/src/paperless/data (right side of the colon). Similarly the next line adds storage to /usr/src/paperless/media.
But the last 2 storage lines are different because these are bind mounts. This is telling docker to mount a directory directly from the host file system into the docker container. The formatting of these lines is similar to the volumes but instead of a volume, the left side of the colon is a file path on the host machine and the right side of the colon is mount location inside the container. It’s important to note, that the host file path in this example is relative to the location of the docker compose file. The dot slash means the same directory as the compose file. The storage directory can also be written as an absolute file path (eg. /home/docker/storage) as well. If the directory does not exist, docker will create it when the compose stack is started. When docker creates a directory, it is owned by root and if the container is running as a non-root user, this can sometimes cause permission errors when the container tries to access the storage. If you are seeing permission errors and you have a bind mount, changing ownership of the storage directory will often fix everything.
Depends On
depends_on:
- db
- broker
- gotenberg
- tika
The depends on section is pretty much what it sounds like. It is telling Docker Compose that this container (or service) requires the services listed here. Docker Compose will then wait until these listed services are started before starting up the service that requires them. It’s an easy way to make sure that the dependant service starts last.
Ports
ports:
- "8000:8000"
The ports section is closely related to networking. As I have previously described, the network that is created for this stack will be separated from the host network. In order for any users of a service, including the host machine, to be able to connect to the running container, a firewall rule needs to be created to allow those connections. That is what the port declaration is for. If you have ever done any kind of port forwarding on your home router this should be really familiar. The port listed on the left side of the colon, is the port on the host/firewall to be redirected to the port listed on the right side of the colon, the listening port on the container. So in this example port 8000 on the host machine will be forwarded to port 8000 of the container. This is one of the best conveniences of containers. If you wanted to run another service that uses the same port, you can remap it to a different port on the host machine. For example:
ports:
- "9025:8000"
This will remap port 9025 on the host machine to port 8000 on the container. This will allow container port usage to be VERY flexible. Using this, you could even run several copies of the same stack of containers.
There’s one more important thing that I have to make sure you understand about this. The rules defined here are changing the host firewall rules. If you use UFW, these rules will not be listed. When these ports are opened, they are opened to the whole world. If you don’t want this service exposed outside your host machine then you can bind the exposed port to an address like this:
ports:
- "127.0.0.1:8000:8000"
This will make to service only accessible to the host machine. If instead, you use a VPN address, you will only be able to access the service from that VPN. Chances are, in a homelab, you will want everything exposed. But if you start playing with cloud VPS services, it’s a good thing to know.
Environment
environment:
PAPERLESS_REDIS: redis://broker:6379
PAPERLESS_DBHOST: db
PAPERLESS_DBENGINE: postgresql
PAPERLESS_TIKA_ENABLED: 1
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
The environment section is probably the hardest to properly explain, since it is vastly different on every container that uses it. This section has environment “Variables.” If you’ve learned anything about coding, then are you’re probably familiar with the idea. If not, don’t worry about. For docker containers, these variables are typically used as a sort of replacement for a config file. Due to the stateless nature of containers, importing config files is a little bit of a hassle and although some still require config files, environment variables are often used.
In this example, the first 2 and last 2 variables are telling the paperless web server container where to find the other services that it uses. Since these are containers in the same stack, the address is just the name of the service, but it could easily be on a another host and work just as well. The DBENGINE line is setting the database type, since paperless can use postgress, SQL and SQLite. The TIKA_ENABLED line is configuring paperless to use tika. Without that line the paperless web server wouldn’t even look for the tika container. This shows 3 different types of settings for this container alone. The variables on the postgres service define how the database should be configured. This goes to show how environment variables can be vastly different based on the container and why I can’t cover every possibility, but the examples here are the more common ones you’ll see.
That brings me to this line:
env_file: docker-compose.env
This declares an environment file for the paperless container. Which is pointing to this file:
###############################################################################
# Paperless-ngx settings #
###############################################################################
# See http://docs.paperless-ngx.com/configuration/ for all available options.
# The UID and GID of the user used to run paperless in the container. Set this
# to your UID and GID on the host so that you have write access to the
# consumption directory.
USERMAP_UID=1000
USERMAP_GID=1000
# See the documentation linked above for all options. A few commonly adjusted settings
# are provided below.
# This is required if you will be exposing Paperless-ngx on a public domain
# (if doing so please consider security measures such as reverse proxy)
#PAPERLESS_URL=https://paperless.example.com
# Required. A unique secret key for session tokens and signing.
# Generate with: python3 -c "import secrets; print(secrets.token_urlsafe(64))"
PAPERLESS_SECRET_KEY=change-me
# Use this variable to set a timezone for the Paperless Docker containers. Defaults to UTC.
PAPERLESS_TIME_ZONE=America/Edmonton
# The default language to use for OCR. Set this to the language most of your
# documents are written in.
#PAPERLESS_OCR_LANGUAGE=eng
# Additional languages to install for text recognition, separated by a whitespace.
# Note that this is different from PAPERLESS_OCR_LANGUAGE (default=eng), which defines
# the language used for OCR.
# The container installs English, German, Italian, Spanish and French by default.
# See https://packages.debian.org/search?keywords=tesseract-ocr-&searchon=names
# for available languages.
#PAPERLESS_OCR_LANGUAGES=tur ces
These files are a way to set environment variables from outside of the compose file. Environment files are typically used when there is deployment specific settings. That sometimes means sensitive information like API keys and passwords. In the example I’ve shown here, the PAPERLESS_SECRET_KEY line is the only security sensitive line. The user and group ID’s, URL, time zone and languages are more site specific settings that could be different based on where it’s deployed. These are not terribly common, in my experience, but the sample compose file I grabbed had it and I figured It’s worth a sew extra seconds of time to cover, otherwise I wouldn’t have mentioned it in a beginners guide.
Command
command:
- "gotenberg"
- "--chromium-disable-javascript=true"
- "--chromium-allow-list=file:///tmp/.*"
The command section is the most rare of them all. It overrides the start up command for the container. In this case, it adds a couple of flags to the start up of gotenberg. I’m not even going to pretent to know what those flags are doing, since I don’t know how gotenberg works, but I can tell that it’s the same as running gotenberg --chromium-disable-javascript=true --chromium-allow-list=file:///tmp/.* from the shell, if gotenberg was natively installed.
Conclusion
This is far from a complete docker tutorial but I’ve covered most of the things that you will typically see in a docker compose file. My intention was only to demystify the elements of the file and make it seem less confusing. I hope this helps you start out on your homelabbing journey.