Docker reverse proxy using Traefik

Why you might need a reverse proxy server?

The need to introduce a reverse proxy to a docker compose config file is quite popular. Some common use cases are:

  • routing inbound traffic to the right container in multi-container environments (heavily used by me in PHP refactoring projects using the strangler pattern), ex. route the request to the right web servers
  • terminate SSL (ideally using Let's encrypt?)
  • allow for load balancing in multiple backend servers environments
  • basic auth
  • IP whitelist/blacklist

As you can see in the image below, an exemplary reverse proxy sits in front of your application and can terminate the SSL connection, and then route the client requests to the correct backend web servers.

Reverse proxy basic example

Using a reverse proxy, you can also split incoming traffic onto multiple servers, all working inside an internal network and exposed under a single public IP address.

An interesting fact is that a good reverse proxy can also protect you from hacker requests, for example by filtering out malicious HTTP requests - like the recent log4j vulnerability.

A common approach to reverse proxy servers in Docker

There is a popular solution that is using NGINX reverse proxy as the server. It is configured using labels, and thus quite easy to implement. In fact, I have used it for the last few years quite often.

The problem with it was when I had to add some more sugar to it, like SSL, basic auth or some compression. This is supported by the mentioned nginx-proxy, but is a bit hard to configure in some cases. F.e. adding Let's encrypt requires adding yet another container, and if something fails, you need to look for issues in multiple places.

A better approach? Traefik reverse proxy!

A couple of months ago, I had (again) a need for a reverse proxy server for one of our new projects. I was setting up a review-apps environment and needed something efficient, but also stable. In most cases, I would reuse the mentioned NGINX setup, but I hit some issues with it in the past, mostly connected with no debug possibilities, so I decided to give Traefik a try.

Do you know the feeling when you discover a new thing and after a week or two you already wonder how you could have lived without it? That was the case for me with Traefik.

Simplifying a bit, Traefik is built on four main concepts: entrypoint, router, middleware and service. You can configure it to listen for incoming connections on certain entrypoints (ports, for example, TCP/HTTP on port 80), then the incoming request is matched to a route, each request can go through middleware (or a couple of them) - ex. path rewrite, compression, etc.; to finally reach a configured service that was assigned to the matched route. Services will usually map to your containers in the case of Docker, but they can also map to an appropriate server on your network. Traefik components overview

Basic reverse proxy configuration

Traefik supports multiple different configuration providers, including files or even HTTP endpoints, but we will go with the one that works best for me - Docker labels. It's using the same approach of labels as ngin reverse proxy, but has a bit more configuration possibilities.

Let's have a look at an example configuration file that uses docker compose and is available in Traefik official documentation.

version: "3.3"

services:
  traefik:
    image: "traefik:v2.6"
    command:
      #- "--log.level=DEBUG"
      - "--api.insecure=true"
      - "--providers.docker=true"
      - "--providers.docker.exposedbydefault=false"
      - "--entrypoints.web.address=:80"
    ports:
      - "80:80"
      - "8080:8080"
    volumes:
      - "/var/run/docker.sock:/var/run/docker.sock:ro"

  whoami:
    ...
  • --api.insecure=true allows accessing a Traefik dashboard - that simplifies debugging, but should be disabled outside of development environments due to security reasons;
  • --providers.docker=true - enables the Docker configuration discovery;
  • --providers.docker.exposedbydefault=false -do not expose Docker services by default;
  • --entrypoints.web.address=:80 - create an entrypoint called web, listening on :80

We expose port 80 to allow access to the web entrypoint, and port 8080 as it is the default dashboard port. We also need to connect a volume with the docker.sock so Traefik can talk with the Docker daemon (and fetch information about running containers).

Now, let's have a look at an example service we would like to expose:

version: "3.3"

services:
  traefik:
    ...
  whoami:
    image: "traefik/whoami"
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.whoami.rule=Host(`whoami.localhost`)"
      - "traefik.http.routers.whoami.entrypoints=web"

We don't need to expose the port, the only thing required to expose a service is to add a couple of labels:

  • traefik.enable=true - tell Traefik this is something we would like to expose
  • traefik.http.routers.whoami.rule=Host("whoami.localhost") - specify the rule used to match a request to this service. The whoami part is a name that you can specify, you can also adjust the Host to your needs. Traefik also supports other matchers, f.e. path, but we will take a look at them a bit later.
  • traefik.http.routers.whoami.entrypoints=web - what entrypoint should be used for the whoami service.

You can specify multiple routers for each container, just alter the router name. Make also sure the router names are unique and you have no collisions where two containers specify the same router name.

My final docker compose file looks like this:

version: "3.3"

services:
  traefik:
    image: "traefik:v2.6"
    command:
      #- "--log.level=DEBUG"
      - "--api.insecure=true"
      - "--providers.docker=true"
      - "--providers.docker.exposedbydefault=false"
      - "--entrypoints.web.address=:80"
    ports:
      - "80:80"
      - "8080:8080"
    volumes:
      - "/var/run/docker.sock:/var/run/docker.sock:ro"

  whoami:
    image: "traefik/whoami"
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.whoami.rule=Host(`localhost`)"
      - "traefik.http.routers.whoami.entrypoints=web"

Notice the changed hostname on line 22.

Let's run this: docker-compose up -d

After pulling the images, the service is exposed under localhost:

Proxied service

I can also open localhost:8080 to check the current Traefik configuration:

Traefik dashboard

Traefik HTTP route view

Load balancing

Now here comes the fun part. You already have load balancing in place! If you scale the whoami service in docker-compose:

version: "3.3"

services:
  traefik:
    ...
  whoami:
    image: "traefik/whoami"
    scale: 5
    labels:
      ...

Then Traefik will connect all the containers to the service:

Traefik dashboard load balancer view

and divide all incoming requests evenly between them. That's it! You have a fully working load balancer

Multiple services and path matching

As you can imagine, adding more services to the reverse proxy is quite easy. We can make them use different domains - by providing a different domain inside Host("localhost"). But we can also route the services by path. Let's add a new service to the docker-compose.yml file we created previously:

...
  whoami2:
    image: "nginxdemos/hello"
    scale: 1
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.whoami2.rule=Host(`localhost`) && Path(`/whoami2`)"
      - "traefik.http.routers.whoami2.entrypoints=web"

Notice three changes:

  1. I have used a different image, so something else is served through HTTP.
  2. I have switched the name in the routers part to whoami2 - to avoid name collision
  3. I have added && Path("/whoami2") to routing rule. So now both the hostname has to match localhost and the path needs to be exactly whoami2.

Let's check how this works: Traefik path routing example

If you need to match a path prefix, not only an exact match, you can use PathPrefix("/whoami2") instead.

SSL encryption

I also quite often need a thin layer between my app and the client, that could take the SSL certificate creation and updates off my shoulder. Let's Encrypt did a great job, but when the task is to quickly expose a Docker service it required some tricks.

Thankfuly, Traefik has built-in certificate resolvers. How does this work? Well, you just specify what SSL certificate provider you would like to use, and then it will handle all the required certificates. So if I have a running instance of Traefik and add a domain like accesto.com for one of the containers - it will automatically call the resolver. In the example case, the resolver is Let's Encrypt, and it will fetch the certificate for me.

Let's have a look at a slightly modified example we started with - the one with one exposed Docker container.

version: "3.3"

services:
  traefik:
    image: "traefik:v2.6"
    command:
      #- "--log.level=DEBUG"
      - "--api.insecure=true"
      - "--providers.docker=true"
      - "--providers.docker.exposedbydefault=false"
      - "--entrypoints.web.address=:80"
      - "--entrypoints.websecure.address=:443" # new
      - "--certificatesresolvers.myresolver.acme.tlschallenge=true" # new
      - "[email protected]" # new
      - "--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json" # new
    ports:
      - "80:80"
      - "443:443" # new
    volumes:
      - "/var/run/docker.sock:/var/run/docker.sock:ro"
      - "./letsencrypt:/letsencrypt" # new
(...)

Let me start with the changes added to Traefik labels. There are 4 new lines [12-15]

  • Line 12 - we need to add a new SSL entrypoint - websecure, listening to the HTTPS 443 port
  • Line 13 - enable the acme tls challenge for myresolver certificate resolver
  • Line 14 - provide e-mail used by let's encrypt to send important information
  • Line 15 - path to a JSON file where the certificates will be stored

Then some additional changes:

  • Line 18 - expose the 443 port
  • Line 21 - link the file where certificates are stored to a file on the local disk, so updating Traefik does not require fetching all certificates again.

There are some changes, but I believe all are quite simple and should be easy to understand. Now let's look at what's changed in the configuration for app service:

(...)
  whoami:
    image: "traefik/whoami"
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.whoami.rule=Host(`mydomain.com`)"
      - "traefik.http.routers.whoami.entrypoints=web,websecure" # changed
      - "traefik.http.routers.whoami.tls.certresolver=myresolver" # new
  • Line 29 - we added the websecure entrypoint to the list, so our service is now available both via HTTP and HTTPS;
  • Line 30 - we notified Traefik, it should use myresolver to get the SSL certificate for this service.

And that's actually it. If you now run docker-compose up -d Traefik will automatically fetch the certificate and use it.

You can also check the Traefik dashboard to see the SSL status for a router: Traefik SSL status in dashboard

And that's actually everything you need to do in order to have a docker reverse proxy with SSL termination.

Middleware features

Following the previous steps, you have a fully working reverse proxy, with an in-built load balancer and SSL encryption. Nice right? But in my case, I quite often need to secure access to such endpoints. F.e. whitelist incoming IPs or require a username and password. This could obviously be done in the exposed application, but I think that a reverse proxy is a better place for this. This is also another thing that Trafeik does better than the nginx reverse proxy. It comes with lots of different middlewares, that allow to cover more scenarios. Let's have a look at a couple of them:

IP Whitelist

Web application security is key, so how can you add an IP whitelist? It's just two new labels:

(...)
  whoami:
    image: "traefik/whoami"
    labels:
    (...)
        - "traefik.http.middlewares.whoami-filter-ip.ipwhitelist.sourcerange=192.168.1.1/24,127.0.0.1/32"
        - "traefik.http.routers.whoami.middlewares=whoami-filter-ip"

In this case, whoami-filter-ip is our middleware name, and it has to be unique. The provided IP list will be allowed to access your service, other sources will get a 403 Forbidden.

Basic auth

Quite frequently, we need to secure websites by adding a basic auth in front of it. Adding basic auth is also pretty simple and uses the same approach of middleware.

(...)
  whoami:
    image: "traefik/whoami"
    labels:
    (...)
      - "traefik.http.middlewares.whoami-auth.basicauth.users=test:$$apr1$$ra8uoeq5$$HqiATqC5edVVEXznsNiVV/,test2:$$apr1$$8ol2akty$$BW.Fsa.K3tc1DzcJ6l9ql1"
      - "traefik.http.routers.whoami.middlewares=whoami-auth"

To create a user and password pair, run:

echo $(htpasswd -nb user password) | sed -e s/\\$/\\$\\$/g

If you open the page now, it will ask for credentials:

Basic auth example

Please note the sed part, it's required to replace single $ with double $$, so docker compose does not treat it as an env variable.

Is there more?

Of course, there is. Traefik supports not only HTTP, but also TCP connections (like the one to a database). It also comes with different middlewares built-in. You can easily add custom headers (f.e. strict transport security), rate limiting, redirects, retries, compression, circuit breaker, custom error pages, etc.

Summary

Creating a reverse proxy server with Traefik, including load balancing, web application security, service discovery or even SSL termination with automated Let's Encrypt certificates is quite easy.

I was able to cover all my needs within minutes, and even more - I managed to create a simple review apps environment combined with our Gitlab CI instance. If you are interested in how I did that - give me a shout and subscribe to our newsletter to not miss that article.

Recently my colleague posted a blog post where he checks how the Dockers work on the MacBook Pro with an M1 Pro chip. Give it a look if you are interested in running a Docker on your new MacBook.

If you are interested in Docker, check out my e-book: Docker deep dive

Docker Deep Dive - free eBook

Docker like a pro!

GET FREE EBOOK

Check out my previous articles on Docker networking in order to separate services and test failure scenarios:

icon

Read the guide for advanced Docker users

Take your Docker containers to the next level with our practical guide for advanced Docker users.

Read now

Related posts