Files
automations/deployments/headscale/deploy.sh
T
57_WolveandClaude Opus 5 947c89931d fix(deployments): stop interpolating .env values into a sed script
The set_env/env_set helper escaped its value with
`esc=${val//\/\\}; esc=${esc//|/\|}; esc=${esc//&/\&}` and then
interpolated it into `sed -i -e "s|^KEY=.*|KEY=${esc}|"`. The escaping does
not do what it looks like. Tested on bash 5.2:

  set_env K 'a&b'   ->  K=aK=seedb        (sed expanded & to the whole match)
  set_env K 'a|b'   ->  sed: unknown option to `s'   (rc!=0, aborts under set -e)

So any value containing & is silently corrupted and any value containing the
s||| delimiter kills the run. That is reachable: headscale writes
OIDC_CLIENT_SECRET through this, pocket-id writes REDIRECT_URL, copyparty
writes DATA_DIR. A generated secret or a URL query string hits both cases.

The copies in headscale and pocket-id were additionally mangled when they
were introduced -- `${val//\/\}` (pattern `\/`, a literal SLASH) and a raw
newline inside `printf '%s=%s\n'`. The mangled form is a no-op rather than a
corrupter, so the practical failure mode was the same as the original.

Replace all of them with an awk rewrite that passes the key and value through
the ENVIRONMENT, so the value is never parsed as part of a script and needs no
escaping at all. ENVIRON and index() are POSIX, so busybox awk handles them.
Output goes to a temp file and is copied back with `cat >`, which preserves the
original mode and owner -- a .env holding secrets stays 0600. If awk fails,
set -e aborts before .env is touched, which `sed -i` could not promise.

Verified against plain, a&b, a|b, a\b, p@ss&w|rd\x, R&D, a URL with a query
string, s/foo/bar/, a trailing space and the empty string; plus the
append-when-key-absent path, the file-does-not-exist path, non-target lines
left intact, no line-count drift, and mode preservation.

copyparty/update.sh and the rebuilt copyparty payload are included because
update.sh is embedded; regenerated with build.sh.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 14:21:07 -05:00

583 lines
27 KiB
Bash

#!/usr/bin/env bash
#
# deploy.sh -- deploy the headscale stack (caddy + headscale) on Alpine.
#
# Single-node, dedicated host: runs everything as root.
#
# What this does:
# 1. Installs docker + docker-cli-compose if missing.
# 2. Lays down docker-compose.yml, Caddyfile, and a substituted
# config.yaml in $STACK_DIR.
# 3. Generates .env on first run. On a re-run an existing .env is kept,
# except for values passed explicitly that run, which are written
# through (.env is the source of truth for every consumer below).
# 4. Prompts for required values not preset (HEADSCALE_DOMAIN,
# ACME_EMAIL, TAILNET_DOMAIN, POCKETID_DOMAIN, OIDC_CLIENT_ID,
# OIDC_CLIENT_SECRET).
# 5. Substitutes those values into config.yaml from the embedded
# template.
# 6. Opens TCP 80/443 in UFW if it's installed and active (Tailscale
# clients connect on 443; LE HTTP-01 needs 80).
# 7. Pulls images, brings the stack up, waits for healthchecks.
#
# Idempotent: re-run to apply config changes / pull new images. Re-running
# regenerates config.yaml from the current .env, so edits to .env propagate --
# as do values passed to the re-run itself, including a rotated
# OIDC_CLIENT_SECRET. Values not passed are left exactly as they are.
#
# Self-contained: docker-compose.yml, Caddyfile, config.yaml, policy.hujson
# and .env.example are embedded as a base64-encoded tar.gz at the bottom of
# this file. Rebuild with build.sh after editing the loose source files.
#
# Usage:
# bash deploy.sh # interactive prompts
# HEADSCALE_DOMAIN=hs.example.com \
# ACME_EMAIL=admin@example.com \
# TAILNET_DOMAIN=tail.example.com \
# POCKETID_DOMAIN=auth.example.com \
# OIDC_CLIENT_ID=... OIDC_CLIENT_SECRET=... FORCE=1 bash deploy.sh
#
# STACK_DIR=/opt/headscale bash deploy.sh
# SKIP_DOCKER_INSTALL=1 bash deploy.sh
set -euo pipefail
: "${STACK_DIR:=/srv/headscale}"
: "${SKIP_DOCKER_INSTALL:=0}"
: "${FORCE:=0}"
: "${SKIP_PROMPTS:=0}" # non-interactive: require values via env, no prompts
[[ "$SKIP_PROMPTS" == "1" ]] && FORCE=1
ENV_FILE="$STACK_DIR/.env"
# Everything downstream reads .env (see the `. "$ENV_FILE"` below), so .env has
# to absorb what this run passed or the run silently deploys the old values.
# Which keys actually arrived in the ENVIRONMENT must be recorded BEFORE the
# ":=" defaults below, which make an unset variable look like an empty one --
# and writing a blank over a live OIDC secret is exactly the wrong move.
RUNTIME_KEYS=(HEADSCALE_DOMAIN ACME_EMAIL TAILNET_DOMAIN POCKETID_DOMAIN
OIDC_CLIENT_ID OIDC_CLIENT_SECRET
HEADPLANE_OIDC_CLIENT_ID HEADPLANE_OIDC_CLIENT_SECRET)
KEYS_FROM_ENV=()
for _k in "${RUNTIME_KEYS[@]}"; do
if [[ -n "${!_k+x}" ]]; then KEYS_FROM_ENV+=("$_k"); fi
done
: "${HEADSCALE_DOMAIN:=}"
: "${ACME_EMAIL:=}"
: "${TAILNET_DOMAIN:=}"
: "${POCKETID_DOMAIN:=}"
: "${OIDC_CLIENT_ID:=}"
: "${OIDC_CLIENT_SECRET:=}"
: "${HEADPLANE_OIDC_CLIENT_ID:=}" # optional: headplane UI OIDC login
: "${HEADPLANE_OIDC_CLIENT_SECRET:=}"
log() { printf '\033[1;32m[+]\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[!]\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31m[x]\033[0m %s\n' "$*" >&2; exit 1; }
[[ $EUID -eq 0 ]] || die "Run as root."
# ---------------------------------------------------------------------------
# OS detection + Docker install (Alpine / Debian / Alma). This deploy.sh is
# self-contained (scp'd standalone), so the OS logic is inlined here instead
# of sourced from scripts/oslib.sh.
# ---------------------------------------------------------------------------
osfam() {
local id="" like=""
if [[ -r /etc/os-release ]]; then
id="$(. /etc/os-release 2>/dev/null && echo "${ID:-}")"
like="$(. /etc/os-release 2>/dev/null && echo "${ID_LIKE:-}")"
fi
case " $id $like " in
*" alpine "*) echo alpine ;;
*" debian "*|*" ubuntu "*) echo debian ;;
*" rhel "*|*" fedora "*|*" centos "*) echo rhel ;;
*) echo "${id:-unknown}" ;;
esac
}
install_docker() {
if command -v docker >/dev/null 2>&1; then
log "Docker already installed: $(docker --version)"
else
log "Installing Docker (OS: $(osfam))..."
case "$(osfam)" in
alpine) apk add -q docker docker-cli-compose openrc ;;
debian|rhel) command -v curl >/dev/null 2>&1 || \
{ command -v apt-get >/dev/null 2>&1 && apt-get install -y -qq curl; } || \
{ command -v dnf >/dev/null 2>&1 && dnf install -y -q curl; }
curl -fsSL https://get.docker.com | sh ;;
*) die "Unsupported OS for auto Docker install. Set SKIP_DOCKER_INSTALL=1 and install Docker yourself." ;;
esac
fi
if command -v rc-update >/dev/null 2>&1; then
rc-update add docker default >/dev/null 2>&1 || true
rc-service docker status >/dev/null 2>&1 || rc-service docker start
elif command -v systemctl >/dev/null 2>&1; then
systemctl enable --now docker >/dev/null 2>&1 || systemctl start docker || true
fi
}
open_web_ports() {
# Register 80/443 for this stack. Prefer the host firewall (harden-firewall.sh)
# when present: drop in a rule file and re-apply. Else fall back to ufw/
# firewalld if active (no-op when neither is).
#
# NOTE: Caddy publishes 80/443 via Docker, which reaches the host through the
# nat/FORWARD chains and BYPASSES the INPUT firewall -- so this is harmless
# belt-and-braces for any host-bound bind and self-documents the stack ports.
if [[ -d /etc/firewall/ports.d && -x /usr/local/sbin/firewall-apply ]]; then
log "Registering 80,443/tcp with host firewall..."
printf '80/tcp\n443/tcp\n' > /etc/firewall/ports.d/headscale.rule
/usr/local/sbin/firewall-apply
elif command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q '^Status: active'; then
log "ufw active -- allowing 80,443/tcp..."
ufw allow 80/tcp >/dev/null; ufw allow 443/tcp >/dev/null
elif command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state >/dev/null 2>&1; then
log "firewalld active -- allowing http,https..."
firewall-cmd -q --add-service=http --permanent
firewall-cmd -q --add-service=https --permanent
firewall-cmd -q --reload
fi
}
# Write headplane's config to $STACK_DIR/headplane.yaml from the current .env
# values. OIDC is enabled only when a client id + secret are present; otherwise
# headplane falls back to API-key login. Mode is API-only (no Docker socket).
render_headplane_config() {
local oidc_enabled=false
[[ -n "${HEADPLANE_OIDC_CLIENT_ID:-}" && -n "${HEADPLANE_OIDC_CLIENT_SECRET:-}" ]] && oidc_enabled=true
( umask 077
cat > "$STACK_DIR/headplane.yaml" <<EOF
# Generated by deploy.sh -- do not edit by hand (regenerated each run from .env).
server:
host: "0.0.0.0"
port: 3000
base_url: "https://${HEADSCALE_DOMAIN}"
cookie_secret: "${HEADPLANE_COOKIE_SECRET}"
cookie_secure: true
headscale:
url: "http://headscale:8080"
api_key: "${HEADPLANE_HS_API_KEY:-}"
oidc:
enabled: ${oidc_enabled}
issuer: "https://${POCKETID_DOMAIN}"
client_id: "${HEADPLANE_OIDC_CLIENT_ID:-}"
client_secret: "${HEADPLANE_OIDC_CLIENT_SECRET:-}"
use_pkce: true
EOF
)
chmod 600 "$STACK_DIR/headplane.yaml"
}
# ----------------------------------------------------------------------------
# Extract embedded archive
# ----------------------------------------------------------------------------
SCRIPT_DIR=$(mktemp -d -t headscale-deploy.XXXXXX)
trap 'rm -rf "$SCRIPT_DIR"' EXIT
extract_archive() {
grep -a -A 9999999 '^__ARCHIVE_BELOW__$' "$0" \
| tail -n +2 \
| base64 -d \
| tar -xz -C "$SCRIPT_DIR"
}
if grep -q -a '^__ARCHIVE_BELOW__$' "$0"; then
log "Extracting embedded deployment files..."
extract_archive
else
die "No embedded archive found. Run build.sh to embed deployment files."
fi
for f in docker-compose.yml Caddyfile config.yaml policy.hujson .env.example; do
[[ -f "$SCRIPT_DIR/$f" ]] || die "Embedded archive missing $f"
done
# ----------------------------------------------------------------------------
# Prompt for required vars
# ----------------------------------------------------------------------------
prompt() {
local varname="$1" message="$2"
local -n ref="$varname"
if [[ -z "${ref:-}" ]]; then
[[ "$SKIP_PROMPTS" == "1" ]] && die "$varname required (set it in the environment; running with SKIP_PROMPTS=1)."
read -r -p "$message: " ref
[[ -n "$ref" ]] || die "$varname required."
fi
}
# On a re-run the deployed values live in .env. Load them into any key NOT passed
# this run, so the prompts below do not ask six questions whose answers the
# `. "$ENV_FILE"` further down would discard -- and so SKIP_PROMPTS=1 does not
# die demanding values the .env already has.
if [[ -f "$ENV_FILE" ]]; then
_preloaded=()
for _k in "${RUNTIME_KEYS[@]}"; do
if [[ " ${KEYS_FROM_ENV[*]-} " == *" ${_k} "* ]]; then continue; fi
_v=$(sed -n "s/^${_k}=//p" "$ENV_FILE" | tail -n1)
if [[ -z "$_v" ]]; then continue; fi
printf -v "$_k" '%s' "$_v"
_preloaded+=("$_k")
done
if (( ${#_preloaded[@]} > 0 )); then
log "Reusing ${ENV_FILE} values for: ${_preloaded[*]}"
log " (pass VAR=... to change one; it is then written back to .env)"
fi
fi
prompt HEADSCALE_DOMAIN "Public hostname for headscale (e.g. hs.example.com)"
prompt ACME_EMAIL "Let's Encrypt email"
prompt TAILNET_DOMAIN "Tailnet base domain for MagicDNS (e.g. tail.example.com)"
prompt POCKETID_DOMAIN "OIDC issuer hostname (your pocket-id, e.g. auth.example.com)"
prompt OIDC_CLIENT_ID "OIDC client_id (from pocket-id)"
prompt OIDC_CLIENT_SECRET "OIDC client_secret (from pocket-id)"
[[ "$HEADSCALE_DOMAIN" != "$TAILNET_DOMAIN" ]] \
|| die "HEADSCALE_DOMAIN and TAILNET_DOMAIN must differ."
# ----------------------------------------------------------------------------
# Docker
# ----------------------------------------------------------------------------
if [[ "$SKIP_DOCKER_INSTALL" != "1" ]]; then
install_docker
fi
# Open 80/443 on whichever host firewall is active. (Tailscale clients connect
# on 443; LE HTTP-01 needs 80. Note the IPv6-to-docker-proxy default-deny
# gotcha on ufw -- opening the ports explicitly covers it.)
open_web_ports
# ----------------------------------------------------------------------------
# Stack directory + files
# ----------------------------------------------------------------------------
log "Setting up $STACK_DIR..."
install -d -m 0750 "$STACK_DIR"
install -m 0640 "$SCRIPT_DIR/docker-compose.yml" "$STACK_DIR/docker-compose.yml"
install -m 0640 "$SCRIPT_DIR/Caddyfile" "$STACK_DIR/Caddyfile"
# .env
set_env() { # <KEY> <value>: update KEY in .env, or append if absent
# The value goes through the ENVIRONMENT, never interpolated into a sed
# script. Interpolating it corrupts any value containing & (sed expands it to
# the whole match) and aborts the run on one containing the s||| delimiter --
# which is reachable for an OIDC secret, a password or a URL query string.
local key="$1" val="$2" tmp
if [[ ! -f "$ENV_FILE" ]]; then
printf '%s=%s
' "$key" "$val" >> "$ENV_FILE"
return 0
fi
tmp="$(mktemp)"
_SE_KEY="$key" _SE_VAL="$val" awk '
BEGIN { k = ENVIRON["_SE_KEY"]; v = ENVIRON["_SE_VAL"]; seen = 0 }
!seen && index($0, k "=") == 1 { print k "=" v; seen = 1; next }
{ print }
END { if (!seen) print k "=" v }
' "$ENV_FILE" > "$tmp"
cat "$tmp" > "$ENV_FILE" # rewrite in place: keeps the original mode/owner
rm -f "$tmp"
}
if [[ ! -f "$ENV_FILE" ]]; then
log "Seeding $ENV_FILE..."
install -m 0600 "$SCRIPT_DIR/.env.example" "$ENV_FILE"
sed -i \
-e "s|^HEADSCALE_DOMAIN=.*|HEADSCALE_DOMAIN=${HEADSCALE_DOMAIN}|" \
-e "s|^ACME_EMAIL=.*|ACME_EMAIL=${ACME_EMAIL}|" \
-e "s|^TAILNET_DOMAIN=.*|TAILNET_DOMAIN=${TAILNET_DOMAIN}|" \
-e "s|^POCKETID_DOMAIN=.*|POCKETID_DOMAIN=${POCKETID_DOMAIN}|" \
-e "s|^OIDC_CLIENT_ID=.*|OIDC_CLIENT_ID=${OIDC_CLIENT_ID}|" \
-e "s|^OIDC_CLIENT_SECRET=.*|OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET}|" \
-e "s|^HEADPLANE_COOKIE_SECRET=.*|HEADPLANE_COOKIE_SECRET=$(openssl rand -hex 16)|" \
-e "s|^HEADPLANE_OIDC_CLIENT_ID=.*|HEADPLANE_OIDC_CLIENT_ID=${HEADPLANE_OIDC_CLIENT_ID}|" \
-e "s|^HEADPLANE_OIDC_CLIENT_SECRET=.*|HEADPLANE_OIDC_CLIENT_SECRET=${HEADPLANE_OIDC_CLIENT_SECRET}|" \
"$ENV_FILE"
else
# Keep the file, but absorb what was passed this run -- otherwise the
# `. "$ENV_FILE"` below reverts it and the run deploys the old values while
# reporting success. A rotated OIDC_CLIENT_SECRET silently not taking effect
# is the case that matters. Keys not passed are left untouched.
log ".env exists; keeping it (only values passed this run are updated)."
for _k in ${KEYS_FROM_ENV[@]+"${KEYS_FROM_ENV[@]}"}; do
_cur=$(sed -n "s/^${_k}=//p" "$ENV_FILE" | tail -n1)
if [[ "$_cur" == "${!_k}" ]]; then continue; fi
set_env "$_k" "${!_k}"
case "$_k" in
*SECRET*)
log " ${_k}: updated (value changed; not echoed)" ;;
HEADSCALE_DOMAIN)
warn "HEADSCALE_DOMAIN ${_cur} -> ${!_k}: Caddy will request a new Let's Encrypt cert, and the pocket-id redirect URI must become https://${!_k}/oidc/callback" ;;
*)
log " ${_k}: ${_cur:-<unset>} -> ${!_k:-<empty>}" ;;
esac
done
fi
# Validate
missing=()
for var in HEADSCALE_DOMAIN ACME_EMAIL TAILNET_DOMAIN POCKETID_DOMAIN \
OIDC_CLIENT_ID OIDC_CLIENT_SECRET; do
grep -E "^${var}=.+$" "$ENV_FILE" >/dev/null || missing+=("$var")
done
(( ${#missing[@]} == 0 )) || die "Missing values in $ENV_FILE: ${missing[*]}"
# Re-read final values from .env so config.yaml substitution sees what's
# actually deployed (not whatever was passed in the environment).
# shellcheck disable=SC1090
set -a; . "$ENV_FILE"; set +a
# config.yaml: regenerate every run from the template so changes to .env
# propagate. Secrets in config.yaml are derived from .env, not stored
# independently, so this is safe.
log "Generating config.yaml from template..."
install -m 0600 "$SCRIPT_DIR/config.yaml" "$STACK_DIR/config.yaml"
# ACL policy: install the default on first run only, so your edits survive
# re-deploys. headscale reads it on start (policy mode = file); after editing,
# validate with `headscale policy check` and `docker compose restart headscale`.
if [[ ! -f "$STACK_DIR/policy.hujson" ]]; then
log "Installing default ACL policy -> $STACK_DIR/policy.hujson"
install -m 0640 "$SCRIPT_DIR/policy.hujson" "$STACK_DIR/policy.hujson"
else
log "ACL policy exists; leaving $STACK_DIR/policy.hujson alone."
fi
# Render headplane.yaml now so the bind mount exists (api_key may still be
# empty on first run; it's re-rendered after the key is minted below).
render_headplane_config
# Escape replacement values for sed (handle slashes/&). Headscale URLs and
# OIDC secrets can contain those.
sed_escape() { printf '%s' "$1" | sed -e 's/[\/&]/\\&/g'; }
sed -i \
-e "s/__HEADSCALE_DOMAIN__/$(sed_escape "$HEADSCALE_DOMAIN")/g" \
-e "s/__TAILNET_DOMAIN__/$(sed_escape "$TAILNET_DOMAIN")/g" \
-e "s/__POCKETID_DOMAIN__/$(sed_escape "$POCKETID_DOMAIN")/g" \
-e "s/__OIDC_CLIENT_ID__/$(sed_escape "$OIDC_CLIENT_ID")/g" \
-e "s/__OIDC_CLIENT_SECRET__/$(sed_escape "$OIDC_CLIENT_SECRET")/g" \
"$STACK_DIR/config.yaml"
# Final sanity check (ignore comment lines so documentation tokens don't
# trip a false positive).
grep -v '^[[:space:]]*#' "$STACK_DIR/config.yaml" | grep -q '__[A-Za-z_]*__' \
&& warn "config.yaml still has un-substituted __PLACEHOLDER__ markers!" \
|| true
# ----------------------------------------------------------------------------
# Bring up the stack
# ----------------------------------------------------------------------------
if [[ "$FORCE" != "1" ]]; then
cat <<EOF
About to pull images and start the stack from $STACK_DIR.
Caddy will request a Let's Encrypt cert for ${HEADSCALE_DOMAIN}. DNS for
that name must already point at this host, and ports 80/443 must be
reachable from the internet.
In pocket-id, the OIDC client must have this redirect URI registered:
https://${HEADSCALE_DOMAIN}/oidc/callback
Continue? [y/N]
EOF
read -r ans
[[ "${ans,,}" == "y" || "${ans,,}" == "yes" ]] || { warn "Aborted."; exit 0; }
fi
cd "$STACK_DIR"
log "Pulling images..."
docker compose pull
# Start headscale + caddy first; headplane needs a headscale API key, which we
# can only mint once headscale is up.
log "Starting headscale + caddy..."
docker compose up -d --remove-orphans headscale caddy
log "Waiting for headscale to become healthy (up to 120s)..."
deadline=$(( $(date +%s) + 120 ))
while (( $(date +%s) < deadline )); do
h=$(docker compose ps --format '{{.Service}} {{.Health}}' 2>/dev/null | awk '$1=="headscale"{print $2}')
[[ "$h" == "healthy" ]] && { log "headscale healthy."; break; }
sleep 5
done
# ----------------------------------------------------------------------------
# Headplane: mint a headscale API key on first deploy (idempotent -- stored in
# .env), render its config, then bring the UI up.
# ----------------------------------------------------------------------------
if ! grep -qE '^HEADPLANE_HS_API_KEY=.+' "$ENV_FILE"; then
log "Creating a headscale API key for headplane..."
HP_KEY=$(docker compose exec -T headscale headscale apikeys create --expiration 999d 2>/dev/null \
| tr -d '\r' | tail -n1)
if [[ -n "$HP_KEY" ]]; then
sed -i "s|^HEADPLANE_HS_API_KEY=.*|HEADPLANE_HS_API_KEY=${HP_KEY}|" "$ENV_FILE"
log "Stored headplane API key in .env."
else
warn "Could not mint a headscale API key; set HEADPLANE_HS_API_KEY in $ENV_FILE and re-run."
fi
fi
# Re-read .env (now with the API key) and render headplane's config.
set -a; . "$ENV_FILE"; set +a
render_headplane_config
log "Starting headplane + remaining services..."
docker compose up -d --remove-orphans
# ----------------------------------------------------------------------------
# Wait for health
# ----------------------------------------------------------------------------
log "Waiting for services to become healthy (up to 120s)..."
deadline=$(( $(date +%s) + 120 ))
while (( $(date +%s) < deadline )); do
status=$(docker compose ps --format '{{.Service}} {{.Health}}' 2>/dev/null || true)
unhealthy=$(echo "$status" | awk '$2 != "healthy" && $2 != "" {print $1}')
if [[ -z "$unhealthy" && -n "$status" ]]; then
log "All services healthy."
break
fi
sleep 5
done
# Convenience: a `headscale` command on the host that runs the CLI inside the
# container from ANY directory (plain `docker compose` needs to be run from
# $STACK_DIR). STACK_DIR is baked in; "$@" is passed through.
cat > /usr/local/bin/headscale <<EOF
#!/bin/sh
# Auto-generated by headscale deploy.sh -- runs the headscale CLI in-container.
exec docker compose -f "$STACK_DIR/docker-compose.yml" exec headscale headscale "\$@"
EOF
chmod +x /usr/local/bin/headscale
log "Installed /usr/local/bin/headscale (runs the CLI inside the container)."
echo
log "Stack status:"
docker compose ps
echo
cat <<EOF
================================================================
DEPLOYED
Headscale: https://${HEADSCALE_DOMAIN}
Headplane: https://${HEADSCALE_DOMAIN}/admin (web UI)
Tailnet: ${TAILNET_DOMAIN} (MagicDNS suffix)
OIDC: https://${POCKETID_DOMAIN}
Stack dir: ${STACK_DIR}
OIDC redirect/callback URIs -- register THESE in pocket-id:
headscale: https://${HEADSCALE_DOMAIN}/oidc/callback
headplane: https://${HEADSCALE_DOMAIN}/admin/oidc/callback (2nd OIDC client)
ACL policy: ${STACK_DIR}/policy.hujson
headscale policy check # validate after editing
cd ${STACK_DIR} && docker compose restart headscale # apply changes
Headscale CLI -- use the installed wrapper from anywhere:
headscale users create alice
headscale users list
headscale nodes list
headscale --help
# (equivalent: cd $STACK_DIR && docker compose exec headscale headscale ...)
Tailscale client login:
tailscale up --login-server https://${HEADSCALE_DOMAIN}
# Browser opens, OIDC flow via pocket-id, node registers.
Manage (run from $STACK_DIR):
cd $STACK_DIR
docker compose logs -f
docker compose pull && docker compose up -d # update
docker compose down # stop, keep volumes
docker compose down -v # stop, WIPE data
Or just re-run this script -- it's idempotent.
================================================================
EOF
# IMPORTANT: do not put any code below this exit. Everything after the
# __ARCHIVE_BELOW__ marker is the embedded tar.gz payload (base64).
exit 0
__ARCHIVE_BELOW__
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
dVmXdVmX/0X5H8de64YAUAAA