#!/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, .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:=}" 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() { if 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 } # ---------------------------------------------------------------------------- # 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 .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 } 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 ENV_FILE="$STACK_DIR/.env" 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}|" \ "$ENV_FILE" else log ".env exists; leaving it alone." 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" # 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 || 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 echo log "Stack status:" docker compose ps echo cat <