Preparing a server
Everything else in the Blueprint runs on top of this. Two decisions here are hard to reverse once services are running, so they come first.
Before you start
Section titled “Before you start”Needs: a Linux host you may reconfigure (Debian 13 is what this is tested on),
Docker Engine 28.0 or newer with Compose 2.33.1 or newer, ports 80 and 443 free,
and a domain whose DNS you control. Engine 28.0 is the first that accepts the IPv6
daemon settings below without a fixed IPv6 range — on 27.x that configuration does
not start the daemon — and Compose 2.33.1 is the first that understands
gw_priority, which pins Traefik’s published ports to proxy-public.
docker --version && docker compose versionss -tlnp | grep -E ':80 |:443 ' || echo "ports free"Enable IPv6 in Docker
Section titled “Enable IPv6 in Docker”The example configuration is dual-stack — Traefik is published on IPv4 and IPv6 —
so enable IPv6 in Docker before starting any service. It matters most when
anyone connects through a VPN — Tailscale, WireGuard, anything where the client
reaches the server directly. Choosing IPv4 only instead: skip this section, and in
core/traefik/.env empty TRAEFIK_BIND_V6= and drop network-dual-stack.yml
from COMPOSE_FILE.
A VPN client connects with no proxy in between, so nothing records the original address in a header. The source address of the connection is the only record there is, and VPN clients prefer IPv6. On an IPv4-only Docker network that address is lost before any access policy can read it, and a policy that allows “only my VPN” will reject your VPN.
The symptom is 403 Forbidden that looks like a permissions problem.
-
Write the daemon configuration:
Terminal window sudo tee /etc/docker/daemon.json >/dev/null <<'EOF'{"log-driver": "json-file","log-opts": { "max-size": "10m", "max-file": "3" },"ipv6": true,"ip6tables": true,"userland-proxy": false,"live-restore": true}EOF -
Check it parses, then restart:
Terminal window sudo python3 -m json.tool /etc/docker/daemon.jsonsudo systemctl restart docker
The log-opts bound container logs — without them they grow until the disk is full.
live-restore keeps containers running when the Docker daemon itself stops or
restarts, which is what updating the Docker packages does. Without it, every such
update restarts every container on the machine at once, and that simultaneous
start-up is its own kind of load.
What it does not do: it does not survive a reboot, it does not help across a major daemon version change — upstream supports it for patch releases only — and it is no substitute for the resource limits each service carries. What it removes is one avoidable restart.
Point DNS at the server
Section titled “Point DNS at the server”One record per hostname you serve:
app.example.com A 203.0.113.10Add an AAAA record when the host has working public IPv6:
curl -6 https://api64.ipify.orgapp.example.com AAAA 2001:db8::1Reaching it over a VPN? Add the same names to your split DNS, pointing at the server’s VPN addresses — both families. An IPv4-only entry sends all VPN traffic over IPv4 and leaves the IPv6 path untested.
Installation
Section titled “Installation”-
Check out the newest release with only the stacks this server runs.
/srv/dockeris the recommended location, not a required one:Terminal window # An empty directory you ownsudo mkdir -p /srv/docker && sudo chown "$USER" /srv/docker# Clone without files, then take the newest releasegit clone --sparse --no-checkout https://github.com/rubennati/secure-docker-blueprint.git /srv/dockercd /srv/dockergit checkout "$(git tag --sort=-v:refname | head -n1)"git sparse-checkout set scripts docs/standards# The stacks on this server, one per line; check out what the file listscp stacks.example stacksscripts/stacks.sh applystacksstarts with Traefik, CrowdSec andwhoami, which is all this page needs. Add a line per application later and runscripts/stacks.sh applyagain; the deployment lifecycle covers removing stacks and previewing updates. -
Create Traefik’s configuration — it terminates TLS and routes everything that follows. Set the values from the Traefik guide’s installation in
core/traefik/.env, but do not start it yet: the network ranges come first. -
Unless you chose IPv4 only, set the two network ranges in
core/traefik/.env, with an IPv6 prefix of your own — the example’s prefix is the documentation example, andvalidate.shrefuses it on purpose:Terminal window RANDOM_HEX=$(openssl rand -hex 5)printf 'PUBLIC_NETWORK_SUBNET_V4=172.30.0.0/16\n'printf 'PUBLIC_NETWORK_SUBNET_V6=fd%s:%s:%s::/64\n' \"${RANDOM_HEX:0:2}" "${RANDOM_HEX:2:4}" "${RANDOM_HEX:6:4}"Generate rather than copy an example — these are private ranges, and reusing someone else’s is the IPv6 equivalent of everyone picking
192.168.1.0/24. -
Keep the example’s dual-stack shape.
core/traefik/.env.examplealready publishes both families and applies the overlay:Terminal window TRAEFIK_BIND_V4=0.0.0.0TRAEFIK_BIND_V6=[::]COMPOSE_FILE=docker-compose.yml:network-dual-stack.yml:config/published-ports.yml:config/edge-networks.ymlA host without public IPv4 empties
TRAEFIK_BIND_V4=— an IPv6-only shape, experimental in this blueprint. Then check, render, check again and start Traefik:Terminal window cd core/traefikbash ops/scripts/validate.sh # before the first render it reports the# published-address checks as not rendered yetbash ops/scripts/render.shbash ops/scripts/validate.shdocker compose up -dvalidate.shalso prints the exposure profile it derives from the bindings —direct,proxiedorprivate— and which protection layers are present or absent. For a public binding,validate.sh --strictadditionally wants the marker of an exposure test run from another machine (scripts/exposure-test.sh, stored inconfig/exposure-verified); see Traefik.
Verify
Section titled “Verify”docker compose psdocker network inspect proxy-public --format '{{range .IPAM.Config}}{{.Subnet}} {{end}}'ss -tlnp | grep -E ':80 |:443 'Expected: containers healthy, two subnets from the second command, and
listeners on both 0.0.0.0 and [::] — on [::] alone for an IPv6-only host.
One subnet means the overlay was missed; no [::] listener means
TRAEFIK_BIND_V6 is empty or render.sh was not run.
Confirm the server sees who is calling
Section titled “Confirm the server sees who is calling”Access policies, rate limits and intrusion detection all depend on this. Deploy the
diagnostic endpoint. Like every stack, whoami ships on its own edge network
(TRAEFIK_NETWORK=${COMPOSE_PROJECT_NAME}-edge, here whoami-edge), and Traefik
reaches it only once that network is listed in EDGE_NETWORKS — otherwise the
stack is unreachable and Traefik’s log says nothing:
# still in core/traefik — in .env: EDGE_NETWORKS="whoami-edge"bash ops/scripts/render.shdocker compose up -d # recreates Traefik: every routed stack pauses ~7 s
cd ../../apps/whoamicp .env.example .env# set APP_TRAEFIK_HOST, and APP_TRAEFIK_ACCESS=access-public for the testdocker compose up -dOpen it and read X-Real-Ip. What it should say depends on the path you came in
on, so test each one you care about and check it against the right expectation:
| Arrived over | X-Real-Ip should be |
|---|---|
| public IPv4 | your public IPv4 — what curl -4 https://api64.ipify.org reports |
| public IPv6 | your public IPv6 — curl -6 https://api64.ipify.org |
| Tailscale, directly | a Tailscale address: 100.x.x.x, or fd7a:115c:a1e0::… — not yet verified on a host with the current bindings and edge networks |
| another VPN | that tunnel’s client address |
| a CDN or upstream proxy | whatever that service forwards — check its own documentation |
The failure to look for is the same in every row: an address from the Docker network rather than the client’s.
172.30.0.1 or similar means the real address is being lost — recheck the daemon
configuration and the overlay.
# close it again afterwardssed -i 's/^APP_TRAEFIK_ACCESS=.*/APP_TRAEFIK_ACCESS=access-vpn/' .envdocker compose up -dGoing further
Section titled “Going further”Rotating the proxy’s logs
Section titled “Rotating the proxy’s logs”Traefik writes access logs to disk, and Docker’s own rotation does not cover them.
sudo cp core/traefik/config/logrotate/traefik /etc/logrotate.d/traefiksudo sed -i 's|/path/to/secure-docker-blueprint|/srv/docker|' /etc/logrotate.d/traefiksudo logrotate -d /etc/logrotate.d/traefikThe last command is a dry run — check the path matches before relying on it.
Troubleshooting
Section titled “Troubleshooting”403 Forbidden on a service that should be reachable — rule out your browser
first:
dig +short traefik.apps.example.comIf that returns the right address but the page stays forbidden, open it in a
private window. Browsers cache DNS independently of the system, and a corrected
record is the worst case: the old host answers 403, which is indistinguishable
from an access policy rejecting you. In Chrome, clear it at
chrome://net-internals/#dns.
Certificate not issued — check the DNS provider token has the permissions the
Traefik guide lists, and watch docker compose logs traefik.
Repository files
Section titled “Repository files”core/traefik/README.md— full proxy setupcore/traefik/docs/ipv6-dual-stack.md— why IPv6 matters for VPN accessdocs/standards/logrotate.md
Official sources
Section titled “Official sources”© 2026 SecDockBlueCode Apache-2.0Site content CC BY-NC 4.0