# tsctl on a NAS — Docker Compose (works with Synology Container Manager, # QNAP Container Station, Portainer, Unraid compose, TrueNAS, plain docker/podman). # # Auth (headless): tsctl joins the tailnet as a tagged node. Provide TS_AUTHKEY # ONCE for first enrollment — a tagged auth key, OR (recommended) an OAuth client # secret (tskey-client-...) whose client owns tag:tsctl and has the auth_keys # scope (OAuth secrets don't expire). After first run the node key persists in the # tsctl-state volume and TS_AUTHKEY is no longer needed — you can remove it. # # Networking: tsnet is userspace — NO cap_add, NO /dev/net/tun, NO host network. # The web UI is always served on the TAILNET: open http://tsctl/ (the -hostname) # from any tailnet device. This compose ALSO publishes the UI on a host port so # you can reach it from your LAN without joining the tailnet — that path is # password-protected (TSCTL_UI_PASSWORD); the tailnet path still works too. services: tsctl: image: ghcr.io/lifeart/tsctl:latest # or build: . (see Dockerfile) # build: . container_name: tsctl restart: unless-stopped environment: # --- one-time enrollment (remove after the node is registered) --- TS_AUTHKEY: ${TS_AUTHKEY:?set TS_AUTHKEY for first enrollment (tag:tsctl auth key or OAuth client secret)} # --- required (Both mode: tailnet owner + UI password) --- TSCTL_OWNER: ${TSCTL_OWNER:?your tailnet login email, e.g. you@example.com} # Optional: empty auto-discovers all tag:router nodes; set to pin a subset. TSCTL_ROUTERS: ${TSCTL_ROUTERS:-} # --- host-port UI (reach the UI from your LAN, password-protected) --- # Listen on a host socket INSIDE the container (all interfaces); the ports: # mapping below publishes it to the NAS. Exposing the UI on a host port # REQUIRES a password — tsctl refuses to start an unauthenticated host UI. TSCTL_HTTP_LISTEN: ":8080" TSCTL_UI_PASSWORD: ${TSCTL_UI_PASSWORD:?set a strong UI password to expose the UI on a host port} # The Host header you browse to MUST be on the allowlist (DNS-rebinding # defense). Add your NAS hostname/IP (WITHOUT the port), comma-separated — # e.g. nas.local,192.168.1.50. The tsnet hostname/MagicDNS/100.x are # trusted automatically; the host-listen host is added for you. TSCTL_ALLOWED_HOSTS: ${TSCTL_ALLOWED_HOSTS:?your NAS hostname/IP, e.g. nas.local,192.168.1.50} # --- optional --- TSCTL_HOSTNAME: tsctl TSCTL_SSH_USER: root # preserve|true|false — 'preserve' (default) never touches the router's # --exit-node-allow-lan-access (or any other `tailscale up` setting). TSCTL_EXIT_NODE_LAN_ACCESS: preserve # --- router transport (DEFAULT: tailscale-ssh — SSH over the tailnet) ----- # The OPT-IN ip-password transport reaches routers over plain LAN IP with an # SSH password instead of Tailscale SSH. It trades ACL-governed identity for # a flat shared secret, so it MUST verify host keys (tofu by default) and is # only sane on a trusted LAN — keys still beat passwords. To enable it: # 1. uncomment the three vars below, # 2. uncomment the `secrets:` reference + top-level block (password is a # file-based secret, NEVER plain env), # 3. map each router's 100.x identity to its LAN endpoint in ROUTER_ADDRS. # TSCTL_ROUTER_TRANSPORT: ip-password # TSCTL_ROUTER_HOSTKEY_MODE: tofu # tofu|strict|pin|insecure (insecure logs a warning) # TSCTL_ROUTER_ADDRS: 100.64.0.10=192.168.1.1,100.64.0.11=192.168.1.2:2222 # tsctl reads the SSH password from $CREDENTIALS_DIRECTORY/ssh_password (the # same filename systemd LoadCredential uses); point it at the secrets mount. # Keep TS_AUTHKEY set in env (or also mount a ts_authkey secret): once # CREDENTIALS_DIRECTORY is set and TS_AUTHKEY is blank, tsctl looks for # $CREDENTIALS_DIRECTORY/ts_authkey too. # CREDENTIALS_DIRECTORY: /run/secrets # Uncomment to deliver the ip-password SSH secret as a file (not env): # secrets: # - ssh_password volumes: - tsctl-state:/var/lib/tsctl # PERSISTENT — holds the node key; do not lose it ports: # host:container — browse to http://:8088/. The container's loopback # /healthz (127.0.0.1:8088 INSIDE the container) is NOT published and does # not collide with this host-side port. Change 8088 to any free host port. - "8088:8080" # No cap_add / devices: userspace netstack needs none. security_opt: - no-new-privileges:true volumes: tsctl-state: # Uncomment alongside the service `secrets:` reference above to enable the # ip-password transport. Put the router root password in ./secrets/ssh_password # (0600, gitignored) — it is mounted read-only at /run/secrets/ssh_password and # read via CREDENTIALS_DIRECTORY, so it never appears in `docker inspect` env. # secrets: # ssh_password: # file: ./secrets/ssh_password