Add blog post: HTTPS for Docker services running locally with Caddy and dnsmasq

This commit is contained in:
Akumatic 2025-04-23 19:47:40 +02:00
parent 72af4aa51d
commit 091497847f
4 changed files with 540 additions and 0 deletions

View File

@ -0,0 +1,4 @@
---
title: "Netzwerk"
slug: "network"
---

View File

@ -0,0 +1,4 @@
---
title: "Network"
slug: "network"
---

View File

@ -0,0 +1,266 @@
---
title: HTTPS für lokale Docker-Dienste mit Caddy und dnsmasq
date: 2025-04-23
description: |
Der Zugriff auf lokale Docker-Dienste über localhost:port ist oft umständlich, insbesondere bei mehr als einem oder zwei Diensten.
Der bevorzugte Weg ist die Verwendung von Subdomains und HTTPS mit einem vertrauenswürdigen Zertifikat, was mit Caddy, dnsmasq und Docker-Labels leicht zu realisieren ist.
categories:
- services
- network
tags:
- docker
- caddy
- dnsmasq
- reverse proxy
- domain
- DNS
- HTTPS
---
Bei der lokalen Verwendung von Docker-Containern ist der übliche Weg, einen Port freizugeben und über `localhost:port` auf den Dienst zuzugreifen. Das funktioniert normalerweise, ist aber natürlich keine gute Lösung. Ein paar Nachteile sind mitunter:
- Schneller Verlust des Überblicks über zugewiesene Ports, insbesondere bei mehreren Containern
- Zugriff über HTTPS nur, wenn der Container entsprechend konfiguriert wurde - und selbst dann gibt es nur ein selbstsigniertes Zertifikat, das eine Sicherheitswarnung im Webbrowser auslöst
- Einträge von Passwortmanagern sind schwieriger zu trennen, wenn alle Dienste über `localhost` statt über eine eindeutige Domain laufen
Ein besserer Weg wäre die Verwendung von Subdomains und gültigen TLS-Zertifikaten. Es würde jedoch einen höheren Verwaltungsaufwand bedeuten, jedem Dienst ein eigenes Zertifikat von einer lokalen Zertifizierungsstelle zuzuweisen, und man müsste sich selbst um die CA und die Signierung kümmern.
Um diesen Prozess zu vereinfachen, kann ein Reverse Proxy wie **Caddy** verwendet werden.
## Reverse Proxy
Ein Reverse Proxy ist ein Server, der vor anderen Webservern sitzt und Anfragen von Clients an diese Webserver weiterleitet. Daher kümmern sie sich oft auch um sicherheitsrelevante Komponenten der Kommunikation, wie die TLS-Terminierung von HTTPS-Verbindungen.
Der Anwendungsfall in diesem Zusammenhang ist ähnlich. Wir verwenden einen Reverse-Proxy, um Anfragen an `localhost` zu verarbeiten. Dienste in Docker-Containern müssen dabei keinen Port mehr freigeben, die Kommunikation zwischen Proxy und Dienst läuft über ein internes Docker-Netzwerk.
### Warum Caddy?
[Caddy](https://caddyserver.com) ist ein moderner Webserver, der eine vereinfachte Konfiguration mit automatischem HTTPS bietet. Ein Reverse Proxy-Block in einem Caddyfile sieht beispielsweise so aus:
``` caddyfile
example.com {
reverse_proxy localhost:3000
}
```
Mehr braucht es nicht. Caddy generiert automatisch die TLS-Zertifikate über Let's Encrypt und setzt die üblichen Header eines Reverse Proxys, was es zu einer einfachen, aber mächtigen Alternative zu den bekannten Lösungen wie Nginx macht.
### Caddy-Modul: Caddy-Docker-Proxy
Ein nützliches Modul zur Verwendung mit Docker-Diensten ist [Caddy-Docker-Proxy](https://github.com/lucaslorentz/caddy-docker-proxy). Es scannt Metadaten und sucht nach Labels, die darauf hinweisen, dass der Dienst von Caddy bedient werden soll. Aus diesen Labels wird ein Caddyfile mit den entsprechenden Einträgen erstellt, was die manuelle Verwaltung für Docker-Container überflüssig macht. Einträge für Dienste außerhalb von Docker können weiterhin über ein Caddyfile verwaltet werden.
Anweisungen, wie die Anweisungen eines Caddyfiles in Label umgewandelt werden können, findet man im [Repository](https://github.com/lucaslorentz/caddy-docker-proxy?tab=readme-ov-file#labels-to-caddyfile-conversion).
#### Beispiel
Dieses Beispiel startet *traefik/whoami* und fügt es zu einem bestehenden Proxy-Netzwerk hinzu. Nach dem Start ist der Container über `https://whoami.dev.internal` erreichbar, gesichert mit einem von Caddys interner Root CA signierten TLS-Zertifikat.
``` yml
name: whoami
services:
whoami:
image: traefik/whoami
networks:
- caddy
labels:
caddy: whoami.dev.internal
caddy.tls: internal
caddy.reverse_proxy: "{{upstream}}"
networks:
caddy:
external: true
```
### Caddy mit Docker Compose bereitstellen
Zuerst erstellen wir ein Proxy-Netzwerk. Dieses Netzwerk wird extern erstellt, um sicherzustellen, dass der Dienst diesem beitreten kann, auch wenn der Caddy-Stack nicht läuft.
{{< notice info >}}
Mit einem geteilten Proxy-Netzwerk können die Dienste direkt miteinander kommunizieren. Wenn dieses Verhalten verhindert werden soll, sollte für jeden Dienst ein eigenes Proxy-Netzwerk erstellt werden.
{{< /notice >}}
```
docker network create --internal caddy
```
Anschließend wird folgenden Stack in einer Datei gespeichert, der Standardname ist `docker-compose.yml`. Wenn ein anderer Name für die Datei verwendet wird, muss dieser beim Aufruf von `docker compose` explizit angegeben werden.
``` yml
name: caddy
services:
caddy:
container_name: caddy
image: lucaslorentz/caddy-docker-proxy:ci-alpine
ports:
- 80:80
- 443:443
environment:
- CADDY_INGRESS_NETWORKS=caddy
networks:
internal:
proxy:
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- data:/data
restart: unless-stopped
networks:
internal:
name: caddy_internal
proxy:
name: caddy
external: true
volumes:
data:
name: caddy_data
```
Schließlich kann der Stack mit `docker compose up -d` gestartet werden.
### Caddys Root-Zertifikat vertrauen
Damit der Computer dem von Caddy ausgestellten Zertifikat vertrauen kann, muss er der gesamten Zertifikatskette vertrauen. Mit einem lokalen Caddy könnte man `caddy trust` ausführen, um das Root-Zertifikat in den Vertrauensspeicher des Systems zu installieren.
Mit Docker ist der Container vom System isoliert und hat keinen direkten Zugriff darauf. Das Root-Zertifikat muss manuell kopiert werden und es muss in den Vertrauensspeicher des Systems oder des Browsers installiert werden. Anleitungen für Linux, Mac und Windows können in der [Dokumentation von Caddy](https://caddyserver.com/docs/running#local-https-with-docker) gefunden werden.
Für die meisten Linux-Systeme lauten die Befehle beispielsweise:
``` bash
sudo docker compose cp \
caddy:/data/caddy/pki/authorities/local/root.crt \
/usr/local/share/ca-certificates/caddy_docker_root.crt
sudo update-ca-certificates
```
#### Arch Linux
Die Art, wie lokale vertrauenswürdige Zertifikate gehandhabt werden, [hat sich 2014 geändert](
https://archlinux.org/news/ca-certificates-update/). Die entsprechenden Befehle für Arch Linux wären beispielsweise:
``` bash
sudo docker compose cp \
caddy:/data/caddy/pki/authorities/local/root.crt \
/etc/ca-certificates/trust-source/anchors/caddy_docker_root.crt
sudo trust extract-compat
```
### Ein Docker-Volume mit Caddys Root-Zertifikat erstellen
Wenn ein Container mit anderen Diensten über Caddy kommunizieren muss und dabei die Gültigkeit des Zertifikats überprüft, muss auch er der Zertifikatskette vertrauen.
Die folgenden Befehle erstellen ein Docker-Volume namens `caddy_root_ca`, das nur das Root-Zertifikat enthält und in andere Container gemountet werden kann. Dort muss dann nur der Vertrauensspeicher aktualisiert werden, was entweder manuell oder durch Überschreiben von `entrypoint` oder `command` getan werden kann.
``` bash
docker volume create caddy_root_ca
docker compose run --rm -v $vol:/ca \
--entrypoint "cp /data/caddy/pki/authorities/local/root.crt /ca/caddy_root.crt" \
caddy
```
Damit der Container über Caddy auf den anderen Dienst zugreifen kann, muss ein Alias gesetzt werden.
Verkürztes, unvollständiges Beispiel für einen Dienst, der über `https://service.dev.internal` erreichbar ist und von einem anderen Container über Caddy angesprochen werden kann:
``` yml
services:
caddy:
image: lucaslorentz/caddy-docker-proxy:ci-alpine
# ...
networks:
caddy:
proxy:
aliases:
- service.dev.internal
# ...
web:
# ...
networks:
- proxy
labels:
caddy: service.dev.internal
caddy.tls: internal
caddy.reverse_proxy: "{{upstream}}"
# ...
client:
# ...
volumes:
- caddy_ca:/usr/local/share/ca-certificates/caddy
networks:
- proxy
# ...
# ...
volumes:
caddy_ca:
external: true
name: caddy_root_ca
```
Nach der Aktualisierung des Vertrauensspeichers kann 'client' nun mit 'web' über eine vertraute HTTPS-Verbindung über Caddy kommunizieren.
## Lokale Domain
Mit unserem Reverse Proxy kann für jeden Dienst eine eigene Subdomain verwendet werden.
Statt in `/etc/hosts` jeden Eintrag manuell hinterlegen zu müssen, werden wir einen Wildcard-DNS-Eintrag für die Subdomain `dev.internal` verwenden. Da `.internal` von der ICANN als Domain für private Anwendungen reserviert wurde, ist die Nutzung problemlos möglich. Durch die Reservierung ist garantiert dass die Domain nicht als Top-Level-Domain im DNS des Internets installiert wird.
Durch diesen Eintrags werden die Domain selbst und alle Subdomains nach `localhost` aufgelöst.
{{< notice info >}}
Generell kann jede beliebige Domain verwendet werden. Die Verwendung einer bestehenden, global gerouteten Domain kann zu Problemen bei der Namensauflösung und damit der Erreichbarkeit führen.
Aber auch eine TLD, die noch nicht im DNS des Internets registriert ist, sollte nur mit Vorsicht verwendet werden, solange sie nicht wie `.internal` explizit reserviert wurde.
{{< /notice >}}
### Linux mit NetworkManager
- dnsmasq installieren
- DNS-Resolver von NetworkManager änden
``` bash
sudo bash -c 'echo "[main]" > /etc/NetworkManager/conf.d/dns.conf'
sudo bash -c 'echo "dns=dnsmasq" >> /etc/NetworkManager/conf.d/dns.conf'
```
- DNS-Einträge hinzufügen
``` bash
sudo bash -c 'echo "address=/dev.internal/127.0.0.1" > /etc/NetworkManager/dnsmasq.d/dev.internal.conf'
sudo bash -c 'echo "address=/dev.internal/::1" >> /etc/NetworkManager/dnsmasq.d/dev.internal.conf'
```
- NetworkManager neu laden
``` bash
nmcli general reload
```
### MacOS
- Wenn noch nicht geschehen: [Homebrew](https://brew.sh/) installieren
- dnsmasq installieren
``` bash
brew install dnsmasq
```
- DNS-Einträge hinzufügen
``` bash
echo "address=/dev.internal/127.0.0.1" > $(brew --prefix)/etc/dnsmasq.d/dev.internal.conf
echo "address=/dev.internal/::1" >> $(brew --prefix)/etc/dnsmasq.d/dev.internal.conf
```
- Autostart aktivieren
``` bash
sudo brew services start dnsmasq
```
- Zu Resolvern hinzufügen
```
sudo mkdir -v /etc/resolver
sudo bash -c 'echo "nameserver 127.0.0.1" > /etc/resolver/internal'
```
## Setup testen
Zunächst sollte getestet werden, ob die Domain-Auflösung wie vorgesehen funktioniert. Dazu ein Terminal öffnen und `dig <domain>` (Linux) oder `dscacheutil -q host -a name <domain>` (MacOS) verwenden.
Sowohl für die Domain selbst (z.B. `dev.internal`) als auch für eine beliebige Subdomain davon (z.B. `a.dev.internal`) sollten `127.0.0.1` für IPv4 und `::1` für IPv6 zurückkommen.
Dann kann das oben erwähnten Beispiel [„whoami“](#beispiel) gestartet werden. Nach dem Start des Containers sollte der Dienst über die angegebene URL (wie z.B. https://whoami.dev.internal) mit gültigen HTTPS ohne Sicherheitswarnungen bedient werden.

View File

@ -0,0 +1,266 @@
---
title: HTTPS for Docker services running locally with Caddy and dnsmasq
date: 2025-04-23
description: |
Accessing local Docker services via localhost:port is often cumbersome, especially with more than one or two services.
The preferred way is to use subdomains and HTTPS with a trusted certificate, which is easy to realize with Caddy, dnsmasq and Docker labels.
categories:
- services
- network
tags:
- docker
- caddy
- dnsmasq
- reverse proxy
- domain
- DNS
- HTTPS
---
When using Docker containers locally, the usual way is to expose a port and access the service via `localhost:port`. This usually works, but is of course not a good solution. A few downsides are:
- Quick loss of overview of assigned ports, especially with more containers
- Access via HTTPS only if the container has been configured accordingly - and even then there is only a self-signed certificate that triggers a security warning in the web browser
- Entries of password managers are more difficult to separate if all services run via `localhost` instead of a unique domain
A better way would be to use subdomains and valid TLS certificates. However, giving each service its own signed certificate from a local certificate authority would mean more administrative work before the service can be used, and you would also have to take care of the certificate authority and signing yourself.
To simplify this process, a reverse proxy like **Caddy** can be used.
## Reverse Proxy
A reverse proxy is a server that sits in front of webservers and forwards requests from clients to these webservers. As a result, they often also take care of security-relevant components of communication such as TLS termination of HTTPS connections.
The use case in this context is similar. We use a reverse proxy to handle requests to `localhost`. Services in Docker containers do not need to expose a port anymore, the communication between proxy and service runs via an internal Docker network
### Why Caddy?
[Caddy](https://caddyserver.com) is a modern web server offering a simplified configuration with automatic HTTPS. A reverse proxy block in a Caddyfile looks like this, for example:
``` caddyfile
example.com {
reverse_proxy localhost:3000
}
```
That's it. Caddy automatically generates the TLS certificates via Let's Encrypt and sets the usual headers of a reverse proxy, which makes it a simple but powerful alternative to the well-known solutions such as Nginx.
### Caddy Module: Caddy-Docker-Proxy
A useful module to use with docker services is [Caddy-Docker-Proxy](https://github.com/lucaslorentz/caddy-docker-proxy). It scans metadata and searches for labels that indicate that the service should be served by Caddy. A Caddyfile with the corresponding entries is created from these labels, which makes manual management for Docker containers superfluous. Entries for services outside of Docker can still be managed via Caddyfile.
Instructions how to convert the Caddyfile instructions to labels can be found in the [repository](https://github.com/lucaslorentz/caddy-docker-proxy?tab=readme-ov-file#labels-to-caddyfile-conversion).
#### Example
This example spins up traefik/whoami and adds it to an existing caddy proxy network. After creation, the container is reachable via `https://whoami.dev.internal`, secured with an TLS certificate signed by Caddy's internal Root CA.
``` yml
name: whoami
services:
whoami:
image: traefik/whoami
networks:
- caddy
labels:
caddy: whoami.dev.internal
caddy.tls: internal
caddy.reverse_proxy: "{{upstream}}"
networks:
caddy:
external: true
```
### Setup Caddy with Docker Compose
First, we create a proxy network. This network is created externally to ensure that a service can join it even if the Caddy stack is not running.
{{< notice info >}}
With a shared proxy network, the services can communicate directly with each other. If you want to prevent this behavior, you should create a proxy network for each service.
{{< /notice >}}
```
docker network create --internal caddy
```
We then store the following stack in a file, the default name is `docker-compose.yml`. If a different name is used for the file, this must be explicitly specified when calling `docker compose`.
``` yml
name: caddy
services:
caddy:
container_name: caddy
image: lucaslorentz/caddy-docker-proxy:ci-alpine
ports:
- 80:80
- 443:443
environment:
- CADDY_INGRESS_NETWORKS=caddy
networks:
internal:
proxy:
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- data:/data
restart: unless-stopped
networks:
internal:
name: caddy_internal
proxy:
name: caddy
external: true
volumes:
data:
name: caddy_data
```
Finally, the stack can be started with `docker compose up -d`.
### Trust Caddy's Root CA
For your computer to trust the certificate issued by Caddy, it needs to trust the certificate chain. With a local Caddy, one could run `caddy trust` to install the root CA to the system's trust store.
With docker, the container is isolated from the system and has no direct access to it. The root certificate needs to be copied and trusted manually. Instructions for Linux, Mac and Windows can be found in the [Caddy documentation](https://caddyserver.com/docs/running#local-https-with-docker).
For most Linux the commands are:
``` bash
sudo docker compose cp \
caddy:/data/caddy/pki/authorities/local/root.crt \
/usr/local/share/ca-certificates/caddy_docker_root.crt
sudo update-ca-certificates
```
#### Arch Linux
The way local CA certificates are handled [has changed in 2014](
https://archlinux.org/news/ca-certificates-update/). The corresponding commands would be:
``` bash
sudo docker compose cp \
caddy:/data/caddy/pki/authorities/local/root.crt \
/etc/ca-certificates/trust-source/anchors/caddy_docker_root.crt
sudo trust extract-compat
```
### Create a docker volume with Caddy's Root CA
If a container needs to communicate with other services via caddy and checks the validity of the certificate, it also needs to trust the certificate chain.
The following commands create a docker volume named `caddy_root_ca` that contains only the root CA and can be mounted in other containers. There, only the trust store needs to be updated, which can be triggered either manually or by overriding `entrypoint` or `command`.
``` bash
docker volume create caddy_root_ca
docker compose run --rm -v $vol:/ca \
--entrypoint "cp /data/caddy/pki/authorities/local/root.crt /ca/caddy_root.crt" \
caddy
```
For the container to access the other service via Caddy, an alias needs to be set.
Shortened incomplete example for a service that is accessible via `https://service.dev.internal` and can be accessed by another docker service via Caddy:
``` yml
services:
caddy:
image: lucaslorentz/caddy-docker-proxy:ci-alpine
# ...
networks:
caddy:
proxy:
aliases:
- service.dev.internal
# ...
web:
# ...
networks:
- proxy
labels:
caddy: service.dev.internal
caddy.tls: internal
caddy.reverse_proxy: "{{upstream}}"
# ...
client:
# ...
volumes:
- caddy_ca:/usr/local/share/ca-certificates/caddy
networks:
- proxy
# ...
# ...
volumes:
caddy_ca:
external: true
name: caddy_root_ca
```
After running the trust store update, the client service can now communicate with web over a trusted HTTPS connection via Caddy.
## Local domain
With our reverse proxy, an individual subdomain can be used for each service.
Instead of having to enter each entry manually in `/etc/hosts`, we are going to use a wildcard DNS entry for the subdomain `dev.internal`. Since `.internal` has been reserved by ICANN as a domain name for private application use, the top level domain is safe to use, as it is guaranteed that it will not be installed in the Internet's DNS.
By specifying this entry, the domain itself and all subdomains are resolved to `localhost`.
{{< notice info >}}
Generally, any domain can be used. Using an existing, globally routed domain can cause problems with name resolution and therefore accessibility.
But even a TLD that is not yet registered in the DNS of the Internet should only be used with caution as long as it has not been explicitly reserved like `.internal`.
{{< /notice >}}
### Linux with NetworkManager
- Install dnsmasq
- Change DNS resolver of NetworkManager
``` bash
sudo bash -c 'echo "[main]" > /etc/NetworkManager/conf.d/dns.conf'
sudo bash -c 'echo "dns=dnsmasq" >> /etc/NetworkManager/conf.d/dns.conf'
```
- Add DNS entries
``` bash
sudo bash -c 'echo "address=/dev.internal/127.0.0.1" > /etc/NetworkManager/dnsmasq.d/dev.internal.conf'
sudo bash -c 'echo "address=/dev.internal/::1" >> /etc/NetworkManager/dnsmasq.d/dev.internal.conf'
```
- Reload NetworkManager
``` bash
nmcli general reload
```
### MacOS
- If not already done: Install [Homebrew](https://brew.sh/)
- Install dnsmasq
``` bash
brew install dnsmasq
```
- Add DNS entries
``` bash
echo "address=/dev.internal/127.0.0.1" > $(brew --prefix)/etc/dnsmasq.d/dev.internal.conf
echo "address=/dev.internal/::1" >> $(brew --prefix)/etc/dnsmasq.d/dev.internal.conf
```
- Enable autostart
``` bash
sudo brew services start dnsmasq
```
- Add to resolvers
```
sudo mkdir -v /etc/resolver
sudo bash -c 'echo "nameserver 127.0.0.1" > /etc/resolver/internal'
```
## Test the setup
First check whether the domain resolution is working as intended. Open a terminal and use `dig <domain>` (Linux) or `dscacheutil -q host -a name <domain>` (MacOS).
Us the command both for the domain itself (e.g. `dev.internal`) and for a subdomain of it (e.g. `a.dev.internal`). Both the domain itself (e.g. `dev.internal`) and for any subdomain of it (e.g. `a.dev.internal`) should return `127.0.0.1` for IPv4 and `::1` for IPv6.
You can then start the example service [“whoami”](#example) mentioned above. After starting the container, call up the specified URL (e.g. https://whoami.dev.internal). It should be served with a valid HTTPS without security warnings.