Let's Encrypt & Docker

In this use case, we want to use Traefik as a layer-7 load balancer with SSL termination for a set of micro-services used to run a web application.

We also want to automatically discover any services on the Docker host and let Traefik reconfigure itself automatically when containers get created (or shut down) so HTTP traffic can be routed accordingly.

In addition, we want to use Let's Encrypt to automatically generate and renew SSL certificates per hostname.

Setting Up

In order for this to work, you'll need a server with a public IP address, with Docker and docker-compose installed on it.

In this example, we're using the fictitious domain my-awesome-app.org.

In real-life, you'll want to use your own domain and have the DNS configured accordingly so the hostname records you'll want to use point to the aforementioned public IP address.

Networking

Docker containers can only communicate with each other over TCP when they share at least one network. This makes sense from a topological point of view in the context of networking, since Docker under the hood creates IPTable rules so containers can't reach other containers unless you'd want to.

In this example, we're going to use a single network called web where all containers that are handling HTTP traffic (including Traefik) will reside in.

On the Docker host, run the following command:

  1. docker network create web

Now, let's create a directory on the server where we will configure the rest of Traefik:

  1. mkdir -p /opt/traefik

Within this directory, we're going to create 3 empty files:

  1. touch /opt/traefik/docker-compose.yml
  2. touch /opt/traefik/acme.json && chmod 600 /opt/traefik/acme.json
  3. touch /opt/traefik/traefik.toml

The docker-compose.yml file will provide us with a simple, consistent and more importantly, a deterministic way to create Traefik.

The contents of the file is as follows:

  1. version: '2'
  2. services:
  3. traefik:
  4. image: traefik:<stable v1.7 from https://hub.docker.com/_/traefik>
  5. restart: always
  6. ports:
  7. - 80:80
  8. - 443:443
  9. networks:
  10. - web
  11. volumes:
  12. - /var/run/docker.sock:/var/run/docker.sock
  13. - /opt/traefik/traefik.toml:/traefik.toml
  14. - /opt/traefik/acme.json:/acme.json
  15. container_name: traefik
  16. networks:
  17. web:
  18. external: true

As you can see, we're mounting the traefik.toml file as well as the (empty) acme.json file in the container.

Also, we're mounting the /var/run/docker.sock Docker socket in the container as well, so Traefik can listen to Docker events and reconfigure its own internal configuration when containers are created (or shut down).

Also, we're making sure the container is automatically restarted by the Docker engine in case of problems (or: if the server is rebooted). We're publishing the default HTTP ports 80 and 443 on the host, and making sure the container is placed within the web network we've created earlier on.

Finally, we're giving this container a static name called traefik.

Let's take a look at a simple traefik.toml configuration as well before we'll create the Traefik container:

  1. debug = false
  2. logLevel = "ERROR"
  3. defaultEntryPoints = ["https","http"]
  4. [entryPoints]
  5. [entryPoints.http]
  6. address = ":80"
  7. [entryPoints.http.redirect]
  8. entryPoint = "https"
  9. [entryPoints.https]
  10. address = ":443"
  11. [entryPoints.https.tls]
  12. [retry]
  13. [docker]
  14. endpoint = "unix:///var/run/docker.sock"
  15. domain = "my-awesome-app.org"
  16. watch = true
  17. exposedByDefault = false
  18. [acme]
  19. email = "[email protected]"
  20. storage = "acme.json"
  21. entryPoint = "https"
  22. onHostRule = true
  23. [acme.httpChallenge]
  24. entryPoint = "http"

Alternatively, the TOML file above can also be translated into command line switches. This is the command value of the traefik service in the docker-compose.yml manifest:

  1. command:
  2. - --debug=false
  3. - --logLevel=ERROR
  4. - --defaultentrypoints=https,http
  5. - --entryPoints=Name:http Address::80 Redirect.EntryPoint:https
  6. - --entryPoints=Name:https Address::443 TLS
  7. - --retry
  8. - --docker.endpoint=unix:///var/run/docker.sock
  9. - --docker.domain=my-awesome-app.org
  10. - --docker.watch=true
  11. - --docker.exposedbydefault=false
  12. - [email protected]
  13. - --acme.storage=acme.json
  14. - --acme.entryPoint=https
  15. - --acme.onHostRule=true
  16. - --acme.httpchallenge.entrypoint=http

This is the minimum configuration required to do the following:

  • Log ERROR-level messages (or more severe) to the console, but silence DEBUG-level messages
  • Check for new versions of Traefik periodically
  • Create two entry points, namely an HTTP endpoint on port 80, and an HTTPS endpoint on port 443 where all incoming traffic on port 80 will immediately get redirected to HTTPS.
  • Enable the Docker provider and listen for container events on the Docker unix socket we've mounted earlier. However, new containers will not be exposed by Traefik by default, we'll get into this in a bit!
  • Enable automatic request and configuration of SSL certificates using Let's Encrypt. These certificates will be stored in the acme.json file, which you can back-up yourself and store off-premises.

Alright, let's boot the container. From the /opt/traefik directory, run docker-compose up -d which will create and start the Traefik container.

Exposing Web Services to the Outside World

Now that we've fully configured and started Traefik, it's time to get our applications running!

Let's take a simple example of a micro-service project consisting of various services, where some will be exposed to the outside world and some will not.

The docker-compose.yml of our project looks like this:

  1. version: "2.1"
  2. services:
  3. app:
  4. image: my-docker-registry.com/my-awesome-app/app:latest
  5. depends_on:
  6. db:
  7. condition: service_healthy
  8. redis:
  9. condition: service_healthy
  10. restart: always
  11. networks:
  12. - web
  13. - default
  14. expose:
  15. - "9000"
  16. labels:
  17. - "traefik.docker.network=web"
  18. - "traefik.enable=true"
  19. - "traefik.basic.frontend.rule=Host:app.my-awesome-app.org"
  20. - "traefik.basic.port=9000"
  21. - "traefik.basic.protocol=http"
  22. - "traefik.admin.frontend.rule=Host:admin-app.my-awesome-app.org"
  23. - "traefik.admin.protocol=https"
  24. - "traefik.admin.port=9443"
  25. db:
  26. image: my-docker-registry.com/back-end/5.7
  27. restart: always
  28. redis:
  29. image: my-docker-registry.com/back-end/redis:4-alpine
  30. restart: always
  31. events:
  32. image: my-docker-registry.com/my-awesome-app/events:latest
  33. depends_on:
  34. db:
  35. condition: service_healthy
  36. redis:
  37. condition: service_healthy
  38. restart: always
  39. networks:
  40. - web
  41. - default
  42. expose:
  43. - "3000"
  44. labels:
  45. - "traefik.backend=my-awesome-app-events"
  46. - "traefik.docker.network=web"
  47. - "traefik.frontend.rule=Host:events.my-awesome-app.org"
  48. - "traefik.enable=true"
  49. - "traefik.port=3000"
  50. networks:
  51. web:
  52. external: true

Here, we can see a set of services with two applications that we're actually exposing to the outside world.

Notice how there isn't a single container that has any published ports to the host — everything is routed through Docker networks.

Also, only the containers that we want traffic to get routed to are attached to the web network we created at the start of this document.

Since the traefik container we've created and started earlier is also attached to this network, HTTP requests can now get routed to these containers.

Labels

As mentioned earlier, we don't want containers exposed automatically by Traefik.

The reason behind this is simple: we want to have control over this process ourselves. Thanks to Docker labels, we can tell Traefik how to create its internal routing configuration.

Let's take a look at the labels themselves for the app service, which is a HTTP webservice listing on port 9000:

  1. - "traefik.docker.network=web"
  2. - "traefik.enable=true"
  3. - "traefik.basic.frontend.rule=Host:app.my-awesome-app.org"
  4. - "traefik.basic.port=9000"
  5. - "traefik.basic.protocol=http"
  6. - "traefik.admin.frontend.rule=Host:admin-app.my-awesome-app.org"
  7. - "traefik.admin.protocol=https"
  8. - "traefik.admin.port=9443"

We use both container labels and segment labels.

Container labels

We tell Traefik to use the web network to route HTTP traffic to this container. With the traefik.enable label, we tell Traefik to include this container in its internal configuration.

With the frontend.rule label, we tell Traefik that we want to route to this container if the incoming HTTP request contains the Host app.my-awesome-app.org. Essentially, this is the actual rule used for Layer-7 load balancing.

Finally but not unimportantly, we tell Traefik to route to port 9000, since that is the actual TCP/IP port the container actually listens on.

Segment labels

Segment labels allow managing many routes for the same container.

When both container labels and segment labels are defined, container labels are just used as default values for missing segment labels but no frontend/backend are going to be defined only with these labels. Obviously, labels traefik.frontend.rule and traefik.port described above, will only be used to complete information set in segment labels during the container frontends/backends creation.

In the example, two segment names are defined : basic and admin. They allow creating two frontends and two backends.

  • basic has only one segment label : traefik.basic.protocol. Traefik will use values set in traefik.frontend.rule and traefik.port to create the basic frontend and backend. The frontend listens to incoming HTTP requests which contain the Host app.my-awesome-app.org and redirect them in HTTP to the port 9000 of the backend.
  • admin has all the segment labels needed to create the admin frontend and backend (traefik.admin.frontend.rule, traefik.admin.protocol, traefik.admin.port). Traefik will create a frontend to listen to incoming HTTP requests which contain the Host admin-app.my-awesome-app.org and redirect them in HTTPS to the port 9443 of the backend.

Gotchas and tips

  • Always specify the correct port where the container expects HTTP traffic using traefik.port label.

    If a container exposes multiple ports, Traefik may forward traffic to the wrong port. Even if a container only exposes one port, you should always write configuration defensively and explicitly.

  • Should you choose to enable the exposedByDefault flag in the traefik.toml configuration, be aware that all containers that are placed in the same network as Traefik will automatically be reachable from the outside world, for everyone and everyone to see. Usually, this is a bad idea.
  • With the traefik.frontend.auth.basic label, it's possible for Traefik to provide a HTTP basic-auth challenge for the endpoints you provide the label for.
  • Traefik has built-in support to automatically export Prometheus metrics
  • Traefik supports websockets out of the box. In the example above, the events-service could be a NodeJS-based application which allows clients to connect using websocket protocol. Thanks to the fact that HTTPS in our example is enforced, these websockets are automatically secure as well (WSS)

Final thoughts

Using Traefik as a Layer-7 load balancer in combination with both Docker and Let's Encrypt provides you with an extremely flexible, powerful and self-configuring solution for your projects.

With Let's Encrypt, your endpoints are automatically secured with production-ready SSL certificates that are renewed automatically as well.