Reverse proxy (Traefik) with docker

A lot of docker containers provide a webUI. Nowadays if you want to access a webUI you either have to click through some certificate exceptions or you need so setup SSL certificates. Often the last thing is too cumbersome so a lot of people will just muddle through clicking on the exceptions. There is a very easy way to fix this and that is with Traefik. Within my docker instance I use ipvlan layer2 (called LocalNet), which results in each of my containers having an IP address in the same network as my docker host and therefor they are directly reachable for all hosts in the local network, without the need of portforwarding through the docker host. This is outside the scope of this post, but it might explain how some examples are implemented. On my docker host I use portainer, which makes it very easy to install new containers. I do this mostly through stacks.

Installing Traefik

Traefik can be easily installed by pasting the following configuration in an empty stack definition within Portainer

version: '3'

services:
  traefik:
    image: traefik:latest
    command:
      - "--api.insecure=true"
      - "--providers.file.directory=/letsencrypt/external"
      - "--providers.file.watch=true"
      - "--providers.docker"
      - "--providers.docker.exposedbydefault=false"
      - "--entryPoints.https.address=:443"
      - "--log.level=DEBUG"
      - "--log.filePath=/letsencrypt/traefik.log"
      - "--api.dashboard=true"
      - "--certificatesresolvers.vm.acme.dnschallenge=true"
      - "--certificatesresolvers.vm.acme.dnschallenge.provider=exec"
      - "--certificatesresolvers.vm.acme.dnschallenge.delaybeforecheck=300"
#      - "--certificatesresolvers.vm.acme.caserver=https://acme-v02.api.letsencrypt.org/directory"
      - "--certificatesresolvers.vm.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory"
      - "--certificatesresolvers.vm.acme.email=${EMAIL-ADDRESS}"
      - "--certificatesresolvers.vm.acme.storage=/letsencrypt/acme.json"
    container_name: traefik
    environment:
      - "TZ=Europe/Amsterdam"
      - "EXEC_PATH=/letsencrypt/plugins/${DNSCHALLENGESCRIPT}"
      - "EXEC_POLLING_INTERVAL=300"
      - "EXEC_PROPAGATION_TIMEOUT=300"
      - "EXEC_SEQUENCE_INTERVAL=30"
    networks:
      LocalNet:
        ipv4_address: '${IPADDRESS}'
 
    volumes:
      - ${TRAEFIKPERSISTANTSTORAGE}:/letsencrypt
      - /var/run/docker.sock:/var/run/docker.sock
    restart: unless-stopped
    labels:
      - "com.centurylinklabs.watchtower.enable=true"
      - "traefik.enable=true"
      - "traefik.http.routers.traefik.rule=Host(`${FQDN}`)"
      - "traefik.http.routers.traefik.entrypoints=https"
      - "traefik.http.routers.traefik.tls.certresolver=vm"
      - "traefik.http.services.traefik.loadbalancer.server.port=8080"

networks:
  LocalNet:
    external: true

A few things need to be addressed:

  • –providers.file.directory=/letsencrypt/external: Provides a directory where you can place config files for traefik to also act as a proxy for applications outside this Docker environment.
  • – “–entryPoints.https.address=:443”: This defines on which port this Traefik instance should listen and in order to bind containers to this specific port a tag “https” is given. You can change this to any other name, but it will be referred further down the line.
  • – “–certificatesresolvers.vm.acme.caserver=“XXX”: There are 2 lines here. One commented out and the other not. The one active in the above config is the staging area of LetsEncrypt. Once you have Traefik up and running and you want to move it into production, You can comment this line and uncomment the other line.
  • ${EMAIL-ADDRESS}: You need to provide an e-mail address. If the certificate is about to expire you will be notified.
  • ${DNSCHALLENGESCRIPT}: This is the name of the script that needs to be executed. In my case it’s vm.sh. That is also why the tag of the certificateresolver in my case is “vm”. Just like the “https” I mentioned previously you can rename it to something else, but it is referred to in several places, so make sure you then rename it all. I will go into further details about this script.
  • ${IPADDRESS}: This is the IP address for the traefik container. You can directly reach the traefik interface on that IP address on TCP port 8080.
  • ${TRAEFIKPERSISTANTSTORAGE}: This is the path that will store the information that has to be kept even when containers are removed and re-installed again. The contents of this storage path should persist.
  • – “com.centurylinklabs.watchtower.enable=true”: This is a bit out of scope, but it is used in a similar way we are going to use Traefik. If you have a watchtower container configured and you put this line in any other container on that docker host. Watchtower will automatically check if there is a new update for this container. If this is the case it will automatically update the container.
  • ${FQDN}: This is the full qualified hostname this application (in this case Traefik) should be reachable. This is the hostname that the certificate will be requested for. The hostname doesn’t need to be resolvable world-wide, since we are using a DNS challenge to verify that we are authorized for this domain. In your local LAN DNS it should be resolvable to ${IPADDRESS}.

DNS Challenge script (vm.sh)

#!/bin/sh

echo $@

action=$1
fulldomain=$2
value=$3

echo "action: $action, fulldomain: $fulldomain, value: $value"


DOMAIN=$(echo $fulldomain | rev | cut -d"." -f2,3| rev)
RECORD=$(echo $fulldomain | sed -e "s/$DOMAIN//" | sed 's/\.$//' | sed 's/\.$//')

case ${1} in

present)

echo "Present";

PAYLOAD="program=modify-dns&domain=${DOMAIN}&add-record=${RECORD}%20TXT%20${3}"

;;

cleanup)

echo "Cleanup";

PAYLOAD="program=modify-dns&domain=${DOMAIN}&remove-record=${RECORD}%20TXT%20${3}"

;;

*)

echo "Crap command";

exit 1

;;

esac



echo ${PAYLOAD}


AUTHHEADER="Authorization: Basic ${SECRET}"

wget --header="Content-Type: application/x-www-form-urlencoded" --header="${AUTHHEADER}" --post-data="${PAYLOAD}" --output-document - "https://${VIRTUALMIN}:10000/virtual-server/remote.cgi"

Two important variables that are not defined within the script:

  • ${SECRET}: this is a combination of username and password separated by a colon “:”. But not in clear text. No afterwards it’s encoded with a base64 encoding. So let’s use as example “username:password”. Encoded this would be: “dXNlcm5hbWU6cGFzc3dvcmQ=”. This is a reversable coding so with base64 you can decode the string again. So this string should not be used lightly.
  • ${VIRTUALMIN}: This is the hostname of the virtualmin server. This script is tailored to virtualmin. If you have another DNS server you most likely will be able to make it work with a few minor tweaks.

Configuring containers:

Let’s use a very simple container as an example to deploy. This IT Tools container can also help you with the base64 coding and decoding.

version: "3"
services:
  ittools:
    image: corentinth/it-tools
    container_name: ittools
    restart: unless-stopped
    networks:
      LocalNet:
        ipv4_address: '${IPADDRESS}'
    labels:
      - "com.centurylinklabs.watchtower.enable=true"
      - "traefik.enable=true"
      - "traefik.http.routers.ittools.rule=Host(`${FQDN}`)"
      - "traefik.http.routers.ittools.entrypoints=https"
      - "traefik.http.routers.ittools.tls.certresolver=vm"
      - "traefik.http.services.ittools.loadbalancer.server.port=${TCPPORT}"

networks:
  LocalNet:
    external: true

First of all this container doesn’t need any persistent storage, so that makes it a little bit easier. These are the things worth mentioning:

  • ${IPADDRESS}: This is a different IP address than the traefik container. It should be in the same subnet though. Pick one that is free and make sure it can’t be claimed by another host through DHCP for instance.
  • ${FQDN}: This is the domainname the certificate will be requested for and also this should be an entry in your local DNS to point to the IP address of your traefik container. Make sure you don’t point your DNS to this container’s IP address!
  • ${TCPPORT}: In this case this container runs on port 80. So ${TCPPORT} should be set to 80. This differs per application. So it’s definately worth checking.

Extra: Securing parts

Some applications you might want to expose to the whole wide world, except for the /admin part for instance. It’s very easy to realise this and the fun part is that you configure it in the configuration of the container you want to secure and not in your Traefik container config. Let’s say for this IT Tools tool, we want to prohibit anybody to use the base64 conversion. Only if your source IP address is 8.8.8.8, you are allowed to access this. Your docker config would look something like the following:

version: "3"
services:
  ittools:
    image: corentinth/it-tools
    container_name: ittools
    restart: unless-stopped
    networks:
      LocalNet:
        ipv4_address: '${IPADDRESS}'
    labels:
      - "com.centurylinklabs.watchtower.enable=true"
      - "traefik.enable=true"
      - "traefik.http.routers.ittools.rule=Host(`${FQDN}`)"
      - "traefik.http.routers.ittools.entrypoints=https"
      - "traefik.http.routers.ittools.tls.certresolver=vm"
      - "traefik.http.services.ittools.loadbalancer.server.port=${TCPPORT}"
      - "traefik.http.routers.ittools-base64.rule=(Host(`${FQDN}`) && PathPrefix(`/base64-string-converter`))"
      - "traefik.http.routers.ittools-base64.entrypoints=https"
      - "traefik.http.middlewares.ittools-base64-ipallowlist.ipallowlist.sourcerange=8.8.8.8/32"
      - "traefik.http.routers.ittools-base64.middlewares=ittools-base64-ipallowlist"

networks:
  LocalNet:
    external: true

It is that simple. So the biggest benefit is of configuring your stuff this way (Traefik and Watchtower), is that all you config for a container is within the configfile of that container and not scattered with different applications.