Simplifying docker web hosting with traefik

Table of contents

Hosting web applications has become a lot easier since the emergence of container technologies like docker. But while deploying the applications themselves is a simple task now, routing traffic to different containers on the same host is less convenient, with traditional reverse proxies requiring manual configuration and third-party tools for SSL certificates - unless you use traefik.

Why traefik?

Traefik is a reverse proxy intended to run as an ingress in front of applications, distributing incoming requests to different applications running either on the same host or remote machines (e.g. based on domain or path). While the reverse proxying can be done by many other web servers, traefik offers a native integration with the docker ecosystem. Instead of configuring virtual hosts and restarting, it can watch docker containers and automatically configure itself based on container labels. No static ip/port requirements, no restarts, no manual config file editing.

Deploying traefik

The first step to automating ingress configurations is to deploy traefik itself. To keep with the docker ecosystem, we will run traefik as a container too:

docker-compose.yml

networks:
 traefik-net:
   name: traefik-net
   external: false

services:
 traefik:
   image: traefik:v3.3.4
   restart: unless-stopped
   networks:
     - traefik-net
   ports:
     - "80:80"
     - "443:443"
   command:
     - "--api.insecure=true"
     - "--providers.docker=true"
     - "--providers.docker.exposedbydefault=false"
     - "--providers.docker.network=traefik-net"
     - "--entrypoints.web.address=:80"
     - "--entrypoints.websecure.address=:443"
     - "--entrypoints.websecure.http.tls=true"
     - "--certificatesresolvers.letsencrypt.acme.httpChallenge.entryPoint=web"
     - "--certificatesresolvers.letsencrypt.acme.email=mymail@example.com"
     - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
   volumes:
     - "/var/run/docker.sock:/var/run/docker.sock:ro"
     - "$PWD/traefik/letsencrypt:/letsencrypt"

We start by creating an external docker network named traefik-net. Containers that should be proxied to must be part of this network (they may be part of others as well). Traefik itself is listening on ports 80 (http) and 443 (https) on the host. The command key contains flags to configure the traefik service itself, for example to enable the docker integration, not expose containers unless they have a label to enable it and set up basic entrypoint information. The only part you need to adjust is the --certificatesresolvers.letsencrypt.acme.email flag. You should provide a real email address here to register ssl certificates for your domains with letsencrypt.

In order to make the docker integration work, we have to mount the docker socket into the traefik container (read-only for security).

Exposing a simple http container

With the traefik proxy up and running, exposing a container over http is very simple:

docker-compose.yml

networks:
 traefik-net:
   external: true # Join the existing Traefik network

services:
 echo:
   image: ealen/echo-server
   networks:
     - traefik-net
   labels:
     - "traefik.enable=true"
     - "traefik.http.routers.echo-http.rule=Host(`echo.example.com`)"
     - "traefik.http.routers.echo-http.entrypoints=web"
     - "traefik.http.services.echo-srv.loadbalancer.server.port=80"

The ealen/echo-server container listens on port 80 and returns the incoming http request in json format, perfect for testing networking and availability.

In order to have it forwarded through traefik, all we need to do is join the traefik-net network and add a few labels to tell traefik when and how to pass traffic to it. The first label tells traefik that this container should be forwarded at all. Without this, traefik will ignore this container even if it has all the other labels.

The next two labels define a traefik router named echo-http, receiving traffic for the echo.example.com domain on for the web entrypoint (i.e. plain http on port 80).

The last label tells traefik to forward traffic to the echo container's port 80. Specifying this may not always be required, but never hurts. This also requires configuring a load balancing service, named echo-srv in this example.

Every container should pick unique names for it's routers / services, so using the container or image name is reasonable (for example the echo application used echo-http for its http router and echo-srv as it's load balancer service name). Reusing the same router name across different containers may have unexpected side effects, like merging or overriding the router configurations.

Automatic SSL certificates

While forwarding plain http is good for an introduction, any serious deployment will want to use encrypted https entrypoints instead.

Adding ssl certificates and encryption to the container's proxy config takes just a few additional labels:

docker-compose.yml

networks:
 traefik-net:
   external: true # Join the existing Traefik network

services:
 echo:
   image: ealen/echo-server
   networks:
     - traefik-net
   labels:
     - "traefik.enable=true"
     - "traefik.http.routers.echo-http.rule=Host(`echo.example.com`)"
     - "traefik.http.routers.echo-http.entrypoints=web"
     - "traefik.http.services.echo-srv.loadbalancer.server.port=80"

     # only the following labels are new:
     - "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https"
     - "traefik.http.routers.echo-http.middlewares=redirect-to-https"
     - "traefik.http.routers.echo-https.rule=Host(`echo.example.com`)"
     - "traefik.http.routers.echo-https.entrypoints=websecure"
     - "traefik.http.routers.echo-https.tls.certresolver=letsencrypt"

The new labels are fairly straight-forward: They start by creating a new middleware that redirects http:// request schemes to https:// in the first label, and telling the echo-http router to use that middleware in the second one.

The last two labels are the equivalent of the previous echo-http router, but for https instead (using the websecure entrypoint and the same domain for traffic routing). The only notable difference is that the https router needs to have its own name, so it is named echo-https instead of echo-http.

You could also choose to only forward https traffic to the container without listening for and redirecting raw http requests, but some older browsers still try to reach a website over http and give up if it doesn't respond, even if an https request would have worked.

Adding these labels to the container is all that's needed to enable encrypted traffic for the container. Traefik will automatically fetch an ssl certificate from letsencrypt and revalidate it before it expires, until the container is removed or the labels don't require the certificate anymore.

Using compression middleware

The traefik proxy comes with many middlewares out of the box, for example to handle http compression to reduce bandwidth usage. In order to allow traffic compression, all you have to do is add the compress middleware to a router's middlewares:

networks:
 traefik-net:
   external: true # Join the existing Traefik network

services:
 echo:
   image: ealen/echo-server
   networks:
     - traefik-net
   labels:
     - "traefik.enable=true"
     - "traefik.http.routers.echo-http.rule=Host(`echo.example.com`)"
     - "traefik.http.routers.echo-http.entrypoints=web"
     - "traefik.http.services.echo-srv.loadbalancer.server.port=80"

     # only the following labels are new
     - "traefik.http.middlewares.compress.compress=true"
     - "traefik.http.routers.echo-http.middlewares=compress"

The first label defines the builtin compress middleware for the current context, and the second one tells the echo-http router to use it.

If you already have a middleware, like the redirect-to-https from the previous example, you can tell traefik to use both of them by separating them with a comma:

traefik.http.routers.echo-http.middlewares=compress,redirect-to-https

Be careful which router you attach the compress middleware to for containers that have multiple ones; adding it to the echo-http router would enable compression only for unencrypted http traffic, not for the https traffic handled by the echo-https router!

Enforcing basic http authentication

Some web applications may need additional protection, for example a staging deployment or a dashboard that has no builtin security features. This need can be satisfied by traefik's basicauth middleware, requiring users to authenticate before proxying traffic between them and the application container.

Configuring basicauth requires an additional step, to generate the username/password information in htpasswd format. We can use a temporary docker container to generate it:

docker run --rm httpd:alpine htpasswd -nbB admin 123

The command creates credentials for a user admin with password 123, which may look like this:

admin:$2y$05$vE9AUPSY2imsDR21FuHUfeuCz17j4JSIz/urhwqQ.YVGZTxLHzUQy

(You should obviously change these credentials to something more secure!)

With the credentials string in hand, we can now define an authentication middleware using them:

networks:
 traefik-net:
   external: true # Join the existing Traefik network

services:
 echo:
   image: ealen/echo-server
   networks:
     - traefik-net
   labels:
     - "traefik.enable=true"
     - "traefik.http.routers.echo-http.rule=Host(`echo.example.com`)"
     - "traefik.http.routers.echo-http.entrypoints=web"
     - "traefik.http.services.echo-srv.loadbalancer.server.port=80"

     # only the following labels are new
     - "traefik.http.middlewares.echo-auth.basicauth.users=admin:$$2y$$05$$vE9AUPSY2imsDR21FuHUfeuCz17j4JSIz/urhwqQ.YVGZTxLHzUQy"
     - "traefik.http.routers.echo-http.middlewares=echo-auth"

Notice how the dollar signs ($) were doubled ($$) for the configuration. Docker compose would interpret anything following a dollar sign as a variable, so they need to be escaped with a second one to be interpreted as a single literal dollar sign.

The first label defines a new basicauth middleware named echo-auth, using the previously generated credentials string for authentication. The second label simply tells the echo-http router to use the newly created middleware.

Access to the container is now restricted to visitors that know your username and password.

Exposing the traefik dashboard

The traefik service comes with a builtin graphical dashboard to browse current configuration and view warnings/errors. It is available on port 8080 by default and does not require any authorization to view. Since some of the displayed data can be dangerous in the hands of an attacker, you should never expose it to the internet without a proper security layer.

Since the traefik application is running as a container itself, it can also define a few labels to expose its own dashboard through a proxy configuration, for example under a specific domain including ssl encryption and basic authentication for security.

In order to achieve this, the traefik application has to be redeployed:

docker-compose.yml

networks:
 traefik-net:
   name: traefik-net
   external: false

services:
 traefik:
   image: traefik:v3.3.4
   restart: unless-stopped
   networks:
     - traefik-net
   ports:
     - "80:80"
     - "443:443"
   command:
     - "--api.insecure=true"
     - "--providers.docker=true"
     - "--providers.docker.exposedbydefault=false"
     - "--providers.docker.network=traefik-net"
     - "--entrypoints.web.address=:80"
     - "--entrypoints.websecure.address=:443"
     - "--entrypoints.websecure.http.tls=true"
     - "--certificatesresolvers.letsencrypt.acme.httpChallenge.entryPoint=web"
     - "--certificatesresolvers.letsencrypt.acme.email=mymail@example.com"
     - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
   volumes:
     - "/var/run/docker.sock:/var/run/docker.sock:ro"
     - "$PWD/traefik/letsencrypt:/letsencrypt"
   labels:
      - "traefik.enable=true"
      - "traefik.http.routers.traefik.rule=Host(`traefik.example.com`)"
      - "traefik.http.routers.traefik.entrypoints=websecure"
      - "traefik.http.routers.traefik.tls.certresolver=letsencrypt"
      - "traefik.http.routers.traefik.service=api@internal"
      - "traefik.http.middlewares.traefik-auth.basicauth.users=admin:$2y$05$vE9AUPSY2imsDR21FuHUfeuCz17j4JSIz/urhwqQ.YVGZTxLHzUQy"
      - "traefik.http.routers.traefik.middlewares=traefik-auth"

Most of these labels should already be familiar to you, with the exception of the traefik.http.routers.traefik.service=api@internal label. This label is used to tell the traefik router to forward traffic to the builtin dashboard service named api@internal, instead of a specific port (you could also have used port 8080, but the internal service name is better in case traefik's default dashboard port changes in the future).

Make sure to change the basicauth credentials before deploying this configuration to a real host!

More articles

Manual web application fingerprinting

Understanding what information attackers are looking for

A guide to ai model file formats

Making sense of the different file extensions

Btrfs vs ZFS

Making informed decisions which filesystem is right for you