Einführung

Diese Webseite zops.top wird momentan über einen kleinen Provider names wint.global in die schöne, weite Welt übertragen. Dazwischen sitzt momentan Cloudflare als nameserver & damit kann ich direkt über dash.cloudflare.com auch auf meine DNS-Einträge zugreifen. So wird der gesamte Traffic durch Cloudflare geleitet (Proxy) und die “echte” IP-Adresse des Servers wird nicht bekannt gegeben.

Macht man ein traceroute oder tracert (Windows) auf zops.top erhält man die IP-Adresse eines Cloudflare-Servers.

▶ traceroute zops.top
traceroute: Warning: zops.top has multiple addresses; using 104.31.93.242
traceroute to zops.top (104.31.93.242), 64 hops max, 52 byte packets
 1  fritz.box                                 (192.168.178.1)  3.652 ms  2.836 ms  3.249 ms
 2  lo1.pop107-asr.ipv4.wtnet.de              (_____________)  4.197 ms  4.061 ms  4.007 ms
 3  b107.graf-zahl.ipv4.wtnet.de              (_____________)  3.150 ms  4.047 ms  4.249 ms
 4  cloudflare.ham.ecix.net                   (193.42.155.58)  4.165 ms
    ipv4.de-cix.ham.de.as13335.cloudflare.com (80.81.203.10)  4.115 ms  3.012 ms
 5  104.31.93.242                             (104.31.93.242)  4.388 ms  4.765 ms  3.415 ms

Nun habe ich einen zweiten Server gemietet, möchte jedoch meine Domain weiterhin bei wint.global behalten.

Projektidee und -ziel

Mein Ziel ist es, verschiedene subdomains unter meiner Domain zops.top zu haben, die nicht auf den Server von wint.global verweisen, sondern auf den neuen Server bei einem anderen Anbieter.

Bei dem anderen Anbieter habe ich eine simplen VPS (Virtual Private Server). Wenn ich darauf z. B. eine Webseite hosten möchte, muss ich die nötigen Pakete installieren & die gesamte Konfiguration selbst machen.

Die simpelste Lösung verschiede Services unter einer Domain anzubieten, wäre einfach alles auf verschiedenen Ports laufen zu lassen - das ist aber meiner Meinung nach ziemlich unangenehm. “Versteckte” Services kann man ja so laufen lassen, aber sobald man z. B. Nextcloud nutzen möchte, macht es schon Sinn, dass die Domain dann auch cloud.example.com o. ä. heißt, anstatt example.com:6464.

Die Frage ist nun, wie kann ich am besten mehrere Subdomains mit der selben IP beliefern ohne kostenpflichtige Tools wie Plex zu nutzen?

DNS (Cloudflare)

Ganz grob gesehen, sieht jede Anfrage eines Nutzers in etwa so aus:

High level overview

Der Nameserver, den ich momentan nutze, ist Cloudflare. Meine Domain ist bei wint.global registriert, jedoch verwalte ich alle DNS-Einträge über Cloudflare.

Daher würde ein Browser den Weg gehen, den wir oben via traceroute gegangen sind; von eurem Endgerät -> Router -> Cloudflare -> Cloudflare Proxy.

Dass die Proxy dann meinen Server kontaktiert, Caching und DDOS-Sicherung betreibt, bemerkt ein Endnutzer gar nicht. Ist ja auch eigentlich gar nicht wichtig.

Die Domain zops.top wird über die Cloudflare Proxy in die große weite Welt geschickt, da die Einstellung in meinem Account so vorgenommen wurde.

Einige Subdomains jedoch werden auf einen anderen Server weitergeleitet - mein neuer VPS. Dieser benutzt Cloudflare nicht als Proxy - daher ist die IP auch bei einem traceroute direkt bekannt.

▶ traceroute server.zops.top
traceroute to server.zops.top (161.97.81.52), 64 hops max, 52 byte packets
 1  fritz.box                       (192.168.178.1)  3.114 ms  2.880 ms  2.165 ms
 2  lo1.pop107-asr.ipv4.wtnet.de    (_________)  6.768 ms  4.007 ms  3.719 ms
 3  b107.graf-zahl.ipv4.wtnet.de    (_________)  4.883 ms  6.003 ms  3.956 ms
 4  b379.bert.ipv4.wtnet.de         (_________)  4.780 ms  9.000 ms  5.282 ms
 5  ae60.edge1.hamburg1.level3.net  (62.67.25.117)  5.600 ms  6.018 ms  4.385 ms
 6  * * *
 7  * * *
 8  vmd57207.contaboserver.net      (161.97.81.52)  17.931 ms !Z  17.919 ms !Z  17.092 ms !Z

Wie man sieht, gehört der Server zu contabo.

Ändern der DNS-Einträge

Im Normalfall haben wir die Möglichkeit unsere DNS-Einträge anzupassen. Da Cloudflare diese Aufgabe für mich übernimmt, tue ich das auch bei Cloudflare.

Dafür müssen wir mindestens einen A-Eintrag hinzufügen - dieser verweist eine domain auf eine IPv4-Adresse. Für IPv6-Weiterleitung benötigen wir noch einen AAAA-Eintrag.

Danach können wir CNAME-Einträge definieren, um z. B. www.example.com auf example.com weiterzuleiten.

So siehen z. B. die Einträge für server.zops.top aus.

Type Name Inhalt TTL Beschreibung
A cloud 161.97.81.52 auto cloud.zops.top -> 161.97.81.52
AAAA cloud IPv6-Adresse.. auto cloud.zops.top -> IPv6-Adresse
CNAME www.cloud cloud.zops.top auto www.cloud.zops.top -> cloud.zops.top -> 161.97.81.52

Nachdem das ganze eingestellt ist, muss unser Server damit umgehen können. Die DNS-Einträge bringen alle Nutzer erstmal einfach direkt auf die IP unseres Servers - bis jetzt ist noch kein Service da.

Da der Browser bei http-Anfragen grundsätzlich auf Port 80 schaut & bei https auf Port 443, würde ein Nutzer, der jetzt https://server.zops.top/ aufruft, einfach nach 161.97.81.52:443 geschickt werden.

Die offene Frage wäre jetzt:

Was machen wir, wenn wir noch weitere Subdomains haben, aber keine weiteren Server kaufen wollen? - Gleichzeitig, möchte ich immer Port 80 & 443 nutzen

An einem Server, kann man nicht den selben Port doppelt belegen, daher muss ein proxy her.

Proxy / Edge router

Es gibt einige gute Lösungen, die wir problemlos mit docker nutzen könnten. Folgende z. B.

Zuerst habe ich das Projekt mit Nginx reverse-proxy gelöst, aber mir gefiel die Anbindung an Let's Encrypt nicht und außerdem gibt es wenige Anpassungen, die ich am reverse-proxy hinzufügen konnte (ohne es unnötig komplex zu machen), weshalb ich mich für Traefik entschieden habe.

In naher Zukunft würden wir es aber höchstwahrscheinlich auf Envoy ummünzen, da es im Benchmark besser abgeschnitten hat.

Ein reverse-proxy - bzw. im Falle von Traefik ein Edge Router - beantwortet die große Frage von oben.

Alle Anfragen treffen zuerst auf Traefik & Traefik schaut sich dann die Domain an, bewertet wohin die Anfrage weitergeleitet werden soll und antwortet danach dem Nutzer.

my alt text
Traefik v2 Architektur (von https://docs.traefik.io/)

Ich nutze Traefik mit Docker Swarm um eine Nextcloud-Instanz und zwei Instanzen meiner Webseite zu erstellen. Die Instanzen der Webseite bekommen über den Traefik-Loadbalancer die passende Menge an Anfragen zugespielt.

Docker Swarm hilft uns dabei immer eine bestimmte Menge an Instanzen zu haben - so wird sofort eine neue Instanz erstellt, sollte die alte Instanz kaputt gehen. Natürlich muss man darauf achten, dass ein Container niemals so wichtig ist, dass wir es nicht einfach wegwerfen können - Pets vs. Cattle.

Installation

Docker & Docker swarm

Ich denke mittlerweile sollten alle Docker installiert haben, falls nicht, dann einfach den Anweisungen hier folgen: https://docs.docker.com/get-docker/)

Docker swarm kann man nutzen, wenn man möchte - muss man aber nicht! Falls also keine Replicas o. ä. benutzt werden sollen, reicht es einfach Docker im normalen Modus zu belassen.

Hier die aktuelle Dokumentation dazu: https://docs.docker.com/engine/swarm/swarm-mode/

Grundsätzlich, muss man nur folgenden Befehl eingeben & sich den Token irgendwo aufschreiben.

  docker swarm init

Sollte man mehrere Nodes haben, kann man über

  docker swarm join --token ${TOKEN} ${IP}:${PORT}

dem swarm beitreten.

docker-compose Dateien

Grundsätzlich sieht mein Ordneraufbau so aus:

.
|____traefik
| |____providers
| | |____file-provider.toml
| |____traefik.toml
| |____passwordfile
| |____tls.env
| |____Readme.md
| |____acme.json
| |____docker-compose.yaml
|
|____zrezai-dev
| |____dev
| | |____...
| | |____...
| |____Dockerfile.Hugo
| |____docker-compose.yaml
|
|____nexworking
| |____db.env
| |____redis.env
| |____nextcloud.env
| |____docker-compose.yaml
|
|____Taskfile.yml

nexworking steht für Nextcloud. In den nächstne Schritten gehen wir einmal alle Daten durch.

Der Gesamtaufbau sieht in etwa so aus:

Traefik high level overview

Webseite (Hugo)

Für meine Webseite nutze ich Hugo, falls ihr etwas anderes benutzt, muss hier natürlich eines angepasst werden. Wenn ihr nur statisches html habt, könnt ihr eigentlich alles genau so kopieren - nur der blog_builder ist dann nicht mehr nötig.

ARG ALPINE_VERSION=3.12

FROM alpine:${ALPINE_VERSION} as hugobase

ENV HUGO_VERSION=0.74.3

WORKDIR /tmp
VOLUME [ "/tmp" ]

# Install HUGO
RUN set -x \
  && apk add --update wget ca-certificates libstdc++ libc6-compat \
  && wget -q -O hugo.tar.gz https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_Linux-64bit.tar.gz \
  && tar xzf hugo.tar.gz hugo \
  && mv hugo /usr/bin \
  ## Cleanup
  && rm -r hugo.tar.gz \
  && apk del wget ca-certificates \
  && rm /var/cache/apk/*

Das Dockerfile ist recht minimalistisch - basierend auf Alpine, wird nur die aktuelle Version von Hugo heruntergeladen und kann dann ausgeführt werden.

Als Volume definiere ich hier /tmp - darin können wir mit der docker-compose unsere Datein mounten & dann mit dem hugo-command statische html-Dateien erstellen.

Das Image ist im Dockerhub hochgeladen und als xcalizorz:hugo:1.0-alpine veröffentlicht - so können wir es mir Docker Swarm oder Kubernetes nutzen.

Im nächsten Schritt beschreiben wir unsere docker-compose.yaml

blog_builder

version: "3.8"

services:
  blog_builder:
    image: xcalizorz/hugo:1.0-alpine
    deploy:
      replicas: 1
      placement:
        max_replicas_per_node: 1
      update_config:
        parallelism: 1
        delay: 10s
      restart_policy:
        condition: none
      labels:
        - traefik.enable=false
    volumes:
      - ./dev:/tmp
    command: hugo --minify --baseUrl https://server.zops.top/
    networks:
      - web

    ...
    ..
    .

Der erste Service ist der blog_builder, mit dem gegebenen Command hugo --minify --baseUrl https://server.zops.top/ werden alle html-Dateien erstellt und unter dem Ordner public abgeworfen. So können dann andere Services auf den Ordner public zugreifen, um dessen Inhalt zu veröffentlichen.

Die Labels sind hier eigentlich unwichtig, da bei beiden einfach gesagt wird bitte ignorieren. Dieser Service ist kurzlebig & endet, sobald der hugo-Befehl beendet wurde.

blog

  ...
  ..
  .

  blog:
    image: nginx:alpine
    deploy:
      replicas: 2
      placement:
        # sinnvoll bei vielen nodes & replicas
        max_replicas_per_node: 2
      resources:
        # es ist besonders wichtig resourcen einzuschränken
        limits:
          cpus: '0.05'
          memory: 50M
      update_config:
        # updates immer nur nacheinander & mit 10 sek. Wartezeit
        parallelism: 1
        delay: 10s
      restart_policy:
        condition: on-failure
        delay: 5s
        max_attempts: 3
        window: 30s
      labels:
        # traefik wird diesen Service beachten/einbinden
        - traefik.enable=true
        # Füge dem Router einen Service hinzu
        - traefik.http.routers.blog.service=blogService
        # Verlange tls-Verschlüsselung
        - traefik.http.routers.blog.tls=true
        # zrezaiResolver wird in der traefik-config definiert
        - traefik.http.routers.blog.tls.certresolver=zrezaiResolver
        # Die Außenwelt greift über Port 443 (websecure) auf diesen Service zu
        - traefik.http.routers.blog.entrypoints=websecure
        # Das Docker Netzwerk wird an traefik weitergegeben
        - traefik.docker.network=web
        # Da wir aktuell die simpelste Form von `nginx` nutzen, veröffentlichen wir nur auf Port 80 - `tls` etc. wird von `traefik` gemacht.
        - traefik.http.services.blogService.loadbalancer.server.port=80
        - traefik.http.services.blogService.loadbalancer.passhostheader=true
        - traefik.http.services.blogService.loadbalancer.sticky.cookie=true
        # Die Domain, die hier her führen soll
        - traefik.http.routers.blog.rule=Host(`server.zops.top`)
        # Alle Middlewares - werden bei Traefik definiert
        - "traefik.http.routers.blog.middlewares=blogHeaders@file, simpleRatelimiter@file, simpleInflightreq@file"
    depends_on:
      - blog_builder
    volumes:
      # Die generierten Daten unter `public` werden an nginx weitergegeben
      - ./dev/public:/usr/share/nginx/html
    networks:
      - web

networks:
  # Es wird ein overlay Netzwerk kreiert
  web:
    driver: overlay
    # Wenn der Name fehlt, bekommt das Netzwerk einen Präfix!
    name: web
    driver_opts:
      # Das Netzwerk wird verschlüsselt
      encrypted: "true"

Resourcen einzuschränken ist enorm wichtig. Sollte es z. B. jemals dazu kommen, dass eines der Container eingenommen wird & man nicht automatisiert oder sofort reagiert, könnte der Container die gesamten Resourcen des Servers auffressen.

In weiteren Posts werde ich mich etwas mehr mit Sicherheit befassen. Grundsätzlich sind die Dinge, die wir jetzt sehen nicht unbedingt super “unsicher”, da alles auf isolierten Containern basiert und wir auf viele best practices achten, aber es geht natürlich immer besser.

Weitere Daten brauchen wir für den Blog nicht.

Nextcloud

version: "3.8"

services:
  db:
    image: postgres:alpine
    deploy:
      replicas: 1
      placement:
        max_replicas_per_node: 1
      update_config:
        parallelism: 1
        delay: 10s
      restart_policy:
        condition: on-failure
      labels:
        # traefik soll diesen Service ignorieren
        - traefik.enable=false
    volumes:
      - nc_db:/var/lib/postgresql/data
    env_file:
      # alle environment variablen sind in db.env definiert
      - db.env
    networks:
      - nextcloud

  nc_redis:
    image: redis:alpine
    deploy:
      replicas: 1
      placement:
        max_replicas_per_node: 1
      update_config:
        parallelism: 1
        delay: 10s
      restart_policy:
        condition: on-failure
      labels:
        # traefik soll diesen Service ignorieren
        - traefik.enable=false
    ports:
      - 6379
    volumes:
      - nc_redis:/data
      - /etc/localtime:/etc/localtime:ro
    networks:
      - nextcloud

  nextcloud:
    image: nextcloud:apache
    # für Apache
    hostname: cloud.zops.top
    deploy:
      replicas: 1
      placement:
        max_replicas_per_node: 2
      resources:
        limits:
          cpus: '0.50'
          # Nextcloud hätte gerne 128MB bis 512MB RAM
          memory: 512M
      update_config:
        parallelism: 1
        delay: 10s
      restart_policy:
        condition: on-failure
      labels:
        # traefik soll diesen service beachten
        - traefik.enable=true
        # es soll ein Zertifikat ausgestellt werden
        - traefik.http.routers.nextcloud.service=nextcloudService
        - traefik.http.routers.nextcloud.tls=true
        - traefik.http.routers.nextcloud.tls.certresolver=zrezaiResolver
        # die Außenwelt greift über Port 443 (websecure) auf diesen Service zu
        - traefik.http.routers.nextcloud.entrypoints=websecure
        # das Docker Netzwerk wird an traefik weitergegeben
        - traefik.docker.network=nextcloud
        - traefik.http.services.nextcloudService.loadbalancer.server.port=80
        - traefik.http.services.nextcloudService.loadbalancer.passhostheader=true
        - traefik.http.services.nextcloudService.loadbalancer.sticky.cookie=true
        # Einstellungen damit caldav funktioniert
        - traefik.http.middlewares.nextcloud-caldav.redirectregex.permanent=true
        - traefik.http.middlewares.nextcloud-caldav.redirectregex.regex=^https://(.*)/.well-known/(card|cal)dav
        - traefik.http.middlewares.nextcloud-caldav.redirectregex.replacement=https://$${1}/remote.php/dav/
        # alle Middlewares - werden bei Traefik definiert
        - "traefik.http.routers.nextcloud.middlewares=nextcloud-caldav@docker, nextcloudHeaders@file, nextcloudRatelimiter@file"
        # die Domain, die hier her führen soll
        - traefik.http.routers.nextcloud.rule=Host(`cloud.zops.top`)
    volumes:
      - nextcloud:/var/www/html
    env_file:
      # alle wichtigen env. variablen
      - db.env
      - redis.env
      - nextcloud.env
    depends_on:
      - db
      - nc_redis
    networks:
      - nextcloud

volumes:
  nextcloud:
  nc_db:
  nc_redis:

networks:
  # es wird ein overlay Netzwerk kreiert
  nextcloud:
    name: nextcloud
    driver: overlay
    driver_opts:
      # verschlüsselt
      encrypted: "true"

Env. files

Es gibt insgesamt drei .env-Dateien.

|____nexworking
| |____db.env
| |____redis.env
| |____nextcloud.env
| |____docker-compose.yaml
# db.env

POSTGRES_PASSWORD=SamplePassword123
POSTGRES_DB=db_name
POSTGRES_USER=db_user
POSTGRES_HOST=nextcloud-stack_nc_db

Beim POSTGRES_HOST kommt es darauf an wie der Datenbank Service und der docker swarm stack heißt.

Bei mir heißt der Datenbank Service nc_db und der stack heißt nextcloud-stack -> nextcloud-stack_nc_db

# redis.env

REDIS_HOST=nextcloud-stack_nc_redis

Hier gilt das gleiche wie bei POSTGRES_HOST

# nextcloud.env

NEXTCLOUD_ADMIN_USER=admin
NEXTCLOUD_ADMIN_PASSWORD=NextcloudPassword
# alle Domänen mit Leerzeichen getrennt
NEXTCLOUD_TRUSTED_DOMAINS=www.cloud.zops.top cloud.zops.top

# wir wollen nur über https kommunizieren
OVERWRITEPROTOCOL=https

# hier kann man noch vielese mehr hinzufügen
## z. B. SMTP Server, um E-Mails zu versenden
## Gerne in die Nextcloud Doku. schauen

Traefik

Jetzt kommen wir zum aller wichtigsten.

.
|____traefik
| |____providers
| | |____file-provider.toml
| |____traefik.toml
| |____passwordfile
| |____tls.env
| |____acme.json
| |____docker-compose.yaml

Gehen wir schrittweise durch docker-compose.yaml, um zu verstehen wozu die ganzen anderen Daten gut sind.

docker-compose.yaml

version: "3.8"

services:
  traefikRouter:
    image: traefik:v2.2
    depends_on:
      - dockerproxy
    deploy:
      placement:
        constraints:
          # traefik sollte auf der manager node laufen
          - node.role == manager
      # aktuell möchte ich es nur einmal haben
      replicas: 1
      restart_policy:
        condition: on-failure
        delay: 5s
        max_attempts: 3
        window: 30s
      labels:
        - traefik.enable=true
        - traefik.docker.network=traefik-network
        # das Dashboard soll unter `traefik.zops.top` erreichbar sein
        - traefik.http.routers.traefikRouter.rule=Host(`traefik.zops.top`)
        # entrypoint ist `traefik` (Port 8080)
        - traefik.http.routers.traefikRouter.entrypoints=traefik
        # ein Zertifikat soll erstellt werden
        - traefik.http.routers.traefikRouter.tls=true
        - traefik.http.routers.traefikRouter.tls.certresolver=zrezaiResolver
        # api@interal ist ein interner Service, den wir nutzen wollen
        - traefik.http.routers.traefikRouter.service=api@internal
        # Alle middlewares - definition kommt gleich
        - "traefik.http.routers.traefikRouter.middlewares=auth@file, simpleHeaders@file"
        # dummy service für Swarm port detection. Der Port ist egal.
        - traefik.http.services.dummy-svc.loadbalancer.server.port=9999
        # globaler redirect to https
        - "traefik.http.routers.http-catchall.rule=hostregexp(`{host:.+}`)"
        - "traefik.http.routers.http-catchall.entrypoints=web"
        - "traefik.http.routers.http-catchall.middlewares=redirect-to-https"
        - "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https"
    ports:
      # wir wollen unter Port 80, 443 und 8080 kontaktiert werden (entrypoints)
      - 80:80
      - 443:443
      - 8080:8080
    volumes:
      # die Konfigurationsdatei für traefik
      - ./traefik.toml:/traefik.toml
      # hierin werden die tls Zertifikate gespeichert
      - ./acme.json:/acme.json
      # unser admin user & gehashtes Password für das Dashbord
      - ./passwordfile:/var/passwordfile
      # extra Konfigurationen (für z. B. Middlewares)
      - ./providers:/providers
    env_file:
      # env. variablen für Cloudflare TLS (nicht nötig, wenn wir HTTP Verifikation nutzen)
      - ./tls.env
    networks:
      - traefik-network
      - web
      - nextcloud

  # ein docker proxy - dieser ist von der Außenwelt nicht erreichbar
  # nur der dockerproxy hat Zugriff auf unseren Docker Socket (Sicherheitsrisiko)
  # Traefik greift über Anfragen auf diesen Container dann auf unser Socket zu
  # Sollte Traefik je angegriffen werden, ist es etwas schwieriger vollen
  # Zugriff auf unseren Docker Socket zu bekommen
  ##
  # Wenn jemand Zugriff auf Docker hat, hat er auch quasi vollen Zugriff
  # auf den Hostserver (!)
  dockerproxy:
    environment:
      CONTAINERS: 1
      NETWORKS: 1
      SERVICES: 1
      TASKS: 1
    image: tecnativa/docker-socket-proxy
    labels:
      - traefik.enable=true
    networks:
      - traefik-network
    ports:
      - 2375
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock

networks:
  # Integration der beiden anderen Netzwerke
  # Traefik kann sonst nicht auf die Services darin zugreifen
  web:
    external: true
  nextcloud:
    external: true
  traefik-network:
    driver: overlay
    name: traefik-network
    driver_opts:
      encrypted: "true"

traefik.toml

Ich habe einige Kommentare entfernt, damit es nicht zu lang wird.

################################################################
# Global configuration
################################################################
[global]
  checkNewVersion = true
  sendAnonymousUsage = false

################################################################
# Entrypoints configuration
################################################################

# Entrypoints definition
#
# Optional
# Default:
[entryPoints]
  [entryPoints.web]
    address = ":80"

  [entryPoints.websecure]
    address = ":443"

################################################################
# Traefik logs configuration
################################################################

# Traefik logs
# Enabled by default and log to stdout
#
# Optional
#
[log]
  level = "ERROR"

################################################################
# Access logs configuration
################################################################

# Enable access logs
# By default it will write to stdout and produce logs in the textual
# Common Log Format (CLF), extended with additional fields.
#
# Optional
#
[accessLog]

################################################################
# API and dashboard configuration
################################################################

# Enable API and dashboard
[api]

################################################################
# Ping configuration
################################################################

# Enable ping
[ping]

################################################################
# Docker configuration backend
################################################################

# Enable Docker configuration backend
[providers.docker]

  endpoint = "tcp://dockerproxy:2375"
  network = "traefik-network"
  swarmMode = true
  swarmModeRefreshSeconds = "60s"
  useBindPortIP = true
  exposedByDefault = false

# Enable file configuration backend
[providers.file]
  directory = "/providers"

# Enable ACME (Let's Encrypt): automatic SSL.
[certificatesResolvers.zrezaiResolver.acme]
  email = "[email protected]"
  storage = "acme.json"

  # CA server to use.
  # Uncomment the line to use Let's Encrypt's staging server,
  # leave commented to go to prod.
  #
  # caServer = "https://acme-staging-v02.api.letsencrypt.org/directory"
  caServer = "https://acme-v02.api.letsencrypt.org/directory"

  # KeyType to use.
  #
  # Optional
  # Default: "RSA4096"
  #
  # Available values : "EC256", "EC384", "RSA2048", "RSA4096", "RSA8192"
  #
  keyType = "RSA4096"

  # Use a TLS-ALPN-01 ACME challenge.
  #
  # Optional (but recommended)
  #
  # [certificatesResolvers.zrezaiResolver.acme.tlsChallenge]

  # Use a HTTP-01 ACME challenge.
  #
  # Optional
  #
  # [certificatesResolvers.zrezaiResolver.acme.httpChallenge]

    # EntryPoint to use for the HTTP-01 challenges.
    #
    # Required
    #
    # entryPoint = "web"

  # Use a DNS-01 ACME challenge rather than HTTP-01 challenge.
  # Note: mandatory for wildcard certificate generation.
  #
  # Optional
  #
  [certificatesResolvers.zrezaiResolver.acme.dnsChallenge]
    provider = "cloudflare"

Im groben sagen die Kommentare eigentlich schon alles.

Da meine DNS Cloudflare ist, habe ich als let's encrypt-challenge die dnsChallenge ausgewählt.

Falls ihr auch die den acme-test über DNS machen wollt, benötigt ihr eine tls.env, welche im aktuellen docker-compose.yaml angesprochen wird.

Die tls.env sieht wie folgt aus:

CF_API_EMAIL=[email protected]
CF_ZONE_API_TOKEN=
CF_DNS_API_TOKEN=

Die Zone und DNS API muss im Cloudflare Dashboard mit folgenden Rechten erstellt werden:

Typ Description
CF_DNS_API_TOKEN API token with DNS:Edit permission (since v3.1.0)
CF_ZONE_API_TOKEN API token with Zone:Read permission (since v3.1.0)

Weitere Informationen hier: https://go-acme.github.io/lego/dns/cloudflare/

Wenn man die überprüfung nicht über DNS machen will, können wir auch ganz einfach die HTTP-01 ACME challenge oder die TLS-ALPN-01 ACME challenge nutzen.

Bensonders interessant ist folgenden Teil der Konfiguration:


# Enable Docker configuration backend
[providers.docker]

  endpoint = "tcp://dockerproxy:2375"
  network = "traefik-network"
  swarmMode = true
  swarmModeRefreshSeconds = "60s"
  useBindPortIP = true
  exposedByDefault = false

Da wir die dockerproxy nutzen, um unser Docker Socket nicht offen für den traefik-Container rumliegen zu lassen, müssen wir natürlich traefik sagen wo unser docker endpoint ist.

exposedByDefault ist auf falsch gesetzt, damit traefik nicht alle Container exposed, sondern nur jene die wir über das traefik.enabled-label veröffentlichen wollen.

Der andere besonders wichtige Teil ist der provider. In traefik kann man außerhalb der Hauptkonfiguration, weitere Konfigurationen hinzufügen. Alle middleware Konfiguration müssen über provider geregelt werden.

Wir sagen in traefik.toml, dass wir im Ordner providers Extrakonfiguration haben:

# Enable file configuration backend
[providers.file]
  directory = "/providers"

Innerhalb dieses Ordners ist aktuell nur die file-provider.toml, jedoch können da viele weitere Konfigurationsdateien sein und alles wird normal eingelesen.

[http.middlewares]
  [http.middlewares.simpleRatelimiter.rateLimit]
    average = 5
    period = "1s"
    burst = 50
  [http.middlewares.nextcloudRatelimiter.rateLimit]
    average = 5
    period = "1s"
    burst = 150
  [http.middlewares.simpleInflightreq.inFlightReq]
    amount = 50
  [http.middlewares.auth.basicAuth]
    usersFile = "/var/passwordfile"
    removeheader = true
  [http.middlewares.blogHeaders.headers]
    frameDeny = true
    sslRedirect = true
    accessControlAllowMethods= ["GET"]
    accessControlMaxAge = 100
    addVaryHeader = true
    stsSeconds = 31536000
    stsIncludeSubdomains = true
    stsPreload = true
    contentTypeNosniff = true
    browserXssFilter = true
    referrerPolicy = "no-referrer"
    featurePolicy = "geolocation 'none'; camera 'none'; microphone 'none'"
    contentSecurityPolicy = """
      default-src 'self' https;
      script-src 'self' 'unsafe-inline' cdn.jsdelivr.net ajax.cloudflare.com;
      style-src 'self' 'unsafe-inline' cdn.jsdelivr.net cdnjs.cloudflare.com fonts.googleapis.com;
      font-src 'self' cdnjs.cloudflare.com fonts.googleapis.com fonts.gstatic.com;
      img-src 'self' i.giphy.com;
    """
    [http.middlewares.blogHeaders.headers.customResponseHeaders]
      Expect-CT = "max-age=604800, enforce;"
      X-Permitted-Cross-Domain-Policies = "none"
      server = "Mein Server"
  [http.middlewares.nextcloudHeaders.headers]
    sslRedirect = true
    accessControlMaxAge = 100
    addVaryHeader = true
    stsSeconds = 31536000
    stsIncludeSubdomains = true
    stsPreload = true
    contentTypeNosniff = true
    browserXssFilter = true
    referrerPolicy = "no-referrer"
    featurePolicy = "geolocation 'none'; camera 'none'; microphone 'none'"
    contentSecurityPolicy = """
      default-src 'self' https;
      script-src 'self' 'unsafe-inline' cdn.jsdelivr.net ajax.cloudflare.com;
      style-src 'self' 'unsafe-inline' cdn.jsdelivr.net cdnjs.cloudflare.com fonts.googleapis.com;
      font-src 'self' cdnjs.cloudflare.com fonts.googleapis.com fonts.gstatic.com;
      frame-ancestors 'self';
    """
    [http.middlewares.nextcloudHeaders.headers.customResponseHeaders]
      Expect-CT = "max-age=604800, enforce;"
      X-Permitted-Cross-Domain-Policies = "none"
      X-Frame-Options = "SAMEORIGIN"
      server = "Mein Server"
  [http.middlewares.simpleHeaders.headers]
    frameDeny = true
    sslRedirect = true
    accessControlMaxAge = 100
    addVaryHeader = true
    stsSeconds = 31536000
    stsIncludeSubdomains = true
    stsPreload = true
    contentTypeNosniff = true
    browserXssFilter = true
    referrerPolicy = "no-referrer"
    featurePolicy = "geolocation 'none'; camera 'none'; microphone 'none'"
    contentSecurityPolicy = """
      default-src 'self' https;
    """
    [http.middlewares.simpleHeaders.headers.customResponseHeaders]
      Expect-CT = "max-age=604800, enforce;"
      X-Permitted-Cross-Domain-Policies = "none"
      server = "Mein Server"

Hier definiere ich einige globale wie wichtige HTTP Header, rate limiting und authentification.

Mit diesen Headern sollte man eine A-Bewertung bei Überprüfungen der Security Header haben. Je nachdem wie eure Services arbeiten und worauf sie zugreifen, sollte man Dinge wie contentSecruityPolicy ändern. Die simpleHeaders-Middleware sollte aber bei den meisten Services passen.

Ein Rätsel könnte das hier sein:

  [http.middlewares.auth.basicAuth]
    usersFile = "/var/passwordfile"
    removeheader = true

Hier definieren wir die middleware mit dem Namen auth und ihr Typ ist basicAuth. Die User, die dann auf die Domains zugreifen können, werden in usersFile definiert.

Die Daten besteht aus Zeilen von user:hash(password) - also alle Passwörter müssen mit MD5, SHA1 oder Bcrypt gehashed sein. Ich nutze Bcrypt, da es vermutlich die beste hashing-methode von den angebotenen ist.

  htpasswd -nbB USER "PASSWORD" | sed '/^$/d' >> passwordfile

Damit wird eine Datein passwordfile erstellt oder erweitert mit dem USER und dem gehashtem Password. Mit sed entfernen wir einfach nur leere Zeilen aus dem Ergebnis von htpasswd.

Starten der Services

  docker stack deploy -c nexworking/docker-compose.yaml nextcloud-stack

  docker stack deploy -c zrezai-dev/docker-compose.yaml blog-stack

  docker stack deploy -c traefik/docker-compose.yaml traefik-stack

  docker service rm blog-stack_blog_builder

Damit werden nacheinander alle Services gestartet. Es ist nur wichtig, dass traefik zuletzt gestartet wird.

Misc (Automatisierung)

Starten und beenden der docker stacks kann nervig sein, wenn man alles per Hand macht. Daher hier ein kleines Script, was das ganze automatisiert.

Taskfile ist ein cooles kleines Tool, was dabei helfen kann solche Aufgaben schnell zu automatisieren.

Hier ist mein Taskfile.yml - es liegt auf der selben Höhe wie die Ordner.

version: '2'

tasks:
  default:
    desc: Start all services
    deps:
      - task: nextcloud
        vars:
          CMD: "deploy -c nexworking/docker-compose.yaml"
      - task: zrezai-dev
        vars:
          CMD: "deploy -c zrezai-dev/docker-compose.yaml"
      - task: vault
        vars:
          CMD: "deploy -c vault/docker-compose.yaml"
    cmds:
      - task: traefik
        vars:
          CMD: "deploy -c traefik/docker-compose.yaml"
      - task: zrezai-dev-remove-builder

  up:
    desc: Start all services
    cmds:
      - task: default

  down:
    desc: Stop all services
    deps:
      - task: traefik
        vars:
          CMD: "rm"
    cmds:
      - task: nextcloud
        vars:
          CMD: "rm"
      - task: zrezai-dev
        vars:
          CMD: "rm"
      - task: vault
        vars:
          CMD: "rm"

  traefik:
    desc: Start traefik
    cmds:
      - docker stack {{.CMD}} traefik-stack
    silent: false

  nextcloud:
    desc: Start Nextcloud
    cmds:
      - docker stack {{.CMD}} nextcloud-stack

  zrezai-dev:
    desc: Start personal blog
    cmds:
      - docker stack {{.CMD}} blog-stack

  zrezai-dev-remove-builder:
    desc: Remove unneeded builder services
    cmds:
      - docker service rm blog-stack_blog_builder

Recht primitiv, aber jetzt kann man einfach mit task oder task up alle Stacks in der korrekten Reihenfolge starten & den blog_builder korrekt löschen. Mit task down werden alle stacks wieder beendet & ihre Überreste werden entfernt.