Skip to main content
  1. Blog/

Using Traefik with Cloudflare Tunnels

Introduction #

Over the last 18 months or so, I’ve been gradually moving all of my services across to Docker Containers, with the aim of making ongoing maintenance a lot easier. Previously, I’ve run everything off bare metal servers, eventually moving to Proxmox when that got too unwealdy. I now have a Docker Swarm running on several virtual machines spread across a number of physical hosts, and I started using Traefik as an application proxy. Since I need a number of my services available externally as well, I decided that using Cloudflare would be a sensible first line of security, but I quickly got fed up of having to log in to the Cloudflare Dashboard every time I wanted to spin up a new service.

Thankfully there are a number of pre-existing Docker services that can help with this, but tying them all together proved somewhat tricky, and I couldn’t find any write-ups online that demonstrated how to use all of these components together, so I’ve decided to document my setup here in the hope that it may help someone else out in future.

My Requirements #

  1. All externally facing services must only be accessible via Cloudflare
  2. Internal-only services must not be accessible apart from on my local network
  3. No exposed ports on my router for port-forwarding
  4. Authentication for external services if required
  5. Be as tolerant to faults/outages as possible

Components #

There’s quite a lot of moving parts in this project! I’ve done my best to draw the relationships between them in the diagram below, but I’ll explain each part in detail within the following sections.

flowchart TD
  external{{External User}} --> cfdns(Cloudflare DNS)
  google(Google oAuth)

  subgraph Cloudflare
  cfdns --> cftunnel(Cloudflare
Tunnel) end subgraph Home Network subgraph Traefik cftunnel --> trtunnel(Traefik
Tunnel) trtunnel --> tr(Traefik
Reverse Proxy) tr --> trcompanion(Cloudflare
Companion) tr ----> trauth(Traefik
Forward Auth) trcompanion --> cfdns tr <--> error(Error Pages) end tr ---> app[(Application)] trauth --> app2[(Protected
Application)] internal{{Internal User}} --> tr end trauth <---> google

Part 0: Prerequisites #

Docker Swarm #

I’m going to take the easy route and tell you to look elsewhere for setup instructions here! The rest of the sections below require us to have the ability to load and run Docker Compose files, and the assumption is that this is done on a Docker Swarm.

Internal DNS #

There are many ways to skin this particular cat, so again I’m not going to give detailed setup instructions. Personally I use Blocky for DNS filtering and internal domain name resolution, but you can achieve the same with Pi-Hole or similar. You’ll need to manually set up entries for each of the applications you’re accessing internally, I’ve not yet found a way of automating this.

(Optional) Virtual IP Address #

In an effort to make my setup ‘Highly Available’, I’ve chosen to use keepalived to have a virtual IP address floating between my Docker Swarm master nodes. My internal applications then resolve back to this IP, so it doesn’t matter if one of the nodes goes down. This step is entirely optional.

Part 1: Cloudflare DNS #

The documentation available for Cloudflare is excellent, so I won’t recreate all the steps necessary here. You will need a domain configured for through Cloudflare DNS, but don’t add any DNS records for now. Our end goal is to have yourdomain.com set up as our primary point of access pointing at our tunnel into Traefik, with all our other applications running on subdomains (application.yourdomain.com) running through the same tunnel.

Part 2: Traefik Configuration #

Next up, we need something running within our home network to act as a Reverse Proxy. This will accept incoming connections via our tunnel, and route them to the appropriate application as defined by a set of rules. There are many options for doing this within Docker, however I’ve chosen to use Traefik because of its ability to read directly from Docker service labels, and the number of helper applications available. My Docker Compose setup for Traefik starts something like this:

version: '3.7'
services:
  reverse-proxy:
    image: traefik:v2.10
    command:
      - "--log"
      - "--log.level=${LOG_LEVEL:-INFO}"
      - "--log.format=json"
      - "--api.insecure=true"
      - "--providers.docker"
      - "--providers.docker.swarmMode=true"
      - "--providers.docker.exposedbydefault=false"
      - "--serversTransport.insecureSkipVerify=true" # Allow self-signed certificates for target hosts - https://doc.traefik.io/traefik/routing/overview/#insecureskipverify
      - "--entrypoints.web.address=:80"
      - "--entrypoints.web.http.redirections.entrypoint.to=websecure"
      - "--entrypoints.web.http.redirections.entrypoint.scheme=https"
      - "--entrypoints.websecure.address=:443"
      - "--entrypoints.websecure.http.tls=true"
      - "--entrypoints.websecure.http.tls.certresolver=letsencrypt"
      - "--certificatesresolvers.letsencrypt.acme.email=<YOUR EMAIL>"
      - "--certificatesresolvers.letsencrypt.acme.storage=/etc/traefik/acme/letsencrypt.json"
      - "--certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare"
      - "--certificatesresolvers.letsencrypt.acme.dnschallenge.delaybeforecheck=300"
      - "--certificatesresolvers.letsencrypt.acme.dnschallenge.resolvers=8.8.8.8:53"
    secrets:
      - cf_token
    environment:
      - CLOUDFLARE_DNS_API_TOKEN_FILE=/run/secrets/cf_token
      - CLOUDFLARE_HTTP_TIMEOUT=${HTTP_TIMEOUT}
      - CLOUDFLARE_POLLING_INTERVAL=${POLLING_INTERVAL}
      - CLOUDFLARE_PROPAGATION_TIMEOUT=${PROPAGATION_TIMEOUT}
      - CLOUDFLARE_TTL=${TTL}
    deploy:
      placement:
        constraints:
          - node.role == manager
      labels:
        - traefik.enable=true

        - traefik.http.routers.api.rule=Host(`traefik.${ROOT_DOMAIN}`)
        - traefik.http.routers.api.service=api@internal
        - traefik.http.routers.api.entrypoints=websecure
        - traefik.http.routers.api.tls=true

        - traefik.http.services.api.loadbalancer.server.port=8080
    ports:
      # HTTP
      - target: 80
        published: 80

      # HTTPS
      - target: 443
        published: 443
        
      # Web UI (enabled by --api.insecure=true)
      - target: 8080
        published: 8080
    networks:
      - traefik
      - internal
    volumes:
      # So that Traefik can listen to the Docker events
      - /var/run/docker.sock:/var/run/docker.sock
      - acme:/etc/traefik/acme
      - traefik:/config

The environment variables can be found here. If you want to skip ahead and see the full docker-compose.yml file, it’s available here

In a nutshell, this creates a Traefik Reverse Proxy instance, with the Web GUI available at https://traefik.yourdomain.com. We’ve also configured automatic creation of SSL certificates, which will be stored in a acme volume. As we’re using the ACME DNS-01 challenge, this should work straight away, even though your reverse proxy isn’t yet accessible from the internet.

You’ll need to create an API key through Cloudflare that has access to Zone : Zone Settings : Read, Zone : Zone : Read and Zone : DNS : Edit for at least the domain you’re using in this example. This API key should be kept in a Docker Secret called cf_token - this is a hardcoded requirement for one of the later packages we’re using.

You’ll also need an external Docker overlay Network called traefik that we will use for Traefik to talk to our applications within our home network. Speaking of which, now would be a good time to fire up a test application and make sure everything is working so far. You can use the Traefik whoami application for ease of testing, using this example configuration

Part 3: Cloudflare Tunnel #

Now we have services running internally being routed via Traefik, we need a way of allowing external access. One option is just to open up a ports 80 and 443 on your router/firewall, point these at your Docker nodes, and call it good, however this isn’t best practice, and may not work for you at all if you’re behind CGNAT. Thankfully, Cloudflare has an easy option that allows us to create a link between their network and ours - Cloudflare Tunnel.

To configure this, you’ll need to sign up for a Zero Trust service through your account dashboard (don’t worry, this is free!). Once you’ve done that, go Networks > Tunnels, click Create a tunnel and select cloudflared. You can name the tunnel whatever you want (I used the root domain name), and then copy the command for running cloudflared in Docker. We’re not actually going to use that command, but you need to grab the token variable for use in the next steps.

Once you have this token, we can create another service within our existing traefik-docker-compose.yml, either pasting the token variable directly, or using environment variables.

  tunnel:
    container_name: cloudflared-tunnel
    image: cloudflare/cloudflared
    restart: unless-stopped
    command: tunnel run
    networks:
      - traefik
    environment:
      - TUNNEL_TOKEN=${TUNNEL_TOKEN}

Once that’s running, you should see your connector pop up on the Cloudflare website, and you can move on to the configuration.

We’re going to set up a wildcard for our domain, so enter the following settings:

Subdomain: *
Domain: yourdomain.com
Path: <empty>
Type: HTTPS
URL: reverse-proxy
TLS > Origin Server Name: *.yourdomain.com

Once created, you’ll need to copy the Tunnel ID and go to the DNS Records for your domain. We’ll create a new record with these settings:

Type: CNAME
Name: yourdomain.com
Target: <Tunnel ID>.cfargotunnel.com
Proxied: True

Part 4: Cloudflare Companion #

Next up, we need to let Traefik know which of our applications should be externally resolvable, and pass these through to Cloudflare. Thankfully, there exists an excellent tool aptly named docker-traefik-cloudflare-companion, which reads from the configuration being provided to Traefik, and updates your DNS Records on Cloudflare to add CNAME records where necessary. We can use these to point at our root domain configured above through the Cloudflare Tunnel.

Adding another service to our traefik-docker-compose.yml file:

  cloudflare-companion:
    image: ghcr.io/tiredofit/docker-traefik-cloudflare-companion:latest
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    deploy:
      placement:
        constraints:
          - node.role == manager
    environment:
      - TIMEZONE=Europe/London

      - LOG_TYPE=CONSOLE
      - LOG_LEVEL=INFO

      - TRAEFIK_VERSION=2
      - RC_TYPE=CNAME

      - TARGET_DOMAIN=${ROOT_DOMAIN}
      - REFRESH_ENTRIES=TRUE

      - DOCKER_SWARM_MODE=TRUE

      - ENABLE_TRAEFIK_POLL=TRUE
      - TRAEFIK_POLL_URL=https://traefik.${ROOT_DOMAIN}/api
      - TRAEFIK_FILTER_LABEL=traefik.constraint
      - TRAEFIK_FILTER=proxy-public

      - DOMAIN1=${ROOT_DOMAIN}
      - DOMAIN1_ZONE_ID=${ZONE_ID}
      - DOMAIN1_PROXIED=TRUE
    restart: always
    networks:
      - internal
    secrets:
      - cf_token

In summary, this service will look for any services we have running that contain the label traefik.constraint=proxy-public and add a CNAME record pointed at yourdomain.com.

We can then create a new service which will become externally resolvable:

version: '3.7'

services:
  whoami:
    image: traefik/whoami
    command:
       - --name=externalapp
    deploy:
      labels:
        - "traefik.enable=true"
        - "traefik.docker.network=traefik"  

        - "traefik.http.routers.external.rule=Host(`external.yourdomain.com`)"
        - "traefik.http.routers.external.entrypoints=websecure"
        - "traefik.http.routers.external.tls=true"
        
        - "traefik.http.services.external.loadbalancer.server.port=80"

        - "traefik.constraint=proxy-public"

networks:
  traefik:
    external: true

After a few minutes, check your Cloudflare DNS Records, and you should see the additional CNAME entry appear. Note that this will not be automatically removed if you stop the service running. Once it has appeared, you should be able to access https://external.yourdomain.com from anywhere externally, running via your Cloudflare Tunnel

Part 5: Google Authentication #

While you may want some services accessible by the general internet-browsing public, you may also want to limit access. The most secure way of doing this would be to not expose your network at all, and instead use a Virtual Private Network, however this is not always an option.

There are many different ways of requiring some form of authentication, ranging from basic HTTP authentication all the way through to hosting your own Single Sign-On application, but I’ve chosen to use Google oAuth rather than having yet another set of credentials.

We add (yet another) service to our traefik-docker-compose.yml:

  traefik-forward-auth:
    image: thomseddon/traefik-forward-auth:2.1.0
    networks:
      - traefik
    environment:
      - PROVIDERS_GOOGLE_CLIENT_ID=${PROVIDERS_GOOGLE_CLIENT_ID}
      - PROVIDERS_GOOGLE_CLIENT_SECRET=${PROVIDERS_GOOGLE_CLIENT_SECRET}
      - SECRET=${SECRET}
      - AUTH_HOST=auth.${ROOT_DOMAIN}
      - COOKIE_DOMAIN=${ROOT_DOMAIN}
      - WHITELIST=${WHITELIST}
    deploy:
      labels:
        - traefik.enable=true
        - traefik.docker.network=traefik

        - traefik.http.routers.auth.rule=Host(`auth.${ROOT_DOMAIN}`)
        - traefik.http.routers.auth.entrypoints=websecure
        - traefik.http.routers.auth.tls=true
        - traefik.http.routers.auth.tls.domains[0].main=${ROOT_DOMAIN}
        - traefik.http.routers.auth.tls.domains[0].sans=*.${ROOT_DOMAIN}
        - traefik.http.routers.auth.tls.certresolver=letsencrypt
        - traefik.http.routers.auth.service=auth@docker

        - traefik.http.services.auth.loadbalancer.server.port=4181

        - traefik.http.middlewares.forward-auth.forwardauth.address=http://traefik-forward-auth:4181
        - traefik.http.middlewares.forward-auth.forwardauth.trustForwardHeader=true
        - traefik.http.middlewares.forward-auth.forwardauth.authResponseHeaders=X-Forwarded-User

        - traefik.http.routers.auth.middlewares=forward-auth

        - traefik.constraint=proxy-public

This service acts as a middleware within your Traefik Reverse Proxy, and will redirect any un-authenticated users to sign in via Google. A successful sign-in is then checked against the WHITELIST list of email addresses, and if the authenticated account is present, redirects the user to your application.

We only need to set up a single instance of traefik-forward-auth, and then we add the following label to any service we want to require authentication for (using the external application above as an example):

        - "traefik.http.routers.external.middlewares=forward-auth"

Part 6: Error Pages #

This final step isn’t strictly necessary, as Traefik will serve an error page to end users. However, I prefer to have something slightly nicer looking than the default pages, so I’ve added the excellent error-pages application to my traefik-docker-compose.yml:

  error-pages:
    image: tarampampam/error-pages:2.26.0
    environment:
      TEMPLATE_NAME: l7-dark
    networks:
      - traefik
    deploy:
      labels:
        - traefik.enable=true
        - traefik.docker.network=traefik

        # use as "fallback" for any non-registered services (with priority below normal)
        - traefik.http.routers.error-pages.rule=HostRegexp(`{host:.+}`)
        - traefik.http.routers.error-pages.priority=10

        # should say that all of your services work on https
        - traefik.http.routers.error-pages.tls='true'
        - traefik.http.routers.error-pages.entrypoints=websecure
        - traefik.http.routers.error-pages.middlewares=error-pages
        - traefik.http.services.error-pages.loadbalancer.server.port=8080

        # "errors" middleware settings
        - traefik.http.middlewares.error-pages.errors.status=400-599
        - traefik.http.middlewares.error-pages.errors.service=error-pages
        - traefik.http.middlewares.error-pages.errors.query=/{status}.html

Similarly to the forward-auth middleware, we then add the following label to any service we want to use these error pages:

        - traefik.http.routers.external.middlewares=error-pages

Final Thoughts #

All the code in this guide is available here.

Overall, I’m very happy with this setup so far. I’ve achieved all my aims around reducing the exposure of my personal network to the internet, while still offering some security to any protected applications. The only downside I’ve experienced is that the Traefik Reverse Proxy represents a single point of failure within this setup. If the traefik service is unavailable within my Docker Swarm (such as the node being restarted), there will be momentary downtime while a new instance is spun up. None of the applications I’m running require true ‘high availability’, so this is something I’m prepared to compromise on for the time being.

I should also note that very little of this guide is my own work, I’ve cobbled it together from various other sources. I’ve tried my best to link these where I can remember the original source, however if I’ve noticably used your work without credit then please reach out, I’ll gladly reference the source.

I hope this guide has proven useful, please reach out via any of my contact links if you spot any mistakes or feel that something needs more clarification.