Skip to content

Instantly share code, notes, and snippets.

@kventil
Created July 16, 2026 14:26
Show Gist options
  • Select an option

  • Save kventil/a055d1963986217d1fa1d1ba6b059105 to your computer and use it in GitHub Desktop.

Select an option

Save kventil/a055d1963986217d1fa1d1ba6b059105 to your computer and use it in GitHub Desktop.
Headscale update script
#!/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