SSL gesicherte Anwendungen mit Nginx und Docker

Ich habe auf ak8s einen neuen Beitrag veröffentlicht, in diesem geht es um die Inbetriebnahme von diesem Blog... und oh, es geht natürlich um die Konfiguration einer Docker Umgebung

SSL gesicherte Anwendungen mit Nginx und Docker

In diesem Beitrag möchte ich kurz erklären, wie ich meinen besagten Blog (aytac´s Blog) aufgesetzt habe. Eins Vorweg, alles läuft mit Docker Containern.

Nach meinem selfhosted Kubernetes Disaster habe ich mir vorgenommen, zuerst einmal mir eine Backup Strategie auszudenken, mit der ich so ein Kubernetes Cluster mit allem drum und dran sichern und bei Bedarf wiederherstellen kann. Da dies als dreifacher Familienvater und Vollzeit Angestellter zeitlich gewisse Einschränkungen mit sich bringt, habe ich mich entschieden meinen Blog vorerst einmal mit Docker zu hosten. Docker gehört ja mittlerweile zur Familie und man kennt sich länger als der neue Schwager Kubernetes.

So, da kamen gleich die ersten Fragestellungen, in meinem Kubernetes Cluster hatte ich ingress als Reverse Proxy verwendet, das Anfragen/Erstellen eines Zertifikats ging über den Letsencrypt Cert Service automatisch. Etwas worauf ich natürlich nicht verzichten wollte, eine kleine Recherche ergab, dass mein Vorhaben grundsätzlich möglich ist, dafür braucht es nginx, nginx-proxy, nginx companion und natürlich Ghost :)

Der grobe Aufbau

Zuerst musste ich auf meinen vServer - bei netcup - docker und docker-compose installieren. Hierfür kann ich 2 Anleitungen von Digital Ocean empfehlen. Beide Anleitungen sind gut und verständlich geschrieben.

So installieren und verwenden Sie Docker auf Ubuntu 18.04
How To Install Docker Compose on Ubuntu 18.04

Docker, Docker-Compose check, was nun?

So nachdem wir eine lauffähige Docker und Docker-Compose Installation haben, können wir weiter fortfahren.
Die Vorgehensweise ist eigentlich recht simpel. Wir erstellen eine Verzeichnisstruktur, erstellen ein Docker Netzwerk, erstellen ein YAML für unsere nginx-proxy Umgebung, erstellen ein weiteres YAML für unseren Ghost Blog. Fangen wir an :)

Verzeichnisstruktur

Rein theoretisch kann diese Verzeichnisstruktur überall angelegt werden, unter Linux bieten sich /var oder /srv an. Die Namen für die Verzeichnisse können abweichen, diese haben für den Betrieb keine technische Bedeutung, jedoch ist es von Vorteil wenn eine semantische Verbindung aufgebaut werden kann, man nehme mal den Standard-Admin der gefühlt 19293949589565394 Systeme betreut, eine Struktur mit einer Namenskonvention macht das wiederfinden einfach einfacher...

Ich habe für meine Umgebung die Verzeichnisstruktur unter /var aufgebaut, die außerdem wie folgt aussieht.

var
 └── webapps
        ├── aytac.kirmizi.online
        ├── nginx-proxy
        └── together-it.de

webapps ist quasi der root Ordner, hier drunter liegen dann die einzelnen WebAnwendungen die wir veröffentlichen und betreiben möchten. Innerhalb der WebAnwendungs-Verzeichnisse werden die einzelnen Konfigurationen abgelegt, was in meinem Fall jeweils eine docker-compose.yml ist.

Ein Docker Netzwerk

Damit das ganze zusammenspielen kann, muss der Proxy und die WebAnwendungen im gleichen Netzwerk laufen.  Docker bringt quasi auf dem Host sein eigenes Netzwerk mit,  den Containern kann man mitgeben innerhalb welchem Netzwerks sie laufen sollen. Die Thematik ist sehr interessant, ein tiefer gehendes Wissen hier aufzubauen ist spätestens mit Docker Swarm unumgänglich, in diesem Beitrag belassen wir es bei den Basics und dem anlegen eines Docker Netzwerks.
Docker Netzwerke werden mit dem docker network create Befehl erstellt. Sofern kein anderer Treiber definiert ist und explizit angegeben wird, wird immer ein bridged Network angelegt, womit die Container innerhalb dieses Netzwerks sowohl untereinander als auch mit dem Internet kommunizieren können. Wir werden nun mit diesem Befehl ein Netzwerk für unser Vorhaben erstellen.

docker network create nginx-proxy

Damit haben wir ein neues Docker Netzwerk angelegt, fahren wir nun fort mit der Erstellung der "Proxy-Schicht" (in diesem Beitrag auch proxy-Umgebung, nginx-Umgebung gennant).

Nginx, JWilder Nginx-Proxy, Letsencrypt Companion

Ok, das ist viel. Nginx alleine reicht nicht, um unser Vorhaben einfach und schnell vonstatten gehen zu lassen. Wir brauchen einpaar Container mehr. Unter anderem den nginx-proxy von jwilder. Nginx-Proxy stellt einen Container mit nginx und docker-gen bereit. Docker-gen erkennt wenn eine neue WebAnwendung gestartet wird und erzeugt automatisch die dazugehörige nginx Konfiguration (basierend auf einer Vorlage, aber weiter unten mehr dazu).

Ein weiterer Container, den wir in diesem Netzwerk betreiben werden, ist die docker-letsencrypt-companion Lösung.  Dieser Container kümmert sich um die Zertifikats-Anfrage und Erstellung. Die folgende Konfiguration habe ich selber von einem anderen Blog-Beitrag, der hier verlinkt ist.
Wir sollten nun in das nginx-proxy Verzeichnis unterhalb von /var/webapps wechseln. Per touch docker-compose.yml eine Docker Compose Datei erstellen, mit nano diese öffnen und den Inhalt aus dem Listing unten hinein kopieren. Damit hätten wir unser Gerüst für das routing, proxying und certificate issuing fertig.

version: '3'

services:
  nginx:
    image: nginx:1.13.1
    container_name: nginx-proxy
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - conf:/etc/nginx/conf.d
      - vhost:/etc/nginx/vhost.d
      - html:/usr/share/nginx/html
      - certs:/etc/nginx/certs
    labels:
      - "com.github.jrcs.letsencrypt_nginx_proxy_companion.nginx_proxy=true"

  dockergen:
    image: jwilder/docker-gen:0.7.3
    container_name: nginx-proxy-gen
    depends_on:
      - nginx
    command: -notify-sighup nginx-proxy -watch -wait 5s:30s /etc/docker-gen/templates/nginx.tmpl /etc/nginx/conf.d/default.conf
    volumes:
      - conf:/etc/nginx/conf.d
      - vhost:/etc/nginx/vhost.d
      - html:/usr/share/nginx/html
      - certs:/etc/nginx/certs
      - /var/run/docker.sock:/tmp/docker.sock:ro
      - ./nginx.tmpl:/etc/docker-gen/templates/nginx.tmpl:ro

  letsencrypt:
    image: jrcs/letsencrypt-nginx-proxy-companion
    container_name: nginx-proxy-le
    depends_on:
      - nginx
      - dockergen
    environment:
      NGINX_PROXY_CONTAINER: nginx-proxy
      NGINX_DOCKER_GEN_CONTAINER: nginx-proxy-gen
    volumes:
      - conf:/etc/nginx/conf.d
      - vhost:/etc/nginx/vhost.d
      - html:/usr/share/nginx/html
      - certs:/etc/nginx/certs
      - /var/run/docker.sock:/var/run/docker.sock:ro

volumes:
  conf:
  vhost:
  html:
  certs:

# Do not forget to 'docker network create nginx-proxy' before launch, and to add '--network nginx-proxy' to proxied containers. 

networks:
  default:
    external:
      name: nginx-proxy

Ich werde jetzt nicht auf die Einzelheiten der YAML eingehen, die interessanten Punkte mal als kurz erwähnt:

  • Es werden 3 Container erstellt.
  • Es werden 4 Volumes erstellt.
  • Die Container laufen im nginx-proxy Netzwerk

Damit wir später automatische nginx Proxy Einstellungen erzeugen können, brauchen wir eine Vorlage für unseren jwilder nginx-proxy container. Diese laden wir direkt in das nginx-proxy Verzeichnis, quasi parallel zu unserer frisch erstellten docker-compose.yml.

curl https://raw.githubusercontent.com/jwilder/nginx-proxy/master/nginx.tmpl > nginx.tmpl

Damit haben wir alles was wir benötigen um unsere Proxy Schicht zu betreiben. Mit dem Ausführen des docker-compose up -d Befehls im nginx-proxy Verzeichnisses, können wir nun unsere Container starten, denn diese brauchen wir für den nächsten Schritt, unserem Ghost Blog. Daher sollte ein docker ps uns 3 laufende Container auflisten.

Ghost CMS hinter dem Nginx Proxy

Nun können wir unsere erste WebAnwendung konfigurieren und anschließend starten. Die Ghost Konfiguration ist an sich nicht schwierig, für mich war es wichtig meinen Blog mit sqlite3 zu betreiben und auf eine eigenständige (damit meine ich einen eigenen Container) Relationale-Datenbank wie MySQL oder MariaDB zu verzichten (nur ich werde in diesem Blog schreiben, da ist mir die vergeudete Performanz für einen dedizierten *SQL Container zu schade).
Ein weiterer Punkt war der, dass ausgehende Emails, und  natürlich die automatisierte Zertifikats-Anforderung bei Lets Encrypt funktionieren. Aus diesen "Gründen/Wünschen" sieht meine docker-compose.yml wie folgt aus.

version: "3"

services:
  aytac_ghost:
    image: ghost:latest
    volumes:
      - ghost_data:/var/lib/ghost/content
    restart: always
    environment:
        url: https://aytac.kirmizi.online
        NODE_ENV: production
        mail__transport: SMTP
        mail__options__host: ##################
        mail__options__port: 587
        mail__from: aytac_blog@kirmizi.online
        mail__options__auth__user: ##################
        mail__options__auth__pass: ##################
        VIRTUAL_HOST: aytac.kirmizi.online
        LETSENCRYPT_HOST: aytac.kirmizi.online
        LETSENCRYPT_EMAIL: #######################
    container_name: aytac_ghost

volumes:
  ghost_data:

networks:
  default:
    external:
      name: nginx-proxy

Es gibt hier nicht spezielles zu erwähnen, die sensiblen Stellen habe ich mit # (einer Raute) maskiert. Wichtig ist, dass diese Anwendung im nginx-proxy Netzwerk läuft, daher muss diese unter networks angegeben werden. Für die automatisierte Zertifikats-Erstellung müssen folgende Parameter konfiguriert werden.

        VIRTUAL_HOST: aytac.kirmizi.online
        LETSENCRYPT_HOST: aytac.kirmizi.online
        LETSENCRYPT_EMAIL: #######################

Ohne dieses Parameter kann der companion kein Zertifikat bei Letsencrypt anfordern. Die Mail-Einstellungen können in der Ghost Doku nachgelesen werden. Wichtig ist hier, dass die Eingaben in JSON, in YAML überführt werden. Jedes neue JSON Abschnitt wird hierbei mit 2 Unterstrichen symbolisiert also __. Hier mal ein Beispiel zur Verständlichkeit. Angenommen folgende Konfiguration liegt uns als JSON vor:

"mail": {
    "transport": "SMTP",
    "options": {
        "service": "Mailgun",
        "auth": {
            "user": "postmaster@example.mailgun.org",
            "pass": "1234567890"
        }
    }
}

Daraus würden folgende Enviorement Variablen für unser YAML enstehen.

        mail__transport: SMTP
        mail__options__service: "Mailgun"
        mail__options__auth__user: postmaster@example.mailgun.org
        mail__options__auth__pass: 1234567890

Ansonsten wird hier wieder ein Docker Volume verwendet, welches auch als Basis-Verzeichnis für unsere Ghost Installation angegeben wird. Ich verwende hier übrigens das originale Standard Ghost Image von Docker Hub. Wer möchte kann hier natürlich auch das alpine Image oder ein anderes verwenden.

So, damit wir unseren Ghost Container starten können, müssen wir in unser Ghost Anwendungs-Verzeichnis wechseln (bei mir /var/webapps/aytac.kirmizi.online).
Anschließend erstellen wir mit dem folgenden Befehl ein neues Docker Compose yaml und öffnen dieses mit unserem Lieblings-Editor nano.

cd /var/webapps/aytac.kirmizi.online
touch docker-compose.yml
nano docker-compose.yml

Anschließend können wir den Inhalt unten reinkopieren, und den eigenen Bedürfnissen anpassen. Ich habe für die Parameter placeholder mit Kommentaren als Werte angegeben, man kann sich an diesen richten und die benötigten Werte eintragen.

version: "3"

services:
  my_ghost:
    image: ghost:latest
    volumes:
      - ghost_data:/var/lib/ghost/content
    restart: always
    environment:
        url: https://<fqdn zur webapp>
        NODE_ENV: production
        mail__transport: SMTP
        mail__options__host: <smtp server>
        mail__options__port: <25,587,465,.. der smtp port>
        # sofern smtp port 465 ist, sollte die folgende Zeile (mail__options__secureConnection) angegeben werden, bei meinen Tests hat es ohne nicht funktioniert
        # mail__options__secureConnection: "true"
        mail__from: <die "von" mail adresse>
        mail__options__auth__user: <smtp login name>
        mail__options__auth__pass: <smtp login pass>
        VIRTUAL_HOST: <fqdn zur webapp>
        LETSENCRYPT_HOST: <fqdn zur webapp>
        LETSENCRYPT_EMAIL: <am besten die Email auf die die domäne registriert ist>
    container_name: my_ghost

volumes:
  ghost_data:

networks:
  default:
    external:
      name: nginx-proxy

Nachdem wir unser docker-compose.yml gespeichert haben, können wir mit dem docker-compose up   ( hier wird absichtlich auf den "-d" Parameter verzichtet) Befehl unseren Ghost starten. Wir sollten nun in der Shell die Docker log Ausgaben einsehen können, sofern hier kein Error gemeldet wird, kann nun die Seite (Zertifikat issuen + ablegen dauert so ca. 1 - 3 Minuten) mit der angegeben URL aufgerufen werden (z.B. https://aytac.kirmizi.online).

Nun sollte die Ghost Umgebung konfiguriert werden. Das geht ganz einfach indem der /ghost Pfad unserer URL hinzugefügt wird. Beim initialen Aufruf der Administration, muss zuerst der Administrator konfiguriert werden (in der Shell wird dafür ein Error geloggt, also nicht erschrecken). Danach könnt ihr weitere Kollegen/Freunde als Redakteure für euren Blog einladen, diesen Punkt solltet ihr überspringen, das kann man auch später angehen. Wichtig ist hier an dieser Stelle die Mail Einstellungen zu prüfen, dafür müsst ihr links Labs auswählen und anschließend " Test email configuration" klicken.

Erfolgreiche Ansicht bei Versenden einer Email

Sofern keine Fehler gemeldet werden sollten, könnt ihr nun mit "strg+c" die Ausgabe stoppen und mit "docker-compose down" die Container stoppen. Da alles funktioniert, kann die Umgebung mit docker-compose up -d gestartet werden. Das war es auch schon :) Ein neuer Blog hat das Licht des Internets erblickt!

Ein bisserl Technik und blabla zum Abschluss

Ich bin bewusst auf manche Themen weiter oben nicht eingegangen, um den Lese-Fluss nicht zu stören. Es müssen natürlich ein paar vorarbeiten getätigt werden. Wir müssen unsere Domäne bei unserem DNS Provider verwalten, und die domain die wir für unsere WebAnwendung (in meinem Beispiel Ghost) verwenden möchten auf die IP des Servers zeigen (A Record) lassen.

Wir sollten auch nicht unseren Server ohne konfigurierte Firewall betreiben, für das oben geschilderte Vorhaben reicht es vollkommen aus, wenn nur die Ports 22,80,443 freigegeben werden (inbound), der Rest kann gesperrt werden. Hier mal ein kleines Beispiel für ufw, da das hier so einfach geht.

ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable

Auch der login per ssh sollte per Zertifikat zugelassen werden. Hierfür finden sich viele Anleitungen im Internet, einfach danach mal googeln.

Ein weiteres Thema ist die maximale Upload Größe von Dateien über nginx. Ihr werdet ziemlich schnell bei Ghost merken, dass hier nicht einmal 1 MB Dateien hochgeladen werden können. Das hat den Grund, dass diese Einstellung in der Konfiguration nicht angegeben ist, und der Standard genommen wird. Auch ein Anpassen dieser Einstellung im Docker Volume für die jeweilige nginx Konfiguration bringt an dieser Stelle nichts, da diese bei jedem Neustart überschrieben wird. Wir müssen die nginx.tmpl anpassen. Ich habe 750MB definiert. Wied das gemacht wird, erkläre ich jetzt :)

Nachdem wir die nginx.tmpl heruntergeladen haben, müssen wir diese mit einem Editor öffnen (nano z.B.). Diese Datei ist eine Vorlage für den jwilder nginx-proxy, um basierend dadrauf nginx Konfigurationen für unsere WebAnwendungen zu generieren. Um Datei-Uploads größer als 1 MB zu erlauben müssen wir in dem server Abschnitt wo auf Port 443 gehorcht wird (wir verwenden ja nur SSL, deswegen gibt es auch diesen Beitrag, ansonsten kann natürlich auch der Abschnitt mit Port 80 angepasst werden) müssen wir den client_max_body_size Wert angeben. Das sieht dann wie folgt aus.

Zeile 213, hier passiert der Zauber

Nachdem wir das gemacht haben, müssen wir unsere Datei speichern, die nginx Umgebung mit docker-compose down stoppen und wieder mit docker-compose up -d starten. Danach ist es uns möglich, in Ghost Dateien bis zu einer Größe von 750 MB hochzuladen. Dieser Wert kann natürlich angepasst werden. Bei mir ist dieser natürlich keine 750 MB, in der Regel sollte hier eine 2 Stellige Zahl reichen.

Was nun?

Zuerst einmal viel Spaß mit der neuen Umgebung  :)
Ich werde in einem anderen Beitrag meine Disaster-Recovery Strategie vorstellen, ein Backup & Restore Szenario ist wichtig (wie ich bereits am eigenen Leib in Erfahrung bringen musste), in diesem Beitrag ist leider kein Platz mehr dafür, für dieses Thema bietet sich daher ein eigener Beitrag an.

this