#!/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; existing .env is never overwritten. # 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 # (but secrets in .env are never touched once seeded). # # 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 : "${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" </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 </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 <