Docker Compose is a brilliant tool for bringing up local development environments for web projects. But working with multiple projects can be a pain due to clashes. For example, all projects want to listen to port 80 (or perhaps one of the super common higher ones like 8000 etc.). This forces developers to only bring one project up at a time, or hack the compose files to change the port numbers.
Recently I’ve found a way that makes managing these more enjoyable.
2023-10-05 note: If this interesting to you, be sure to check out the comments about this article on Hacker News for many other ideas.
2023-10-19 note: I have now created a repo formalising the ideas in this post and some of the Hacker News comments, here: https://github.com/georgek/traefik-local
A single project with Docker Compose
I use docker compose to manage local development instances of these projects. A typical compose file for a web project might look like this:
1# proj/compose.yaml
2services:
3 db:
4 image: "postgres"
5 environment:
6 POSTGRES_DB: "proj"
7 POSTGRES_USER: "user"
8 POSTGRES_PASSWORD: "pass"
9
10 web:
11 build: .
12 depends_on:
13 - "db"
14 environment:
15 DATABASE_URL: "postgres://user:pass@db/proj"
16 ports:
17 - "8000:80"
Note the very last line. This is where we map port 8000 from the host to port 80 of the
container such that the service can be accessed via http://127.0.0.1:8000
.
This works quite well for a single project, but it suffers from a couple of problems if you work on multiple projects:
It doesn’t scale. If I want to run another project at the same time, I’ll have to use a different port number, maybe 8001, then 8002 etc.,
What if that
compose.yaml
file is checked in as part of the project? Does the whole team have to agree on a set of port numbers to use for each project?
Using overrides for multiple projects
Fortunately Docker Compose does have a solution for (2) in the form of the
compose.override.yaml
file. This file will be automatically be merged into the
compose.yaml
without any extra configuration.
Unlike some other guides (including the official docs) concerning this file, I prefer to
not check compose.override.yaml
into version control and instead add it to the
.gitignore
file. Adding it to version control completely defeats the purpose of it: to
allow individual developers to override the standard compose file.
So, with this in mind, I no longer expose any ports by default in compose.yaml
because
I don’t know what will be convenient for each developer. This set up might look like
this:
1# compose.yaml
2services:
3 db:
4 image: "postgres"
5 environment:
6 POSTGRES_DB: "proj"
7 POSTGRES_USER: "user"
8 POSTGRES_PASSWORD: "pass"
9
10 web:
11 build: .
12 depends_on:
13 - "db"
14 environment:
15 DATABASE_URL: "postgres://user:pass@db/proj"
1# compose.override.yaml (to be created by each developer)
2services:
3 web:
4 ports:
5 - "8000:80"
Using Traefik
So now each developer can pick their own port numbers for each project, but we can still do better than this. People aren’t good at remembering numbers. We are much better at remembering names. Traefik is a free software edge router that can be used as a simple and super easy to configure reverse-proxy in container-based set ups.
Using Docker, Traefik can automatically discover services to create routes to. It uses container labels to further configure these routes. The following tiny example from the docs is illustrative:
1# traefik/compose.yaml
2services:
3 reverse-proxy:
4 image: traefik:v2.10
5 ports:
6 - "80:80"
7 volumes:
8 - /var/run/docker.sock:/var/run/docker.sock
9 whoami:
10 image: traefik/whoami
11 labels:
12 - "traefik.http.routers.whoami.rule=Host(`whoami.docker.localhost`)"
This starts two containers on the same docker network. The reverse proxy listens on
port 80 and forwards traffic with a host header of “whoami.docker.localhost” to the
whoami
service. Traefik guesses which port to send it to whoami
based on the ports
exposed by the container.
If you haven’t played with Traefik before it’s worth going through the quick-start properly now then coming back to see how we can make this work for multiple projects.
Traefik with multiple projects
This doesn’t quite solve our problem yet. We don’t want all of our various projects inside one compose file. Luckily Traefik communicates with the Docker daemon directly and doesn’t really care about the compose file, but you do need to make sure a few things are in order for this to work.
Firstly, make a docker network especially for Traefik to communicate with other services that you want to expose, for example:
1# traefik/compose.yaml
2services:
3 reverse-proxy:
4 image: traefik:v2.10
5 restart: unless-stopped
6 command: --api.insecure=true --providers.docker
7 ports:
8 - "80:80"
9 - "8080:8080"
10 volumes:
11 - "/var/run/docker.sock:/var/run/docker.sock"
12 networks:
13 - traefik
14
15networks:
16 traefik:
17 attachable: true
18 name: traefik
We create the network traefik
and give it the name “traefik” (otherwise docker compose
would scope it by project, e.g. “traefik_traefik”). We also allow other containers to
attach to this network.
Then in our compose.override.yaml
file from above, instead of mapping ports, we do the
following:
1# proj/compose.override.yaml
2services:
3 web:
4 labels:
5 - "traefik.http.routers.proj.rule=Host(`proj.traefik.me`)"
6 - "traefik.http.services.proj.loadbalancer.server.port=8000"
7 - "traefik.docker.network=traefik"
8 networks:
9 - default
10 - traefik
11
12networks:
13 traefik:
14 external: true
Now, after bringing up first the traefik project then your web project, you should be able to browse to http://proj.traefik.me/ in your web browser.
There’s a few things going on here. First, we have declared the traefik
network as an
external network. This means compose won’t manage it, but expects it to exist (so you
must start your traefik composition first). Next we override the networks
setting of
web
to make it part of the traefik
network too. Note we also have to add the
default
network, otherwise it wouldn’t be able to communicate with db
and other
services on its own default network.
The traefik.http.routers.proj.rule
label configures Traefik to route HTTP traffic with
the “proj.traefik.me” hostname to the container. The traffic.docker.network
label is
necessary because web
is on two networks. Finally, we set
traefik.http.services.proj.loadbalancer.server.port
for completeness, just in case
your container needs a different port mapping than the port it is set to expose, or if
it exposes multiple ports.
There is one final piece of magic: the “traefik.me” hostname. What is that? You can
read about it at http://traefik.me/. Essentially it is a DNS service that resolves to
any IP address that you want, but by default it resolves <xxx>.traefik.me
to
127.0.0.1
. There are other services like this including https://sslip.io/ and
https://nip.io/.
Now, because we don’t need to define any ports at all, it is possible to take advantage
of a newish compose feature and reinstate the ports in the original compose.yaml
file
for those developers who don’t want to set up Traefik for themselves. So our final
configuration looks like this:
1# compose.yaml
2services:
3 db:
4 image: "postgres"
5 environment:
6 POSTGRES_DB: "proj"
7 POSTGRES_USER: "user"
8 POSTGRES_PASSWORD: "pass"
9
10 web:
11 build: .
12 depends_on:
13 - "db"
14 environment:
15 DATABASE_URL: "postgres://user:pass@db/proj"
16 ports:
17 - "8000:80"
1# compose.override.yaml (to be created by each developer)
2services:
3 web:
4 labels:
5 - "traefik.http.routers.proj.rule=Host(`proj.traefik.me`)"
6 - "traefik.http.services.proj.loadbalancer.server.port=8000"
7 - "traefik.docker.network=traefik"
8 networks:
9 - default
10 - traefik
11 ports: !reset []
12
13networks:
14 traefik:
15 external: true
The !reset []
tag sets the ports back to empty; you can read about it here. Note that
unfortunately it can’t be used to set new ports, only reset them to default (you would
have to use two layers of override file to set new ports). The !reset
tag requires a
fairly recent version of docker compose, at least greater than 2.18.0.
A final note: you can check that these overrides are working correctly by running
docker compose config
.
Conclusion
By leveraging both the compose.override.yaml
file and Traefik it’s easy to run
multiple web projects on your development system at the same time and have easy to
remember names to access them all. Each developer is free to run as many as they want
and create their own easily-manageable configurations. Traefik and traefik.me can also
be used to allow other developers on your network to easily access your local
development instances with no DNS configuration required.
It’s a shame that the docs instruct people to use the override file for a distributed developer config rather than let individual developers use it, but hopefully it’s not too hard to remove this file from repos if already present.