Created
July 16, 2026 14:26
-
-
Save kventil/a055d1963986217d1fa1d1ba6b059105 to your computer and use it in GitHub Desktop.
Headscale update script
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # | |
| # headscale-updater.sh | |
| # | |
| # Installs the latest headscale release (DEB package method, per | |
| # https://headscale.net/0.29.2/setup/install/official/) or upgrades an | |
| # existing installation STEPWISE through every minor version, as required by | |
| # https://headscale.net/0.29.2/setup/upgrade/: | |
| # | |
| # "It's required to update from one stable version to the next | |
| # (e.g. 0.26.0 -> 0.27.1 -> 0.28.0) without skipping minor versions. | |
| # You should always pick the latest available patch release." | |
| # | |
| # Per upgrade hop: stop service -> backup /etc/headscale + /var/lib/headscale | |
| # -> install DEB -> start service -> verify. | |
| # | |
| # Usage: | |
| # sudo ./headscale-updater.sh # interactive (pauses between hops) | |
| # sudo ./headscale-updater.sh -y # non-interactive, no pauses | |
| # sudo ./headscale-updater.sh --dry-run | |
| # | |
| # Requirements: Debian 12+/Ubuntu 22.04+, curl, jq, systemd. | |
| # NOTE: only supports the official DEB installation method and SQLite. | |
| # If you run PostgreSQL, back it up yourself (pg_dump) before running this. | |
| set -euo pipefail | |
| REPO="juanfont/headscale" | |
| API="https://api.github.com/repos/${REPO}/releases?per_page=100" | |
| CONFIG_DIR="/etc/headscale" | |
| DATA_DIR="/var/lib/headscale" | |
| ASSUME_YES=0 | |
| DRY_RUN=0 | |
| log() { printf '\033[1;32m==>\033[0m %s\n' "$*"; } | |
| warn() { printf '\033[1;33mWARN:\033[0m %s\n' "$*" >&2; } | |
| die() { printf '\033[1;31mERROR:\033[0m %s\n' "$*" >&2; exit 1; } | |
| for arg in "$@"; do | |
| case "$arg" in | |
| -y|--yes) ASSUME_YES=1 ;; | |
| --dry-run) DRY_RUN=1 ;; | |
| -h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; | |
| *) die "Unknown argument: $arg" ;; | |
| esac | |
| done | |
| # ---------------------------------------------------------------- preflight | |
| [[ $EUID -eq 0 ]] || die "Run as root (sudo)." | |
| command -v curl >/dev/null || die "curl is required." | |
| command -v jq >/dev/null || die "jq is required (apt install jq)." | |
| command -v dpkg >/dev/null || die "This script only supports Debian/Ubuntu (DEB packages)." | |
| command -v systemctl >/dev/null || die "systemd is required." | |
| ARCH="$(dpkg --print-architecture)" # amd64 / arm64 | |
| case "$ARCH" in | |
| amd64|arm64) ;; | |
| *) die "Unsupported architecture for official DEBs: $ARCH" ;; | |
| esac | |
| # ------------------------------------------------------- fetch release list | |
| log "Fetching stable releases from GitHub..." | |
| RELEASES="$( | |
| for page in 1 2 3; do | |
| curl -fsSL "${API}&page=${page}" | |
| done | jq -r '.[] | select(.prerelease == false and .draft == false) | .tag_name' \ | |
| | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \ | |
| | sed 's/^v//' \ | |
| | sort -uV | |
| )" | |
| [[ -n "$RELEASES" ]] || die "Could not fetch release list from GitHub API." | |
| LATEST="$(tail -n1 <<< "$RELEASES")" | |
| log "Latest stable release: v${LATEST}" | |
| minor_of() { cut -d. -f1-2 <<< "$1"; } | |
| # latest patch for a given major.minor | |
| latest_patch_of_minor() { | |
| grep -E "^$(sed 's/\./\\./g' <<< "$1")\." <<< "$RELEASES" | sort -V | tail -n1 | |
| } | |
| # ---------------------------------------------------- detect current version | |
| CURRENT="" | |
| if command -v headscale >/dev/null 2>&1; then | |
| # `headscale version` prints e.g. "v0.26.1" or "0.26.1" depending on version | |
| CURRENT="$(headscale version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1 || true)" | |
| fi | |
| if [[ -z "$CURRENT" ]] && dpkg -s headscale >/dev/null 2>&1; then | |
| CURRENT="$(dpkg-query -W -f='${Version}' headscale | grep -oE '^[0-9]+\.[0-9]+\.[0-9]+' || true)" | |
| fi | |
| # --------------------------------------------------------------- build plan | |
| declare -a PLAN=() | |
| if [[ -z "$CURRENT" ]]; then | |
| log "No existing headscale installation detected -> fresh install of v${LATEST}." | |
| PLAN=("$LATEST") | |
| FRESH_INSTALL=1 | |
| else | |
| FRESH_INSTALL=0 | |
| log "Installed version: v${CURRENT}" | |
| if [[ "$CURRENT" == "$LATEST" ]]; then | |
| log "Already on the latest release. Nothing to do." | |
| exit 0 | |
| fi | |
| if [[ "$(printf '%s\n%s\n' "$CURRENT" "$LATEST" | sort -V | tail -n1)" == "$CURRENT" ]]; then | |
| die "Installed version v${CURRENT} is NEWER than latest stable v${LATEST}. Refusing to downgrade." | |
| fi | |
| CUR_MINOR="$(minor_of "$CURRENT")" | |
| LATEST_MINOR="$(minor_of "$LATEST")" | |
| if [[ "$CUR_MINOR" == "$LATEST_MINOR" ]]; then | |
| # same minor -> direct patch bump | |
| PLAN=("$LATEST") | |
| else | |
| # one hop per minor version, latest patch each, up to and including latest | |
| while IFS= read -r m; do | |
| # only minors strictly greater than current minor | |
| if [[ "$(printf '%s\n%s\n' "$m" "$CUR_MINOR" | sort -V | tail -n1)" == "$m" && "$m" != "$CUR_MINOR" ]]; then | |
| PLAN+=("$(latest_patch_of_minor "$m")") | |
| fi | |
| done < <(cut -d. -f1-2 <<< "$RELEASES" | sort -uV) | |
| fi | |
| fi | |
| [[ ${#PLAN[@]} -gt 0 ]] || die "Internal error: empty upgrade plan." | |
| echo | |
| log "Planned steps:" | |
| prev="${CURRENT:-none}" | |
| for v in "${PLAN[@]}"; do | |
| echo " ${prev} -> ${v}" | |
| prev="$v" | |
| done | |
| echo | |
| if [[ $FRESH_INSTALL -eq 0 ]]; then | |
| warn "Read the release notes for EACH version above before proceeding:" | |
| warn " https://github.com/${REPO}/releases" | |
| warn "Minor versions frequently contain breaking config changes." | |
| fi | |
| if [[ $DRY_RUN -eq 1 ]]; then | |
| log "Dry run - stopping here." | |
| exit 0 | |
| fi | |
| if [[ $ASSUME_YES -eq 0 ]]; then | |
| read -r -p "Proceed? [y/N] " answer | |
| [[ "$answer" =~ ^[Yy]$ ]] || { log "Aborted."; exit 0; } | |
| fi | |
| # ------------------------------------------------------------------ helpers | |
| TMPDIR="$(mktemp -d)" | |
| trap 'rm -rf "$TMPDIR"' EXIT | |
| download_and_install() { | |
| local version="$1" | |
| local deb="${TMPDIR}/headscale_${version}_linux_${ARCH}.deb" | |
| local url="https://github.com/${REPO}/releases/download/v${version}/headscale_${version}_linux_${ARCH}.deb" | |
| log "Downloading ${url}" | |
| curl -fSL --retry 3 -o "$deb" "$url" | |
| log "Installing headscale v${version}" | |
| apt-get install -y --allow-downgrades "$deb" | |
| } | |
| backup_state() { | |
| local ts | |
| ts="$(date +%Y%m%d%H%M%S)" | |
| for dir in "$CONFIG_DIR" "$DATA_DIR"; do | |
| if [[ -d "$dir" ]]; then | |
| log "Backing up ${dir} -> ${dir}.backup-${ts}" | |
| cp -aR "$dir" "${dir}.backup-${ts}" | |
| fi | |
| done | |
| echo "$ts" | |
| } | |
| wait_for_active() { | |
| local tries=15 | |
| while (( tries-- > 0 )); do | |
| if systemctl is-active --quiet headscale; then | |
| return 0 | |
| fi | |
| sleep 2 | |
| done | |
| return 1 | |
| } | |
| # -------------------------------------------------------------- fresh install | |
| if [[ $FRESH_INSTALL -eq 1 ]]; then | |
| download_and_install "$LATEST" | |
| echo | |
| log "headscale v${LATEST} installed." | |
| log "Next steps (NOT automated, on purpose):" | |
| echo " 1. Edit the configuration: sudo nano ${CONFIG_DIR}/config.yaml" | |
| echo " (example: /usr/share/doc/headscale/examples/config-example.yaml)" | |
| echo " 2. Start and enable the service: sudo systemctl enable --now headscale" | |
| echo " 3. Verify: sudo systemctl status headscale" | |
| exit 0 | |
| fi | |
| # ------------------------------------------------------------- upgrade chain | |
| STEP=0 | |
| TOTAL=${#PLAN[@]} | |
| for version in "${PLAN[@]}"; do | |
| STEP=$((STEP + 1)) | |
| echo | |
| log "[${STEP}/${TOTAL}] Upgrading to v${version}" | |
| log "Stopping headscale..." | |
| systemctl stop headscale | |
| TS="$(backup_state)" | |
| download_and_install "$version" | |
| log "Starting headscale..." | |
| systemctl start headscale | |
| if ! wait_for_active; then | |
| systemctl status headscale --no-pager || true | |
| die "headscale failed to start on v${version}. | |
| Your data was backed up with suffix .backup-${TS}. | |
| Check 'journalctl -u headscale -e' - most likely a config change is needed. | |
| Compare your config with /usr/share/doc/headscale/examples/config-example.yaml" | |
| fi | |
| RUNNING="$(headscale version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1 || true)" | |
| log "Service is active, running v${RUNNING:-unknown}." | |
| if [[ $STEP -lt $TOTAL && $ASSUME_YES -eq 0 ]]; then | |
| echo | |
| warn "Before the next hop: check 'journalctl -u headscale -e' and your" | |
| warn "config against the v${version} release notes." | |
| read -r -p "Continue to next version? [y/N] " answer | |
| [[ "$answer" =~ ^[Yy]$ ]] || { log "Stopped after v${version}. Re-run the script later to continue."; exit 0; } | |
| fi | |
| done | |
| echo | |
| log "Done. headscale is at v${LATEST}." | |
| log "Backups are in ${CONFIG_DIR}.backup-* and ${DATA_DIR}.backup-* - delete them once you're happy." |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment