Skip to content

Instantly share code, notes, and snippets.

@RichardTMiles
Last active September 12, 2025 09:23
Show Gist options
  • Select an option

  • Save RichardTMiles/02e4fb243aabfc3c99e4b20eb412c121 to your computer and use it in GitHub Desktop.

Select an option

Save RichardTMiles/02e4fb243aabfc3c99e4b20eb412c121 to your computer and use it in GitHub Desktop.
Setup Github Actions on a Proxmox Server
#!/usr/bin/env bash
# Proxmox "GOD SCRIPT" — idempotent, hands-free GitHub runner VM
# - Ensures storage/snippets
# - Downloads Ubuntu cloud image
# - Creates/repairs VM (disk flags, cloud-init, boot order)
# - Resizes VM disk and auto-grows root FS on first boot
# - Injects SSH key
# - Writes cloud-init user-data that installs qemu-guest-agent + GitHub Actions runner
# - Starts VM and verifies runner service via guest agent
set -uo pipefail
# ===== CONFIG (override via env) =====
VMID="${VMID:-900}"
VMNAME="${VMNAME:-github-runner}"
MEM_MB="${MEM_MB:-4096}"
CORES="${CORES:-2}"
SOCKETS="${SOCKETS:-1}"
CPU_TYPE="${CPU_TYPE:-host}"
BRIDGE="${BRIDGE:-vmbr0}"
STORAGE="${STORAGE:-local-lvm}"
CLOUDINIT_CTLR="${CLOUDINIT_CTLR:-ide2}"
SCSIHW="${SCSIHW:-virtio-scsi-pci}"
OS_TYPE="${OS_TYPE:-l26}"
AGENT_ENABLED="${AGENT_ENABLED:-1}"
CI_USER="${CI_USER:-ubuntu}"
# Disk size to *target* (not increment) before first boot
DISK_SIZE="${DISK_SIZE:-20G}"
# Ubuntu cloud image (jammy). Switch to noble by overriding IMG_URL/FILE.
IMG_URL="${IMG_URL:-https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img}"
IMG_FILE="${IMG_FILE:-/var/lib/vz/template/iso/jammy-server-cloudimg-amd64.img}"
# Networking (leave IP_CIDR empty for DHCP)
IP_CIDR="${IP_CIDR:-}" # e.g. 192.168.1.200/24
GW_IP="${GW_IP:-}" # e.g. 192.168.1.1
NAMESERVER="${NAMESERVER:-}" # e.g. 1.1.1.1
# SSH key
SSH_PUBKEY_FILE="${SSH_PUBKEY_FILE:-$HOME/.ssh/id_rsa.pub}"
# GitHub Runner (use fresh, short-lived registration token)
GH_RUNNER_URL="${GH_RUNNER_URL:-}" # https://github.com/<org> OR https://github.com/<owner>/<repo>
GH_RUNNER_TOKEN="${GH_RUNNER_TOKEN:-}" # registration token (NOT a PAT)
GH_RUNNER_LABELS="${GH_RUNNER_LABELS:-proxmox,r640}"
GH_RUNNER_DIR="${GH_RUNNER_DIR:-/opt/actions-runner}"
# Cloud-init snippet
SNIPPETS_DIR="${SNIPPETS_DIR:-/var/lib/vz/snippets}"
CI_SNIPPET_NAME="${CI_SNIPPET_NAME:-user-data-${VMID}.yaml}"
CI_SNIPPET_PATH="${SNIPPETS_DIR}/${CI_SNIPPET_NAME}"
# Verification / timeouts
VERIFY_RUNNER="${VERIFY_RUNNER:-1}" # 1 = wait for service
VERIFY_TIMEOUT_SEC="${VERIFY_TIMEOUT_SEC:-180}"
# ===== Helpers =====
log(){ printf "\033[1;36m[%s]\033[0m %s\n" "$(date +%H:%M:%S)" "$*"; }
warn(){ printf "\033[1;33m[WARN]\033[0m %s\n" "$*"; }
err(){ printf "\033[1;31m[ERR ]\033[0m %s\n" "$*" >&2; }
qm_exists(){ qm status "$1" &>/dev/null; }
qm_running(){ [[ "$(qm status "$1" 2>/dev/null | awk '{print $2}')" == running ]]; }
disk_attached(){ qm config "$1" 2>/dev/null | grep -q '^scsi0:'; }
ci_attached(){ qm config "$1" 2>/dev/null | grep -q "^${CLOUDINIT_CTLR}: .*cloudinit"; }
boot_ok(){ qm config "$1" 2>/dev/null | grep -q '^boot: .*scsi0'; }
image_present(){ [[ -f "$IMG_FILE" ]]; }
storage_has_disk(){ pvesm list "$STORAGE" 2>/dev/null | awk '{print $1}' | grep -q "^$STORAGE:vm-${VMID}-disk-0$"; }
needs_discard_flag(){
local l; l="$(qm config "$1" 2>/dev/null | sed -n 's/^scsi0: //p')"
[[ -n "$l" ]] || return 1
echo "$l" | grep -q 'discard=on' && echo "$l" | grep -q 'ssd=1' && return 1
return 0
}
ensure_dir(){ [[ -d "$1" ]] || { log "mkdir $1"; mkdir -p "$1" || { err "mkdir $1 failed"; exit 1; }; }; }
# ===== Steps =====
ensure_snippets(){
# Ensure local storage allows snippets
if ! pvesm status | awk '{print $1" "$2" "$3}' | grep -q '^local '; then
warn "Storage 'local' not found; cicustom may fail if no snippets storage is available."
fi
pvesm set local --content iso,vztmpl,backup,rootdir,images,snippets >/dev/null 2>&1 || true
}
download_image(){
if image_present; then log "Cloud image present: $IMG_FILE"; return; fi
ensure_dir "$(dirname "$IMG_FILE")"; log "Downloading cloud image → $IMG_FILE";
command -v wget >/dev/null || { err "wget missing (apt-get install -y wget)"; exit 1; }
wget -O "$IMG_FILE" "$IMG_URL" || { err "download failed"; exit 1; }
}
write_ci_snippet(){
ensure_dir "$SNIPPETS_DIR"
cat >"$CI_SNIPPET_PATH" <<EOF
#cloud-config
users:
- default
- name: ${CI_USER}
sudo: ALL=(ALL) NOPASSWD:ALL
shell: /bin/bash
ssh_authorized_keys:
$( if [[ -f "$SSH_PUBKEY_FILE" ]]; then sed 's/^/ - /' "$SSH_PUBKEY_FILE"; else echo " -"; fi )
package_update: true
packages: [curl, tar, ca-certificates, qemu-guest-agent, cloud-guest-utils]
# Ensure the guest grows its root filesystem to the resized virtual disk
growpart: true
resize_rootfs: true
# Extra belt-and-suspenders in case the distro module skips:
bootcmd:
- [ sh, -xc, "mkdir -p /run/growtmp && mount -t tmpfs -o size=64m tmpfs /run/growtmp || true" ]
- [ sh, -xc, "TMPDIR=/run/growtmp growpart /dev/sda 1 || true" ]
- [ sh, -xc, "resize2fs /dev/sda1 || true" ]
write_files:
- path: /usr/local/bin/gh-runner-install.sh
permissions: '0755'
owner: root:root
content: |
#!/usr/bin/env bash
set -euo pipefail
# Allow env overrides at runtime
URL="\${URL:-${GH_RUNNER_URL}}"
TOKEN="\${TOKEN:-${GH_RUNNER_TOKEN}}"
LABELS="\${LABELS:-${GH_RUNNER_LABELS}}"
RUNNER_DIR="\${RUNNER_DIR:-${GH_RUNNER_DIR}}"
USER="\${USER:-${CI_USER}}"
if [[ -z "\${URL}" || -z "\${TOKEN}" ]]; then
echo "[WARN] Missing GH vars; skipping runner install"; exit 0; fi
# Map architecture
case "\$(uname -m)" in
x86_64) runner_arch="x64" ;;
aarch64|arm64) runner_arch="arm64" ;;
*) echo "[ERR] Unsupported arch: \$(uname -m)"; exit 1 ;;
esac
mkdir -p "\${RUNNER_DIR}"; chown -R "\${USER}:\${USER}" "\${RUNNER_DIR}"; cd "\${RUNNER_DIR}"
# Resolve latest runner without jq (redirect), with API fallback
latest_url="\$(curl -ILs -o /dev/null -w '%{url_effective}' https://github.com/actions/runner/releases/latest)"
version="\${latest_url##*/tag/}"
DL_URL="https://github.com/actions/runner/releases/download/\${version}/actions-runner-linux-\${runner_arch}-\${version#v}.tar.gz"
if [[ -z "\${version}" || -z "\${DL_URL}" ]]; then
DL_URL="\$(curl -fsSL -H 'User-Agent: curl' -H 'Accept: application/vnd.github+json' \
https://api.github.com/repos/actions/runner/releases/latest \
| grep -o "https://[^\\\"]*actions-runner-linux-\${runner_arch}-[0-9.]*\\.tar\\.gz" | head -n1)"
fi
[ -n "\${DL_URL}" ] || { echo "[ERR] Could not resolve runner URL"; exit 1; }
# Download & extract as target user
su - "\${USER}" -c "cd '\${RUNNER_DIR}' && curl -fsSL -o actions-runner.tar.gz '\${DL_URL}' && tar xzf actions-runner.tar.gz && rm actions-runner.tar.gz"
# Optional native deps
if [[ -f ./bin/installdependencies.sh ]]; then ./bin/installdependencies.sh || true; fi
# Register service
su - "\${USER}" -c "cd '\${RUNNER_DIR}' && ./config.sh --unattended --replace --url '\${URL}' --token '\${TOKEN}' --labels '\${LABELS}' --name \$(hostname)"
./svc.sh install; ./svc.sh start
runcmd:
- [ systemctl, start, qemu-guest-agent ] # unit is static; start is enough
- [ bash, -lc, "/usr/local/bin/gh-runner-install.sh || true" ] # don't mark boot failed on transient issues
EOF
log "Wrote cloud-init snippet: $CI_SNIPPET_PATH"
}
create_or_update_vm(){
if qm_exists "$VMID"; then
log "VM $VMID exists; ensuring settings"
qm set "$VMID" --name "$VMNAME" --memory "$MEM_MB" --cores "$CORES" --sockets "$SOCKETS" --cpu "$CPU_TYPE" >/dev/null || true
qm set "$VMID" --scsihw "$SCSIHW" >/dev/null || true
qm set "$VMID" --agent enabled=${AGENT_ENABLED} >/dev/null || true
qm set "$VMID" --net0 "virtio,bridge=${BRIDGE}" >/dev/null || true
else
log "Creating VM $VMID ($VMNAME)"
qm create "$VMID" --name "$VMNAME" --memory "$MEM_MB" --cores "$CORES" --sockets "$SOCKETS" --cpu "$CPU_TYPE" \
--net0 "virtio,bridge=${BRIDGE}" --scsihw "$SCSIHW" --${CLOUDINIT_CTLR} "${STORAGE}:cloudinit" \
--serial0 socket --vga serial0 --ostype "$OS_TYPE" --agent enabled=${AGENT_ENABLED} || { err "qm create failed"; exit 1; }
fi
# Attach snippet
qm set "$VMID" --cicustom "user=local:snippets/$(basename "$CI_SNIPPET_PATH")" >/dev/null || warn "Could not set cicustom; ensure 'local' allows snippets"
}
import_and_attach_disk(){
if ! storage_has_disk; then
log "Importing cloud image as VM disk into $STORAGE"
qm importdisk "$VMID" "$IMG_FILE" "$STORAGE" || { err "importdisk failed"; exit 1; }
else
log "Disk already imported"
fi
local disk_path="${STORAGE}:vm-${VMID}-disk-0"
if disk_attached "$VMID"; then
if needs_discard_flag "$VMID"; then
log "Updating scsi0 flags"
qm set "$VMID" --scsi0 "${disk_path},discard=on,ssd=1" || { err "set scsi0 failed"; exit 1; }
else
log "scsi0 OK"
fi
else
log "Attaching scsi0"
qm set "$VMID" --scsi0 "${disk_path},discard=on,ssd=1" || { err "attach scsi0 failed"; exit 1; }
fi
# Grow virtual disk to target size BEFORE first boot
log "Resizing scsi0 to ${DISK_SIZE}"
qm resize "$VMID" scsi0 "${DISK_SIZE}" || warn "qm resize failed (check storage type/permissions)"
}
configure_network_and_boot(){
if [[ -n "$IP_CIDR" ]]; then
local ipcfg="ip=${IP_CIDR}"; [[ -n "$GW_IP" ]] && ipcfg+=",gw=${GW_IP}"
log "Static net: $ipcfg"; qm set "$VMID" --ipconfig0 "$ipcfg" >/dev/null || warn "ipconfig0 failed"
else
log "DHCP net"; qm set "$VMID" --ipconfig0 "ip=dhcp" >/dev/null || warn "ipconfig0 failed"
fi
[[ -n "$NAMESERVER" ]] && qm set "$VMID" --nameserver "$NAMESERVER" >/dev/null || true
[[ -f "$SSH_PUBKEY_FILE" ]] && qm set "$VMID" --sshkeys "$SSH_PUBKEY_FILE" >/dev/null || warn "No SSH key at $SSH_PUBKEY_FILE"
boot_ok "$VMID" || qm set "$VMID" --boot "order=scsi0;${CLOUDINIT_CTLR}" >/dev/null || warn "boot order set failed"
}
force_first_boot(){
# Ensure cloud-init will run runner installation on next boot
log "Resetting cloud-init for fresh first-boot"
qm cloudinit reset "$VMID" >/dev/null 2>&1 || true
}
start_vm(){
if qm_running "$VMID"; then log "VM already running"; else log "Starting VM"; qm start "$VMID" || { err "qm start failed"; exit 1; }; fi
}
wait_for_runner(){
[[ "$VERIFY_RUNNER" == 1 ]] || { log "Skipping runner verification"; return; }
log "Waiting for guest agent and runner service (timeout ${VERIFY_TIMEOUT_SEC}s)"
local t=0 step=5
# 1) Wait for qemu-guest-agent to respond
while (( t < VERIFY_TIMEOUT_SEC )); do
if qm guest ping "$VMID" >/dev/null 2>&1; then
log "Guest agent is responsive"
break
fi
sleep "$step"; t=$((t+step))
done
if ! qm guest ping "$VMID" >/dev/null 2>&1; then
warn "Guest agent did not become ready in time"; return
fi
# 2) Wait for actions runner to be active (parse out-data without jq)
while (( t < VERIFY_TIMEOUT_SEC )); do
status="$(
qm guest exec "$VMID" --verbose -- bash -lc "systemctl is-active 'actions.runner*' || true" 2>/dev/null \
| sed -n 's/^out-data: //p' | tr -d '\0'
)"
if echo "$status" | grep -q '^active'; then
log "Runner service is ACTIVE"
return
fi
sleep "$step"; t=$((t+step))
done
warn "Runner service not confirmed within timeout.
Check inside VM:
systemctl status 'actions.runner*' --no-pager
tail -n +200 /var/log/cloud-init-output.log"
}
# ===== RUN =====
log "== GOD SCRIPT: ensure VM ${VMID} (${VMNAME}) =="
ensure_snippets
download_image
write_ci_snippet
create_or_update_vm
import_and_attach_disk
configure_network_and_boot
force_first_boot
start_vm
wait_for_runner
log "Done. View: qm config ${VMID}; console: qm terminal ${VMID}"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment