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 with the Compose plugin, ports 80 and 443 free, and a domain whose DNS you control.

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

Decide: will anyone connect through a VPN?

Section titled “Decide: will anyone connect through a VPN?”

If yes — Tailscale, WireGuard, anything where the client reaches the server directly — enable IPv6 in Docker before starting any service.

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

A wildcard record covers every service you add later:

*.apps.example.com A 203.0.113.10
*.apps.example.com AAAA 2001:db8::1

Add the AAAA record only if the host has working public IPv6:

Terminal window
curl -6 https://api64.ipify.org

Using split DNS on a VPN? Add the same wildcard there, 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. Clone into its own directory:

    Terminal window
    sudo mkdir -p /srv/blueprint && sudo chown "$USER" /srv/blueprint
    cd /srv/blueprint
    git clone --branch main https://github.com/rubennati/secure-docker-blueprint.git .
  2. Set up Traefik — it terminates TLS and routes everything that follows. Follow the Traefik guide, then come back here.

  3. If you enabled IPv6, add the two network ranges to core/traefik/.env and generate your own IPv6 prefix:

    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. Start Traefik with the dual-stack overlay:

    Terminal window
    cd core/traefik
    docker compose -f docker-compose.yml -f network-dual-stack.yml up -d
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 [::].

One subnet means the overlay was missed.

Access policies, rate limits and intrusion detection all depend on this. Deploy the diagnostic endpoint:

Terminal window
cd ../whoami
cp .env.example .env
# set APP_TRAEFIK_HOST, and APP_TRAEFIK_ACCESS=acc-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::…
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=acc-private/' .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/blueprint|' /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.