DockerSelf-Hosting

Headscale with Docker: a Self-Hosted Tailscale Server (Linux server, Windows and Mac clients)

Before you start

  • 20 min read

Boxes marked Run in terminal are what you type; plain text explains why. Check what you need first

What you get in one minute#

Headscale is a free, open source control server for the official Tailscale apps: you run the sign-in and key exchange on your own server, and your laptops, phones and servers join a private network without a Tailscale account.

Headscale official logo

Logo: the official Headscale project (github.com/juanfont/headscale)

Your devices still use the normal Tailscale apps and WireGuard. Headscale only hands out keys and addresses (100.64.x.x); your traffic goes from device to device. It suits a home lab, a family or a small team. It is a community project, not associated with Tailscale Inc., and covers one network (one tailnet).

WhatValue (official docs, read October 2026)
Current versionHeadscale v0.29.7, released 30 October 2026 (a security release)
Image to pinghcr.io/juanfont/headscale:0.29.7 (checked on the registry; amd64 and arm64)
Time neededAbout 30 minutes with a domain ready (our estimate)
Size and RAMImage 113 MB; 13 MiB RAM idle with one device (we measured it, see Performance)
PortsTCP 443 public (HTTPS); TCP 80 only for Let's Encrypt; 8080 stays on the server itself
LicenceBSD 3-Clause (LICENSE at the release tag)
ClientsOfficial Tailscale apps. Minimum client v1.80.0; the release notes ask for v1.104.4 or newer
Official docsheadscale.net and the repository

At a glance:

  • What it replaces: the Tailscale company's sign-in server. The apps stay the same; you point them at your own address.
  • What you need: a Linux server with a public IP, a domain name and HTTPS on port 443.
  • Docker is community-supported: the project builds the image but says it does not officially support Docker or reverse proxies. We follow its container page and mark where we went beyond it.
  • No web interface: you manage it with a command line tool inside the container.
  • Not supported: the docs list Tailscale Funnel, Serve and network flow logs as missing.
  • Privacy: by default Headscale tells clients to switch off log upload to Tailscale Inc. Relays default to Tailscale's public DERP servers; your traffic stays encrypted.

How it fits together#

Devices talk to Headscale through your HTTPS address to sign in and fetch keys. After that they talk to each other directly, and use a relay only when a direct path fails.

Two-lane diagram: on the left, a laptop or phone signs in over HTTPS to Caddy or Traefik on port 443, which proxies to Headscale on port 8080, which stores its SQLite data in a folder on the host; on the right, the devices open a direct WireGuard tunnel to each other, with an optional relay when a direct connection fails

Drawn for this guide from the official docs and config example. The dashed relay is used only when no direct path works.

Before you start#

You need a Linux server you manage, Docker with Compose, and a domain name that points to it.

Linux server: a public IPv4 address, Docker Engine with Docker Compose, curl, and an x86_64 or arm64 CPU. The docs ask for "a reasonably modern Linux or BSD based operating system".

Windows: a client only (Windows 10 or later). Check the package manager:

Run in terminalpowershell
winget --version

Mac: a client only (macOS Ventura 13.0 or later, Tailscale's requirement). Check Terminal works:

Run in terminalbash
sw_vers -productVersion

Checklist:

  • [ ] Docker and Docker Compose on the server (docker --version, docker compose version)
  • [ ] A domain name, for example headscale.example.com, with a DNS record pointing at the server
  • [ ] Ports 80 and 443 reach the server (forward them on your router if it sits at home)
  • [ ] A second name for the tailnet that is NOT under that domain, for example tailnet.example.net (Step 2 explains why)
  • [ ] Nothing else listening on ports 80 and 443 (or an existing Traefik, see the HTTPS section)

Tested by DIY Lab Hub on 2026-10-31: the container, Caddy with tls internal and one Tailscale 1.104.4 test client ran on a Docker Desktop host (Windows 11, Linux containers). We did not test a real Linux server, Let's Encrypt, Traefik, or Windows, Mac and phone clients. Those steps follow the official docs.

Step 1: Create the folders on the Linux server#

Headscale needs a config folder and a data folder (keys and the SQLite database), in ~/docker_volumes/headscale.

Linux#

Run in terminalbash
mkdir -p ~/docker_volumes/headscale/{config,data,backups}
cd ~/docker_volumes/headscale
docker --version
docker compose version

Windows (PowerShell)#

Not supported as the server. The docs name Linux and BSD systems and a public IP address. Run this step on a Linux machine.

Mac (Terminal)#

Not supported as the server. The docs do not list macOS as a host, and we did not test it. Run this step on a Linux machine.

Check it worked. The folders exist:

Run in terminalbash
ls -d ~/docker_volumes/headscale/*

Tip: Install Docker from the official Docker documentation (docs.docker.com/engine/install). Skip install scripts from blogs.

Step 2: Download the config for this version and edit four lines#

Headscale will not start without a config file. The project says to download the example for the same tag as your version.

First replace the two example names in the commands below, headscale.example.com and tailnet.example.net, with your own.

Linux#

Run in terminalbash
cd ~/docker_volumes/headscale
curl -fsSL -o config/config.yaml \
  https://raw.githubusercontent.com/juanfont/headscale/v0.29.7/config-example.yaml
sed -i \
  -e 's|^server_url: http://127.0.0.1:8080$|server_url: https://headscale.example.com|' \
  -e 's|^listen_addr: 127.0.0.1:8080$|listen_addr: 0.0.0.0:8080|' \
  -e 's|^  base_domain: example.com$|  base_domain: tailnet.example.net|' \
  -e 's|^disable_check_updates: false$|disable_check_updates: true|' \
  config/config.yaml
grep -n -E '^(server_url|listen_addr|disable_check_updates):|^  base_domain:' config/config.yaml

What each line does:

LineWhy we change it
server_urlThe address every device uses: https:// and reachable from outside. The example's http://127.0.0.1:8080 only works on the server
listen_addrInside a container 127.0.0.1 hides the port from Docker, so use 0.0.0.0:8080 (the example's own comment says so for production)
base_domainThe tailnet name (laptop.tailnet.example.net). It must NOT be a parent of the server_url host, or Headscale refuses to start
disable_check_updatesStops the startup check against GitHub, which in 0.29.7 slows every command when GitHub is unreachable (issue #3639)

Windows (PowerShell)#

Not supported as the server. Edit this file on the Linux server, for example over SSH.

Mac (Terminal)#

Not supported as the server. Edit this file on the Linux server, for example over SSH.

Check it worked. The grep prints four lines with your names. To see the base-domain rule yourself, we left base_domain: example.com in place with server_url: https://headscale.example.com. The start failed with server_url cannot be part of base_domain in a way that could make the DERP and headscale server unreachable.

Tip: base_domain and server_url must be different names. If your login page is headscale.example.com, do not use example.com as the tailnet name.

Step 3: Write the Compose file and start Headscale#

This follows the project's container page, with our changes: a pinned tag, /tmp as a RAM disk because Docker does not add one to a read-only container (issue #3463), and a shared proxy network for the HTTPS step. The port stays on the server's loopback address.

Linux#

Run in terminalbash
cd ~/docker_volumes/headscale
docker network inspect proxy >/dev/null 2>&1 || docker network create proxy
cat > docker-compose.yml << 'EOF'
services:
  headscale:
    image: ghcr.io/juanfont/headscale:0.29.7
    container_name: headscale
    restart: unless-stopped
    read_only: true
    tmpfs:
      - /var/run/headscale
      - /tmp
    command: serve
    ports:
      - "127.0.0.1:8080:8080"
    volumes:
      - ./config:/etc/headscale:ro
      - ./data:/var/lib/headscale
    networks:
      - proxy
    healthcheck:
      test: ["CMD", "headscale", "health"]

networks:
  proxy:
    external: true
EOF
docker compose up -d
docker compose ps
curl -s http://127.0.0.1:8080/health

Windows (PowerShell)#

Not supported as the server. Run this step on the Linux server.

Mac (Terminal)#

Not supported as the server. Run this step on the Linux server.

Check it worked. In our run the container showed (healthy) after under a minute and /health answered:

File contenttext
{"status":"pass"}

The log ends with listening and serving HTTP on: 0.0.0.0:8080. It also warns listening without TLS but ServerURL does not start with http://. The docs say that warning is expected when a proxy handles TLS.

Put it behind HTTPS (with or without Traefik)#

Unlike most apps, Headscale needs HTTPS before your devices can use it: the docs call HTTPS on port 443 "strongly recommended". Pick one route; both need your domain pointing at the server.

The docs' reverse-proxy page is community-written. It says the proxy must pass WebSockets (an upgrade with a POST and the header Upgrade: tailscale-control-protocol), and that Headscale does not work behind Cloudflare's proxy or tunnel.

With Traefik#

Use this if Traefik v3 with the Docker provider already runs on the server. The docs have no Traefik example, so this follows our other guides. It is untested with a real Tailscale login; if sign-in fails, try the Caddy route.

In docker-compose.yml, add this block under the headscale service, after healthcheck:. This assumes the external network proxy and an entrypoint called https (use your own names):

File contentyaml
    labels:
      - "traefik.enable=true"
      - "traefik.docker.network=proxy"
      - "traefik.http.routers.headscale.rule=Host(`headscale.example.com`)"
      - "traefik.http.routers.headscale.entrypoints=https"
      - "traefik.http.routers.headscale.tls=true"
      - "traefik.http.services.headscale.loadbalancer.server.port=8080"

You may now remove the ports: lines from the Headscale service, because Traefik reaches it on the proxy network. We keep the loopback port for the health check from the server. Then docker compose up -d.

Check it worked. curl -I https://headscale.example.com/health returns an HTTP status line over HTTPS.

Without Traefik: Caddy#

Choose this if you have no Traefik. Caddy gets and renews its own certificate.

CaseWhat happens
Public domainCaddy gets a Let's Encrypt certificate by itself. The DNS record must point at the server and ports 80 and 443 must reach it
Home or lab onlytls internal uses Caddy's own local certificate. Every device must trust Caddy's root certificate first

Create the Caddy folder and file. The image is caddy:2.11.7, the latest Caddy release read in October 2026:

Run in terminalbash
mkdir -p ~/docker_volumes/caddy/{data,config}
cd ~/docker_volumes/caddy
cat > docker-compose.yml << 'EOF'
services:
  caddy:
    image: caddy:2.11.7
    container_name: caddy
    restart: unless-stopped
    ports:
      - "80:80/tcp"
      - "443:443/tcp"
      - "443:443/udp"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - ./data:/data
      - ./config:/config
    networks:
      - proxy

networks:
  proxy:
    external: true
EOF

The Caddyfile for a public domain (the Headscale container is named headscale, so Caddy finds it by that name):

File contenttext
headscale.example.com {
    reverse_proxy headscale:8080
}

For a home-only name use tls internal:

File contenttext
headscale.home.arpa {
    tls internal
    reverse_proxy headscale:8080
}

The docs' Caddy example also passes the client address in two headers. You do not need that to sign in; if you add it, set trusted_proxies to your proxy network too.

Start it and check:

Run in terminalbash
cd ~/docker_volumes/caddy && docker compose up -d
curl -I https://headscale.example.com/health

For tls internal our test returned HTTP/1.1 200 OK with {"status":"pass"} (with curl -k, because the root was not trusted yet). Copy Caddy's root certificate out of its container, then install it on each device:

Run in terminalbash
docker cp caddy:/data/caddy/pki/authorities/local/root.crt ./caddy-root.crt

These are standard operating system commands, not from the Headscale or Caddy docs, and not tested with the Tailscale apps:

  • Linux (Debian and Ubuntu): copy it to /usr/local/share/ca-certificates/caddy-root.crt, then run sudo update-ca-certificates.
  • Windows (PowerShell, as administrator): certutil -addstore -f "ROOT" caddy-root.crt
  • Mac (Terminal): sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain caddy-root.crt

We could not confirm that every Tailscale app, such as the phone apps, accepts a locally trusted root. Beyond a lab, use a public domain.

Tip: Open https://headscale.example.com/health in your phone's browser before you install any app. If the browser shows a certificate warning, the app will fail too.

Check it worked. curl -I https://headscale.example.com/health returns a status line over HTTPS, and the browser shows {"status":"pass"} without a warning.

Step 4: Create a user and a sign-in key#

Every device belongs to a Headscale user. Create one, then make a one-use key for the first device.

Linux#

Run in terminalbash
cd ~/docker_volumes/headscale
docker compose exec headscale headscale users create alice
docker compose exec headscale headscale users list
docker compose exec headscale headscale preauthkeys create --user 1

Pass the ID from users list to --user: 0.29.7 expects the ID (a name works only in the unreleased 0.30). The key lasts one hour and works once.

Windows (PowerShell)#

Not for the server. To run these commands from your PC, connect to the server over SSH, then run the Linux commands there:

Run in terminalpowershell
ssh <your-user>@<your-server-address>

Mac (Terminal)#

Same as Windows, from Terminal:

Run in terminalbash
ssh <your-user>@<your-server-address>

Check it worked. In our run users create printed User created, users list showed ID 1, and the key command printed a line starting with hskey-auth-. Treat that key like a password.

Tip: Add --expiration 10m to the key command if you will use it at once. A leaked key lets a stranger join until it expires or is used.

Step 5: Connect the first device#

Install the normal Tailscale app, then point it at your server. The address is the server_url from Step 2.

Linux#

Run in terminalbash
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up --login-server https://headscale.example.com --authkey <YOUR_AUTH_KEY>

Windows (PowerShell)#

Run in terminalpowershell
winget install --id Tailscale.Tailscale
tailscale login --login-server https://headscale.example.com

Open a new PowerShell window after the install so tailscale is found. A browser page opens and shows an Auth ID. Approve it on the server:

Run in terminalbash
docker compose exec headscale headscale auth register --user alice --auth-id <AUTH_ID>

Mac (Terminal)#

Install Tailscale from the Mac App Store or tailscale.com/download, then sign in with the command (the tool lives inside the app):

Codebash
/Applications/Tailscale.app/Contents/MacOS/Tailscale login --login-server https://headscale.example.com

The docs also describe an app route: Option-click the Tailscale menu icon, open Debug, and choose Add Account under Custom Login Server. Approve the Auth ID on the server with the auth register command above. Phones (docs): on iOS use "Use custom coordination server", on Android "Use an alternate server".

Check it worked. On the server:

Run in terminalbash
docker compose exec headscale headscale nodes list

In our test with a Tailscale 1.104.4 container the list showed this (trimmed to the main columns):

Codetext
ID | Hostname | Name     | User  | IP addresses      | Connected | Expired
1  | lab-test | lab-test | alice | 100.64.0.1        | online    | no
   |          |          |       | fd7a:115c:a1e0::1 |           |

Tip: If a device was already signed in to Tailscale's own service, run tailscale logout first, then the login command above. A device talks to one control server at a time.

Step 6: Add a second device and test the tunnel#

Repeat Step 5 on another device (new key or new Auth ID). Then test from the first device.

Linux#

Run in terminalbash
tailscale status
tailscale ping <other-device-name>

Windows (PowerShell)#

Run in terminalpowershell
tailscale status
tailscale ping <other-device-name>

Mac (Terminal)#

Codebash
/Applications/Tailscale.app/Contents/MacOS/Tailscale status
/Applications/Tailscale.app/Contents/MacOS/Tailscale ping <other-device-name>

Check it worked. tailscale status lists both devices with 100.64.x.x addresses. tailscale ping answers pong from ...; it may first say via DERP (the relay) and then switch to a direct path. Short names work through MagicDNS.

Tip: Out of the box every device can reach every other device. Read the access rules below before you add other people.

Names and access rules#

Access: the docs say that with no policy file "Headscale allows all traffic between nodes". To restrict it, write a policy file, set policy.path in the config and send Headscale a SIGHUP. A policy with {"grants": []} denies everything. Read the policy page first and test each rule with a second device.

Which clients work: the official Tailscale apps for Linux, FreeBSD, OpenBSD, Windows, macOS, iOS, tvOS and Android. The project supports the last 10 client releases (v1.80.0 or newer); update to v1.104.4 or newer, because 0.29.7 also fixes problems inside the client.

What does not work: Funnel, Serve and network flow logs. Our Ollama over Tailscale guide uses tailscale serve and Tailscale-issued HTTPS names; with Headscale, reach that service by its tailnet address or put Caddy in front of it. If you only want a plain WireGuard VPN with a web admin page, wg-easy is a smaller tool.

Security and testing#

A control server decides who joins your network, so protect it like a password manager.

ControlHow
HTTPSOnly the proxy faces the internet (443, and 80 for Let's Encrypt). Port 8080 stays on 127.0.0.1
Metrics and gRPCDo not publish ports 9090 or 50443
Who is adminAnyone who can run docker exec on the server controls Headscale. The docker group is root-equivalent
Sign-in keysOne use, short expiry, never in chat or screenshots
Data folder~/docker_volumes/headscale/data holds the server key and database; keep it unreadable for others
Access rulesDefault is allow all; add a policy before you share the tailnet
Same machineDo not run Headscale on a device of its own tailnet (FAQ: unsupported)

Quick tests after each change (Linux; on Windows or Mac use curl.exe -I or curl -I against the HTTPS address):

Run in terminalbash
cd ~/docker_volumes/headscale
docker compose ps
docker compose logs --tail=100 headscale
curl -I https://headscale.example.com/health
docker compose exec headscale headscale nodes list

What leaves the machine: the startup update check contacts GitHub unless you switch it off (Step 2). Headscale also fetches the relay list from https://controlplane.tailscale.com/derpmap/default (the example's default) and gives it to your devices, which may relay through Tailscale's public servers. Tunnel content stays encrypted.

Common mistakes#

These come from the official issue tracker, the docs' own warnings and common AI assistant answers.

MistakeWhy it happensDo this instead
Leaving base_domain: example.com while server_url is under itThe example config's default; the error is server_url cannot be part of base_domain in a way that could make the DERP and headscale server unreachableUse a tailnet name that is not a parent of the sign-in host, such as tailnet.example.net
Starting the container with no configIssue #3567 shows three fatal errors on a first run; the maintainers point to the docsDownload the example for your tag and mount it at /etc/headscale/config.yaml
Keeping listen_addr: 127.0.0.1:8080 in a containerDocker's published port cannot reach a loopback-only listenerSet 0.0.0.0:8080 in the config and publish 127.0.0.1:8080:8080 on the host
A proxy without WebSocket upgradeThe sign-in protocol upgrades with a POST and a custom Upgrade headerUse the Caddy file above, or add the same upgrade handling in Nginx or Apache from the docs
Putting Cloudflare's proxy or tunnel in frontIt looks easy; the docs say it does not work (issue #1468)Use a normal HTTPS reverse proxy on your own server
Read-only container without /tmpThe docs' file sets read_only: true; deleting a user then failed with disk I/O error (6410) in issue #3463Add /tmp to tmpfs, as in Step 3
Skipping versions when upgradingThe docs require one minor at a time; a jump from 0.26.1 to 0.27.0 ended with noise handshake failed: decrypting machine key in issue #2870One minor at a time, newest patch, backup first
Reusing the default keyIt lasts one hour and works onceMake a new key per device
AI assistants: headscale nodes register --key or namespacesOld tutorials; we ran the first and got a deprecation notice, and namespaces is an unknown commandUse headscale auth register --user <USER> --auth-id <AUTH_ID> and headscale users
AI assistants: ip_prefixes in the config, or preauthkeys create --user aliceThe key became prefixes.v4 and prefixes.v6; 0.29.7 expects the user IDStart from the config example for your tag; use the ID from users list
AI assistants: tailscale serve, tailscale funnel or Tailscale HTTPS namesThey are Tailscale-company services; the docs list Funnel and Serve as missingPut Caddy in front, or reach the service by its tailnet address

Tip: Before you paste a config an AI wrote, compare every key with config-example.yaml at your exact tag. Old keys either do nothing or stop the start.

Tip: Change one thing at a time and read docker compose logs --tail=100 headscale after each; it prints the config line that is wrong.

Tip: Write down your tailnet name and domain, and take a backup, before you add family or team members. Every device's full name ends in base_domain, so changing it later changes all of them.

Troubleshooting#

ProblemSolution
Container exits at oncedocker compose logs --tail=50 headscale; fix the config line it names, then docker compose up -d
Container shows unhealthy or commands take about 30 secondsThe startup update check cannot reach GitHub (issue #3639, 0.29.7). Set disable_check_updates: true and restart
/health works on the server but not over HTTPSThe proxy cannot reach headscale:8080. Check both are on the proxy network: docker network inspect proxy
Certificate error in CaddyThe DNS record must match the name in the Caddyfile, and ports 80 and 443 must reach the server. Read docker logs caddy
Device says it cannot reach the coordination serverTest https://headscale.example.com/health in a browser from that device, then check the proxy and the server_url (a similar report is issue #3346)
tailscale login prints a page but the device never appearsYou did not run headscale auth register --user ... --auth-id ... yet
Pre-auth key refusedIt expired or was used. Make a new one
Windows stops when you log outOpen the Tailscale tray menu, Preferences, and turn on "Run unattended" (Windows docs)

Known bugs (open on 2026-10-31)#

  • Issue #3626: a subnet router does not see its own approved routes unless the policy pairs it with them.
  • Issue #3598: on Android, subnet routing stopped after a switch from Wi-Fi to mobile data (reported on 0.29.5).
  • Issue #3573: the embedded STUN server answers IPv4 clients with an IPv4-mapped IPv6 address; it only matters if you enable the embedded DERP server.

Diagnostics to run first:

Run in terminalbash
docker compose ps
docker compose logs --tail=200 headscale
docker inspect headscale --format '{{.Config.Image}}'
docker compose exec headscale headscale configtest

Updates and backups#

Update. Read the release page first. The docs require one minor version at a time and the latest patch. For a new patch (for example 0.29.8):

Run in terminalbash
cd ~/docker_volumes/headscale
# edit the image tag in docker-compose.yml, then compare config.yaml with config-example.yaml at that tag
docker compose pull
docker compose up -d
docker compose logs --tail=100 headscale

For 0.30 or later, go to the last 0.29 patch first, then one minor at a time. The rollback tag is 0.29.6; restore the backup if the database already changed.

Back up. The docs say to stop Headscale, then copy the folder. Create ~/docker_volumes/headscale/backup.sh:

File contentbash
#!/bin/bash
set -e
cd "$(dirname "$0")"
docker compose stop headscale
mkdir -p backups
sudo tar -czf "backups/headscale-$(date +%F).tar.gz" config data docker-compose.yml
sudo chmod 600 backups/headscale-*.tar.gz
docker compose start headscale

Run it once with bash ~/docker_volumes/headscale/backup.sh. To run it every night at 03:00 on Linux, add this line to root's crontab with sudo crontab -e (use the full path, because ~ is not your home folder there):

File contenttext
0 3 * * * /bin/bash /home/<your-user>/docker_volumes/headscale/backup.sh

Keep a copy on another machine and treat it as secret: it holds the server key.

Restore test. Unpack into a new empty folder, never over the live one:

Run in terminalbash
mkdir -p /tmp/restore-test && sudo tar -xzf backups/headscale-<date>.tar.gz -C /tmp/restore-test
ls /tmp/restore-test/data

You should see db.sqlite and noise_private.key.

Windows (PowerShell)#

Not for the server. Copy the archive off the Linux server from your PC:

Run in terminalpowershell
scp <your-user>@<your-server-address>:~/docker_volumes/headscale/backups/headscale-<date>.tar.gz .

Mac (Terminal)#

Use the same scp command from Terminal.

Performance and resources#

  • Measured by us (one test device, idle): 13.08 MiB of RAM and 0.18% CPU from docker stats --no-stream; the image is 113 MB on disk.
  • How many devices: the FAQ says "100s of devices" can work when little changes, and that performance is not a focus. Plan for a home, a family or a small team.
  • Database: SQLite is recommended; PostgreSQL is in "maintenance mode".
  • Monitor: docker stats headscale and the logs. The metrics port 9090 stays on loopback.

FAQ#

Is Headscale free? Yes. It is open source under the BSD 3-Clause licence. You still need a server and a domain.

Is it made by Tailscale, and do I need a public IP and a domain? No, it is a separate community project. For devices outside your home you need a public address, a domain and HTTPS on 443. For a lab at home, tls internal works if each device trusts Caddy's root.

Can Windows or Mac be the server? The docs name Linux and BSD systems, so this guide does not support them as servers. They are fine as clients.

Can I use it with the Tailscale company's service too? A device talks to one control server at a time, so log out of one before you log in to the other. Devices on the two services cannot see each other.

Does it have a web page? No. The CLI inside the container is the admin interface. The docs list a few community web interfaces; this guide does not cover them.

Sources#

Tested by DIY Lab Hub on 2026-10-31 (container, Caddy and one test client on Docker Desktop). Checked against the official docs on 2026-10-31.