Skip to content

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.

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.

Terminal window
docker --version && docker compose version
ss -tlnp | grep -E ':80 |:443 ' || echo "ports free"

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.

  1. 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
  2. Check it parses, then restart:

    Terminal window
    sudo python3 -m json.tool /etc/docker/daemon.json
    sudo 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.

One record per hostname you serve:

app.example.com A 203.0.113.10

Add an AAAA record when the host has working public IPv6:

Terminal window
curl -6 https://api64.ipify.org
app.example.com AAAA 2001:db8::1

Reaching 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.

  1. Check out the newest release with only the stacks this server runs. /srv/docker is the recommended location, not a required one:

    Terminal window
    # An empty directory you own
    sudo mkdir -p /srv/docker && sudo chown "$USER" /srv/docker
    # Clone without files, then take the newest release
    git clone --sparse --no-checkout https://github.com/rubennati/secure-docker-blueprint.git /srv/docker
    cd /srv/docker
    git 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 lists
    cp stacks.example stacks
    scripts/stacks.sh apply

    stacks starts with Traefik, CrowdSec and whoami, which is all this page needs. Add a line per application later and run scripts/stacks.sh apply again; the deployment lifecycle covers removing stacks and previewing updates.

  2. 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.

  3. 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, and validate.sh refuses 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.

  4. Keep the example’s dual-stack shape. core/traefik/.env.example already publishes both families and applies the overlay:

    Terminal window
    TRAEFIK_BIND_V4=0.0.0.0
    TRAEFIK_BIND_V6=[::]
    COMPOSE_FILE=docker-compose.yml:network-dual-stack.yml:config/published-ports.yml:config/edge-networks.yml

    A 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/traefik
    bash ops/scripts/validate.sh # before the first render it reports the
    # published-address checks as not rendered yet
    bash ops/scripts/render.sh
    bash ops/scripts/validate.sh
    docker compose up -d

    validate.sh also prints the exposure profile it derives from the bindings — direct, proxied or private — and which protection layers are present or absent. For a public binding, validate.sh --strict additionally wants the marker of an exposure test run from another machine (scripts/exposure-test.sh, stored in config/exposure-verified); see Traefik.

Terminal window
docker compose ps
docker 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.

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:

Terminal window
# still in core/traefik — in .env: EDGE_NETWORKS="whoami-edge"
bash ops/scripts/render.sh
docker compose up -d # recreates Traefik: every routed stack pauses ~7 s
cd ../../apps/whoami
cp .env.example .env
# set APP_TRAEFIK_HOST, and APP_TRAEFIK_ACCESS=access-public for the test
docker compose up -d

Open 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.

Terminal window
# close it again afterwards
sed -i 's/^APP_TRAEFIK_ACCESS=.*/APP_TRAEFIK_ACCESS=access-vpn/' .env
docker compose up -d

Traefik writes access logs to disk, and Docker’s own rotation does not cover them.

Terminal window
sudo cp core/traefik/config/logrotate/traefik /etc/logrotate.d/traefik
sudo sed -i 's|/path/to/secure-docker-blueprint|/srv/docker|' /etc/logrotate.d/traefik
sudo logrotate -d /etc/logrotate.d/traefik

The last command is a dry run — check the path matches before relying on it.

403 Forbidden on a service that should be reachable — rule out your browser first:

Terminal window
dig +short traefik.apps.example.com

If 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.