Skip to content

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-dns on traefik-resolver network at 172.21.0.53
  • Resolves internal hostnames: phil-db10.42.10.3, traefik172.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 help192.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.at172.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-003host.docker.internal workaround