Secscape

Docker config with traefik as reverse proxy

In the past, I used to self-hosted a Nextcloud instance for things like calendar and fileshare. After that I also hosted my own Bitwarden server. Then even a Wireguard server. In the past I used docker and docker-compose. For ssl stuff I also used Caddy. But recently I was rethinking my server pricing, especially since I saw the recent pricing changes from Hetzner. This made me think, why shouldn’t I update the configs as well?

So first I thought about what applications I wanted: Nextcloud, Bitwarden, VPN server and a personal blog. Once I had that figured out, I thought about how to use them on the server. I could either just install them using the package manager and configure each one. This wasn’t a good option for me as I like my systems clean and want to be able to replicate easily if I change providers. Another method would be to use Flatpak/Snap applications. This doesn’t have the dirty system problem, but if I want to switch providers, I have to reinstall everything on the other system.

So I decided to use containers, especially Docker, since I already had some knowledge of it and it has a lot of resources to read and find bugs. With docker I also like to use docker-compose files to easily start/stop multiple containers and have all the configuration in one file.

So to get started I looked at dockerhub for the applications I wanted:

services:
############################## blog ##############################
  blog:
    image: nginx:latest
    container_name: blog
    restart: unless-stopped
    volumes:
      - ./data/blog:/usr/share/nginx/html

############################## bitwarden ##############################
  vaultwarden:
    image: vaultwarden/server:latest
    container_name: vaultwarden
    restart: unless-stopped
    volumes:
      - ./data/vaultwarden:/data
    environment:
      - ADMIN_TOKEN=${VAULTWARDEN_ADMIN_PASSWORD}
  
############################## nextcloud ##############################
  mariadb:
    image: mariadb:10.6
    container_name: mariadb
    command: --transaction-isolation=READ-COMMITTED --log-bin=binlog --binlog-format=ROW
    restart: unless-stopped
    volumes:
      - ./data/nextcloud/db:/var/lib/mysql
    environment:
      - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
      - MYSQL_PASSWORD=${MYSQL_PASSWORD}
      - MYSQL_DATABASE=nextcloud
      - MYSQL_USER=nextcloud

  redis:
    image: redis:alpine
    container_name: redis
    restart: unless-stopped

  nextcloud:
    image: nextcloud:latest
    container_name: nextcloud
    restart: unless-stopped
    volumes:
      - ./data/nextcloud/cloud:/var/www/html
    environment:
      - MYSQL_PASSWORD=${MYSQL_PASSWORD}
      - MYSQL_DATABASE=nextcloud
      - MYSQL_USER=nextcloud
      - MYSQL_HOST=mariadb
      - REDIS_HOST=redis

############################## wireguard ##############################
  wg-easy:
    image: weejewel/wg-easy
    container_name: wg-easy
    restart: unless-stopped
    environment:
      - WG_HOST=wg.${DOMAIN}
      - PASSWORD=${WG_PASSWORD}
    volumes:
      - ./data/wireguard:/etc/wireguard
    ports:
      - 51820:51820/udp
    cap_add:
      - NET_ADMIN
      - SYS_MODULE
    sysctls:
      - net.ipv4.ip_forward=1
      - net.ipv4.conf.all.src_valid_mark=1

This compose file includes Nextcloud, Bitwarden (Vaultwarden), Wireguard, and a simple nginx server where I can later install my static files for my blog. Most of the configuration is just copied from the dockerhub pages of each image. I also added some environment variables that I will add later via an .env file.

After all that I would still like to have a reverse proxy so that I can browse the pages with ssl/tls encryption. There are many options for this, like Caddy, nginx, apache, … . I looked around and tried to do some things with nginx. While there are nginx proxy images, like nginx-proxy, I would rather use plain nginx and then configure it. But there was the problem that it couldn’t really be done by just dropping some env vars into the nginx container and letting it do it all by itself. So that was a no-go. I also looked at Caddy, but I had already done that once and wanted to try something different.

So I decided to go with Traefik, which is also a very popular reverse proxy that works very well with containers. Traefik does a lot of things on its own if you give it access to the Docker socket. After reading through some examples and documentation, I managed to get a container up and running:

  traefik:
    image: traefik:latest
    container_name: traefik
    command:
      # - "--log.level=DEBUG"
      - "--providers.docker=true"
      - "--providers.docker.exposedbydefault=false"
      - "--entryPoints.web.address=:80"
      - "--entryPoints.websecure.address=:443"
      - "--certificatesresolvers.myresolver.acme.httpchallenge=true"
      - "--certificatesresolvers.myresolver.acme.httpchallenge.entrypoint=web"
      # - "--certificatesresolvers.myresolver.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory"
      - "--certificatesresolvers.myresolver.acme.email=${EMAIL}"
      - "--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json"
    ports:
      - 80:80
      - 443:443
    volumes:
      - ./data/traefik/letsencrypt:/letsencrypt
      - ./data/traefik/log:/var/log/traefik
      - /var/run/docker.sock:/var/run/docker.sock:ro

I hope most of this is self-explanatory. I chose http challange since my domain provider doesn’t support DNS challange (THANKS NAMECHEAP!). The commented out certificateresolver, which should be used if you are still building your site and requesting a lot of certificates from letsencrypt. Letsencrypt has a limit of 50 certificate requests per domain.

Having the container I still need labels so that traefik knows which container it should proxy the request to, so I added some labels to each container depending on the subdomain. I even added similar to each container depending on the subdomain, and also made some special rules for the bitwarden/WG server so that some pages can’t be accessed from the outside:

  blog:
    ...
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.blog.rule=Host(`${DOMAIN}`)"
      - "traefik.http.routers.blog.entrypoints=websecure"
      - "traefik.http.routers.blog.tls.certresolver=myresolver"
    
  vaultwarden:
    ...
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.vaultwarden.rule=Host(`bitwarden.${DOMAIN}`)"
      - "traefik.http.routers.vaultwarden.entrypoints=websecure"
      - "traefik.http.routers.vaultwarden.tls.certresolver=myresolver"
      - "traefik.http.routers.vaultwarden-admin.rule=Host(`bitwarden.${DOMAIN}`) && PathPrefix(`/admin`)"
      - "traefik.http.routers.vaultwarden-admin.entrypoints=websecure"
      - "traefik.http.routers.vaultwarden-admin.tls.certresolver=myresolver"
      - "traefik.http.routers.vaultwarden-admin.middlewares=local@docker"
  
  nextcloud:
    ...
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.nextcloud.rule=Host(`cloud.${DOMAIN}`)"
      - "traefik.http.routers.nextcloud.entrypoints=websecure"
      - "traefik.http.routers.nextcloud.tls.certresolver=myresolver"

  wg-easy:
    ...
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.wg.rule=Host(`wg.${DOMAIN}`)"
      - "traefik.http.routers.wg.entrypoints=websecure"
      - "traefik.http.routers.wg.tls.certresolver=myresolver"
      - "traefik.http.services.wg.loadbalancer.server.port=51821"
      - "traefik.http.middlewares.local.ipallowlist.sourcerange=${IPALLOWLIST}"
      - "traefik.http.routers.wg.middlewares=local@docker"

With that you could just do it. This is a good configuration in itself. But since I’m also a bit into security, I wanted to protect myself a bit more. In particular, I wanted to protect myself against the various vulnscanners that are out there with free and open source frameworks. I knew about Fail2Ban as an intrusion detection software. After looking around, I found that while it would be sufficient, there were other, newer detection software out there. So I found out about crowdsec.

Crowdsec is kind of the same as Fail2Ban, but with a community aspect. It allows you to interact with lists created by the community and even share malicious IPs with other users. This seemed like a good piece of software that I wanted to try out. The first thing I had to do was register and get an API key. After that and reading through the documentation, I made some changes to my docker-compose config. I added the plugin to traefik, added a new middleware for crowdsec, inserted my api key and enabled logging in traefik. These and some other changes leave me with the final configuration.

docker-compose.yml

services:
############################## reverse proxy ##############################
  traefik:
    image: traefik:latest
    container_name: traefik
    depends_on: 
      - crowdsec
    command:
      # - "--log.level=DEBUG"
      - "--accesslog=true"
      - "--accesslog.filepath=/var/log/traefik/access.log"
      - "--providers.docker=true"
      - "--providers.docker.exposedbydefault=false"
      - "--entryPoints.web.address=:80"
      - "--entryPoints.websecure.address=:443"
      - "--certificatesresolvers.myresolver.acme.httpchallenge=true"
      - "--certificatesresolvers.myresolver.acme.httpchallenge.entrypoint=web"
      # - "--certificatesresolvers.myresolver.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory"
      - "--certificatesresolvers.myresolver.acme.email=${EMAIL}"
      - "--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json"
      - "--experimental.plugins.crowdsec-bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
      - "--experimental.plugins.crowdsec-bouncer.version=v1.3.1"
    ports:
      - 80:80
      - 443:443
    volumes:
      - ./data/traefik/letsencrypt:/letsencrypt
      - ./data/traefik/log:/var/log/traefik
      - /var/run/docker.sock:/var/run/docker.sock:ro

############################## blog ##############################
  blog:
    image: nginx:latest
    container_name: blog
    restart: unless-stopped
    volumes:
      - ./data/blog:/usr/share/nginx/html
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.blog.rule=Host(`${DOMAIN}`)"
      - "traefik.http.routers.blog.entrypoints=websecure"
      - "traefik.http.routers.blog.tls.certresolver=myresolver"
      - "traefik.http.routers.blog.middlewares=crowdsec@docker"

############################## bitwarden ##############################
  vaultwarden:
    image: vaultwarden/server:latest
    container_name: vaultwarden
    restart: unless-stopped
    volumes:
      - ./data/vaultwarden:/data
    environment:
      - ADMIN_TOKEN=${VAULTWARDEN_ADMIN_PASSWORD}
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.vaultwarden.rule=Host(`bitwarden.${DOMAIN}`)"
      - "traefik.http.routers.vaultwarden.entrypoints=websecure"
      - "traefik.http.routers.vaultwarden.tls.certresolver=myresolver"
      - "traefik.http.routers.vaultwarden.middlewares=crowdsec@docker"
      - "traefik.http.routers.vaultwarden-admin.rule=Host(`bitwarden.${DOMAIN}`) && PathPrefix(`/admin`)"
      - "traefik.http.routers.vaultwarden-admin.entrypoints=websecure"
      - "traefik.http.routers.vaultwarden-admin.tls.certresolver=myresolver"
      - "traefik.http.routers.vaultwarden-admin.middlewares=local@docker"
  
############################## nextcloud ##############################
  mariadb:
    image: mariadb:10.6
    container_name: mariadb
    command: --transaction-isolation=READ-COMMITTED --log-bin=binlog --binlog-format=ROW
    restart: unless-stopped
    volumes:
      - ./data/nextcloud/db:/var/lib/mysql
    environment:
      - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
      - MYSQL_PASSWORD=${MYSQL_PASSWORD}
      - MYSQL_DATABASE=nextcloud
      - MYSQL_USER=nextcloud

  redis:
    image: redis:alpine
    container_name: redis
    restart: unless-stopped

  nextcloud:
    image: nextcloud:latest
    container_name: nextcloud
    restart: unless-stopped
    volumes:
      - ./data/nextcloud/cloud:/var/www/html
    environment:
      - MYSQL_PASSWORD=${MYSQL_PASSWORD}
      - MYSQL_DATABASE=nextcloud
      - MYSQL_USER=nextcloud
      - MYSQL_HOST=mariadb
      - REDIS_HOST=redis
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.nextcloud.rule=Host(`cloud.${DOMAIN}`)"
      - "traefik.http.routers.nextcloud.entrypoints=websecure"
      - "traefik.http.routers.nextcloud.tls.certresolver=myresolver"
      - "traefik.http.middlewares.crowdsec.plugin.crowdsec-bouncer.enabled=true"
      - "traefik.http.middlewares.crowdsec.plugin.crowdsec-bouncer.crowdseclapikey=${CROWDSEC_LAPI_KEY}"
      - "traefik.http.routers.nextcloud.middlewares=crowdsec@docker"

############################## wireguard ##############################
  wg-easy:
    image: weejewel/wg-easy
    container_name: wg-easy
    restart: unless-stopped
    environment:
      - WG_HOST=wg.${DOMAIN}
      - PASSWORD=${WG_PASSWORD}
    volumes:
      - ./data/wireguard:/etc/wireguard
    ports:
      - 51820:51820/udp
    cap_add:
      - NET_ADMIN
      - SYS_MODULE
    sysctls:
      - net.ipv4.ip_forward=1
      - net.ipv4.conf.all.src_valid_mark=1
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.wg.rule=Host(`wg.${DOMAIN}`)"
      - "traefik.http.routers.wg.entrypoints=websecure"
      - "traefik.http.routers.wg.tls.certresolver=myresolver"
      - "traefik.http.services.wg.loadbalancer.server.port=51821"
      - "traefik.http.middlewares.local.ipallowlist.sourcerange=${IPALLOWLIST}"
      - "traefik.http.routers.wg.middlewares=local@docker"

############################## crowdsec ##############################
  crowdsec:
    image: crowdsecurity/crowdsec
    container_name: crowdsec
    restart: unless-stopped
    environment:
      - COLLECTIONS=crowdsecurity/traefik crowdsecurity/http-cve
      - ENROLL_KEY=${CROWDSEC_ENROLL_KEY}
      - BOUNCER_KEY_traefikBouncer=${CROWDSEC_LAPI_KEY}
    volumes:
      - ./data/traefik/log:/var/log/traefik:ro
      - ./data/crowdsec/db:/var/lib/crowdsec/data
      - ./data/crowdsec/config:/etc/crowdsec
      - ./data/crowdsec/acquis.d:/etc/crowdsec/acquis.d

traefik.yaml

filenames:
  - /var/log/traefik/*
labels:
  type: traefik

.env

EMAIL=
DOMAIN=
VAULTWARDEN_ADMIN_PASSWORD=
MYSQL_ROOT_PASSWORD=
MYSQL_PASSWORD=
WG_PASSWORD=
IPALLOWLIST=172.16.0.0/12
CROWDSEC_LAPI_KEY=
CROWDSEC_ENROLL_KEY=

Now just fill in the .env file and the server runs without problems. I also uploaded all the code to github: https://github.com/crasoke/get-traefiked. If you have any questions or more ideas to the project, just contact me or do a PR or something.

Greetings