Network (Traefik + CoreDNS + Shorewall)¶
Stack:
stack/traefik/(Traefik),stack/dns/(CoreDNS) · Host:phil-app· Updated: 2026-03-01
Reverse proxy (Traefik), internal DNS (CoreDNS), and host firewall (Shorewall).
Overview¶
All HTTP/HTTPS ingress is handled by Traefik via Docker labels. CoreDNS provides internal hostname resolution for services that need to reach each other by name (e.g., phil-db). Shorewall is the host-level firewall.
Architecture¶
Traefik Entry Points¶
| Entry point | Port | Purpose |
|---|---|---|
websecure |
443 | Main HTTPS — all public web apps |
customsecure |
8993 | Admin dashboards (Grafana, phpLDAPadmin) |
gitea-ssh |
222 | Git SSH to Forgejo |
| Direct host binding | 25/465/587 | SMTP (mailcow Postfix, not Traefik) |
| Direct port mapping | 143/993 | IMAP (mailcow Dovecot, not Traefik) |
TLS certificates obtained via Let's Encrypt (Hetzner DNS challenge). The cert-dumper copies certs to a shared certs volume consumed by mailcow.
CoreDNS (stack/dns/)¶
- Container:
internal-dnsontraefik-resolvernetwork at172.21.0.53 - Resolves internal hostnames:
phil-db→10.42.10.3,traefik→172.21.0.100 - Services that need internal resolution add
dns: [172.21.0.53]to their compose
Testing DNS (must be done from inside a container):
sudo docker run --rm --network traefik-resolver alpine sh -c 'nslookup phil-db 172.21.0.53'
CoreDNS does not listen on the host — DNS from the host always fails.
Shorewall (Host Firewall)¶
Shorewall manages the host firewall via zones: net (public internet), wg (WireGuard), dock (Docker).
Key rule: Docker inserts its own iptables DOCKER chain before Shorewall rules. Any ports: mapping with 0.0.0.0 is publicly accessible regardless of Shorewall config.
Network Role Pattern¶
Every service typically joins exactly two networks:
| Network | Role | Purpose |
|---|---|---|
traefik-ingress |
Ingress | Traefik picks up Docker labels and routes public HTTPS traffic to the service. The service must be on this network to be reachable from outside. |
traefik-resolver |
Resolver | Internal Docker-to-Docker routing via CoreDNS. Services use this to reach each other by hostname (e.g., git.opensocial.at → Traefik's internal IP 172.21.0.100). No internet egress — --internal. |
Rule of thumb:
traefik-ingress= route traffic in (public-facing),traefik-resolver= route traffic between services (internal).
Services that only need internal access (no public route) join traefik-resolver only. Services that must reach the internet (e.g., Renovate, Forgejo runners) need a per-stack network that is not --internal.
Docker Networks¶
All Docker networks are pre-created externally via stack/.config/create-docker-networks.sh. Services connect to shared networks with external: true.
Shared networks (172.x.x.x, cross-stack):
| Network | Subnet | Bridge | Internal | Purpose |
|---|---|---|---|---|
traefik-ingress |
172.20.0.0/24 | br-ingress |
no | Public HTTP/HTTPS ingress |
traefik-resolver |
172.21.0.0/24 | br-resolver |
yes | CoreDNS (172.21.0.53) |
certs-network |
172.22.0.0/28 | br-certs |
yes | Let's Encrypt cert sharing |
ldap-network |
172.23.0.0/24 | br-ldap |
yes | LDAP/SSO auth |
prometheus-network |
172.24.0.0/26 | br-prom |
yes | Metrics scraping |
ca-network |
172.25.0.0/24 | br-ca |
yes | Step-CA internal PKI |
mail-network |
172.26.0.0/24 | br-mail |
yes | SMTP/IMAP relay |
backup-network |
172.27.0.0/24 | br-backup |
yes | Database backup streaming |
Per-stack networks (192.168.x.x, stack-internal):
| Network | Subnet | Bridge |
|---|---|---|
compose-traefik-network |
192.168.100.0/28 | br-compTraefik |
compose-ldap-network |
192.168.101.0/26 | br-compLdap |
compose-secure-network |
192.168.102.0/26 | br-compSecure |
compose-dns-network |
192.168.103.0/28 | br-compDNS |
compose-nextcloud-network |
192.168.104.0/24 | br-compCloud |
compose-borgmatic-network |
192.168.105.0/28 | br-compBorg |
compose-itop-network |
192.168.106.0/24 | br-compItop |
compose-gitea-network |
192.168.107.0/26 | br-compGitea |
compose-paperless-network |
192.168.109.0/24 | br-compPaperle |
compose-woodpecker-network |
192.168.110.0/24 | br-compWoodpec |
compose-friendica-network |
192.168.200.0/26 | br-frienPhil |
compose-friendicame-network |
192.168.201.0/24 | br-frienMe |
compose-opensocial-network |
192.168.202.0/24 | br-frienOpensc |
Docker default bridge: 172.255.255.0/24 (docker0, 172.255.255.1 — not used by any compose stack).
WireGuard Tunnel¶
Point-to-point VPN between phil-app and phil-db:
- phil-app: 10.42.10.4 (public: 157.90.134.159)
- phil-db: 10.42.10.3 (public: 88.198.7.144)
- Port: 51820
Port Security¶
Rules for port mappings:
- Internal only: no port mapping (use shared networks)
- Accessible from phil-db via WireGuard: bind to WireGuard IP 10.42.10.4
- Host-only access: bind to 127.0.0.1 (e.g., Prometheus, Step-CA)
- Never use 0.0.0.0 or bare port numbers — these bypass Shorewall
XFS Quota Exporter (port 9101): systemd service xfs_quota_exporter.service, listens 0.0.0.0:9101 (nc doesn't support bind-address). Low risk (read-only metrics). Shorewall rule dock → fw tcp 9101 allows Docker access.
Operations¶
Backup¶
Traefik and CoreDNS configs are committed to git in stack/traefik/ and stack/dns/. No volume backup needed — config is the source of truth.
The certs-network volume (Let's Encrypt certs) is backed up by Borgmatic if it exists as a named volume.
Traefik Dashboard¶
Available at traefik.philipp.info:8993 (admin entry point).
Reload CoreDNS config¶
cd /opt/docker/stack/dns
sudo docker compose kill -s SIGUSR1 internal-dns
Pitfalls¶
host-gateway / host.docker.internal broken with Shorewall¶
Docker's extra_hosts: host.docker.internal:host-gateway resolves to 172.255.255.1 (docker0 bridge). Containers on custom bridge networks (192.168.x.x) cannot reach this IP — Shorewall's fw→dock OUTPUT chain only allows RELATED,ESTABLISHED.
Fix: Use the gateway IP of a shared network the container is already on (e.g., host.docker.internal:172.24.0.1 for containers on prometheus-network). See ADR-003.
Port security — Docker bypasses Shorewall¶
Docker inserts its own iptables DOCKER chain before Shorewall rules. Any ports: mapping with 0.0.0.0 is publicly accessible regardless of firewall config.
Testing DNS from the host fails¶
CoreDNS listens only on the Docker bridge network. dig @172.21.0.53 phil-db from the host gets "connection refused". Test from inside a Docker container on the traefik-resolver network.
Forgejo runner step containers: checkout fails via hairpin NAT¶
Step containers in compose-forgejo-runner-network try to connect to git.opensocial.at, which resolves to the host's own public IP (157.90.134.159). Docker's hairpin NAT fails here — Shorewall's dock → fw INPUT chain blocks port 443 for connections originating from Docker bridge networks.
--add-host=git.opensocial.at:192.168.208.1 does not help — 192.168.208.1 is the runner network's gateway IP, and 192.168.208.1:443 hits the same Shorewall INPUT block.
Fix: Set container.network: traefik-resolver in runner.yaml. CoreDNS at 172.21.0.53 resolves git.opensocial.at → 172.21.0.100 (Traefik's IP on traefik-resolver). The connection stays entirely within Docker networks, bypassing the host firewall.
Since traefik-resolver is --internal (no internet egress), mount the Docker socket from the host so step containers can pull images and run containers through the host daemon:
container:
network: traefik-resolver
options: >-
--dns=172.21.0.53
--userns=host
--volume=/var/run/docker.sock:/var/run/docker.sock
--volume=/usr/bin/docker:/usr/local/bin/docker:ro
valid_volumes:
- /var/run/docker.sock
- /usr/bin/docker
Workflows must not use apt-get install docker.io — there is no internet egress for apt. Docker CLI is available via the host bind mount.
DooD: Docker socket permission denied with userns-remap¶
Phil-app runs Docker with userns-remap (UID offset 165536). Step container "root" (UID 0 inside) maps to UID 165536 on the host, which has no permission on /var/run/docker.sock (owned by root:docker, mode 660).
Fix: Add --userns=host to container.options. This runs step containers in the host user namespace (same as the runner container's userns_mode: host), so UID 0 inside = UID 0 on host = real root with full socket access.
DooD: workspace bind mount doesn't work when workspace is in a named volume¶
The runner workspace lives inside the forgejo-runner_data Docker named volume, not on the host filesystem. Passing -v "$GITHUB_WORKSPACE:/docs:ro" to the host Docker daemon via DooD results in an empty bind mount — the path /workspace/philipp.info/root-repo does not exist on the host.
Fix: Use --volumes-from $(hostname) to share the step container's volume set with the build container. Named Docker volumes (like docs_site) are still passed directly since the host daemon manages them:
- name: Build MkDocs site
run: |
docker run --rm \
--volumes-from "$(hostname)" \
-v "docs_site:/docs_output" \
-w "$GITHUB_WORKSPACE" \
squidfunk/mkdocs-material:9.7.3 \
build --strict --site-dir /docs_output
$(hostname) returns the step container's short ID (Docker sets container hostname to the container ID by default). --volumes-from mounts all volumes from that container, including the workspace, into the build container.
Forgejo runner v9: container.extra_hosts field removed¶
Runner v9 removed container.extra_hosts. Using it causes a Go nil-pointer panic at startup.
Fix: Move host entries to container.options as --add-host flags (though prefer the traefik-resolver + CoreDNS approach above instead of static --add-host entries).
Decisions¶
- ADR-003 —
host.docker.internalworkaround