Skip to content

Instantly share code, notes, and snippets.

@terry90
Last active May 6, 2026 12:42
Show Gist options
  • Select an option

  • Save terry90/3edf816e4cd7100367947b46bd106365 to your computer and use it in GitHub Desktop.

Select an option

Save terry90/3edf816e4cd7100367947b46bd106365 to your computer and use it in GitHub Desktop.
Install gitlab runner on LXC
set -euo pipefail
# ---------- config (override via env) ----------
GITLAB_URL="${GITLAB_URL:-}"
RUNNER_TOKEN="${RUNNER_TOKEN:-}" # new flow: glrt-...
REGISTRATION_TOKEN="${REGISTRATION_TOKEN:-}" # legacy flow
RUNNER_NAME="${RUNNER_NAME:-$(hostname)-runner}"
RUNNER_TAGS="${RUNNER_TAGS:-lxc,docker}" # legacy flow only
RUNNER_EXECUTOR="${RUNNER_EXECUTOR:-docker}" # docker | shell
RUNNER_DEFAULT_IMAGE="${RUNNER_DEFAULT_IMAGE:-alpine:latest}"
INSTALL_DOCKER="${INSTALL_DOCKER:-yes}" # yes | no
CONCURRENT="${CONCURRENT:-2}"
FORCE_REREGISTER="${FORCE_REREGISTER:-no}" # yes to drop existing & re-register
# Docker cleanup (systemd timer)
INSTALL_CLEANUP="${INSTALL_CLEANUP:-yes}" # yes | no
CLEANUP_SCHEDULE="${CLEANUP_SCHEDULE:-daily}" # any OnCalendar value, e.g. 'daily', 'weekly', '03:30'
CLEANUP_KEEP_HOURS="${CLEANUP_KEEP_HOURS:-72}" # only prune things unused for N hours
CLEANUP_DISK_THRESHOLD="${CLEANUP_DISK_THRESHOLD:-80}" # also force prune if / use% > this
CONFIG_FILE="/etc/gitlab-runner/config.toml"
# ---------- helpers ----------
log() { printf '\033[1;32m[+]\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[!]\033[0m %s\n' "$*" >&2; }
err() { printf '\033[1;31m[x]\033[0m %s\n' "$*" >&2; exit 1; }
[[ $EUID -eq 0 ]] || err "Run as root."
# ---------- detect distro ----------
. /etc/os-release
case "${ID:-}" in
debian|ubuntu) : ;;
*) err "Unsupported distro: ${ID:-unknown}. Use a Debian/Ubuntu LXC template." ;;
esac
log "Detected ${PRETTY_NAME}"
export DEBIAN_FRONTEND=noninteractive
# ---------- prerequisites ----------
log "Ensuring prerequisites..."
apt-get update -y
apt-get install -y curl ca-certificates gnupg lsb-release apt-transport-https debian-archive-keyring
# ---------- Docker (optional) ----------
if [[ "$INSTALL_DOCKER" == "yes" && "$RUNNER_EXECUTOR" == "docker" ]]; then
if ! command -v docker >/dev/null 2>&1; then
log "Installing Docker CE..."
install -m 0755 -d /etc/apt/keyrings
if [[ ! -f /etc/apt/keyrings/docker.gpg ]]; then
curl -fsSL "https://download.docker.com/linux/${ID}/gpg" \
| gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
fi
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/${ID} $(. /etc/os-release && echo "${VERSION_CODENAME}") stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update -y
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
systemctl enable --now docker
else
log "Docker already installed: $(docker --version)"
fi
if ! docker info >/dev/null 2>&1; then
warn "Docker is installed but 'docker info' failed."
warn "On the Proxmox host, ensure the LXC has features: nesting=1,keyctl=1"
warn " pct set <vmid> -features nesting=1,keyctl=1"
warn "Then restart the container."
fi
fi
# ---------- gitlab-runner package ----------
if ! command -v gitlab-runner >/dev/null 2>&1; then
log "Adding GitLab Runner apt repository..."
if [[ ! -f /etc/apt/sources.list.d/runner_gitlab-runner.list ]]; then
curl -fsSL "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh" | bash
fi
log "Installing gitlab-runner..."
apt-get install -y gitlab-runner
else
log "gitlab-runner already installed: $(gitlab-runner --version | head -n1)"
fi
systemctl enable --now gitlab-runner
# ---------- prompt for URL ----------
if [[ -z "$GITLAB_URL" ]]; then
read -rp "GitLab URL [https://gitlab.com/]: " GITLAB_URL
GITLAB_URL="${GITLAB_URL:-https://gitlab.com/}"
fi
# ---------- clean up stale runners ----------
# `verify --delete` removes any [[runners]] entry that GitLab no longer knows
# about (e.g. you deleted it in the UI). This keeps config.toml tidy across runs.
log "Verifying existing runners against GitLab..."
gitlab-runner verify --delete || true
# ---------- decide whether to register ----------
NEED_REGISTER="yes"
if [[ "$FORCE_REREGISTER" == "yes" ]]; then
log "FORCE_REREGISTER=yes → unregistering any runner named '${RUNNER_NAME}'"
gitlab-runner unregister --name "$RUNNER_NAME" || true
elif gitlab-runner list 2>&1 | grep -qE "^${RUNNER_NAME}\b|Name=${RUNNER_NAME}\b"; then
log "A runner named '${RUNNER_NAME}' is already registered and verified — skipping registration."
log " (set FORCE_REREGISTER=yes to replace it)"
NEED_REGISTER="no"
fi
# ---------- choose registration mode (only if needed) ----------
if [[ "$NEED_REGISTER" == "yes" && -z "$RUNNER_TOKEN" && -z "$REGISTRATION_TOKEN" ]]; then
echo
echo "Choose registration mode:"
echo " 1) NEW flow — paste an authentication token (glrt-...) created in the GitLab UI"
echo " 2) LEGACY flow — paste a registration token (deprecated)"
read -rp "Mode [1]: " MODE
MODE="${MODE:-1}"
if [[ "$MODE" == "1" ]]; then
read -rsp "Authentication token (glrt-...): " RUNNER_TOKEN; echo
else
read -rsp "Registration token: " REGISTRATION_TOKEN; echo
fi
fi
# ---------- register ----------
if [[ "$NEED_REGISTER" == "yes" ]]; then
if [[ -n "$RUNNER_TOKEN" ]]; then
log "Registering '${RUNNER_NAME}' via authentication token (new flow)..."
REG_ARGS=(
--non-interactive
--url "$GITLAB_URL"
--token "$RUNNER_TOKEN"
--description "$RUNNER_NAME"
--executor "$RUNNER_EXECUTOR"
)
[[ "$RUNNER_EXECUTOR" == "docker" ]] && REG_ARGS+=( --docker-image "$RUNNER_DEFAULT_IMAGE" --docker-privileged=false )
gitlab-runner register "${REG_ARGS[@]}"
elif [[ -n "$REGISTRATION_TOKEN" ]]; then
warn "Using legacy registration-token flow (deprecated)."
log "Registering '${RUNNER_NAME}' (executor=${RUNNER_EXECUTOR}, tags=${RUNNER_TAGS})..."
REG_ARGS=(
--non-interactive
--url "$GITLAB_URL"
--registration-token "$REGISTRATION_TOKEN"
--description "$RUNNER_NAME"
--tag-list "$RUNNER_TAGS"
--executor "$RUNNER_EXECUTOR"
)
[[ "$RUNNER_EXECUTOR" == "docker" ]] && REG_ARGS+=( --docker-image "$RUNNER_DEFAULT_IMAGE" --docker-privileged=false )
gitlab-runner register "${REG_ARGS[@]}"
else
err "No token provided. Set RUNNER_TOKEN (new) or REGISTRATION_TOKEN (legacy)."
fi
fi
# ---------- patch config.toml (always, so this script can repair broken state) ----------
[[ -f "$CONFIG_FILE" ]] || err "Expected $CONFIG_FILE after install — something is wrong."
# Backup once per run so a bad edit can be rolled back.
cp -a "$CONFIG_FILE" "${CONFIG_FILE}.bak.$(date +%s)"
# concurrent
if grep -qE '^\s*concurrent\s*=' "$CONFIG_FILE"; then
sed -i -E "s|^\s*concurrent\s*=.*|concurrent = ${CONCURRENT}|" "$CONFIG_FILE"
else
# Prepend if somehow missing
sed -i "1i concurrent = ${CONCURRENT}" "$CONFIG_FILE"
fi
log "concurrent=${CONCURRENT}"
# volumes (only meaningful for docker executor)
if [[ "$RUNNER_EXECUTOR" == "docker" ]]; then
DESIRED_VOLUMES='["/cache", "/var/run/docker.sock:/var/run/docker.sock"]'
if grep -qE '^\s*volumes\s*=' "$CONFIG_FILE"; then
sed -i -E "s|^(\s*)volumes\s*=\s*\[.*\]|\1volumes = ${DESIRED_VOLUMES}|" "$CONFIG_FILE"
log "Updated volumes line in ${CONFIG_FILE}"
elif grep -qE '^\s*\[runners\.docker\]' "$CONFIG_FILE"; then
# Insert once, after the FIRST [runners.docker] header that has no volumes line below it.
# awk is more robust than sed here for multi-section files.
awk -v vols="$DESIRED_VOLUMES" '
BEGIN { added = 0 }
{
print
if (!added && $0 ~ /^[[:space:]]*\[runners\.docker\][[:space:]]*$/) {
print " volumes = " vols
added = 1
}
}
' "$CONFIG_FILE" > "${CONFIG_FILE}.new" && mv "${CONFIG_FILE}.new" "$CONFIG_FILE"
log "Inserted volumes line under [runners.docker]"
else
warn "No [runners.docker] section found — skipping volumes patch."
fi
warn "Docker socket is mounted into job containers — any job on this runner"
warn "effectively has root on the host. Only use with trusted projects."
fi
# ---------- docker cleanup timer ----------
if [[ "$INSTALL_CLEANUP" == "yes" && "$RUNNER_EXECUTOR" == "docker" ]]; then
log "Installing docker-cleanup systemd timer (schedule=${CLEANUP_SCHEDULE}, keep=${CLEANUP_KEEP_HOURS}h)..."
cat > /usr/local/sbin/gitlab-runner-docker-cleanup <<EOF
#!/usr/bin/env bash
# Prune unused Docker resources to keep the LXC disk healthy.
# Generated by install-gitlab-runner.sh — re-run that script to update.
set -euo pipefail
KEEP_HOURS=${CLEANUP_KEEP_HOURS}
DISK_THRESHOLD=${CLEANUP_DISK_THRESHOLD}
log() { printf '[docker-cleanup %s] %s\n' "\$(date -Is)" "\$*"; }
# Disk usage of the filesystem holding /var/lib/docker (falls back to /).
docker_root="\$(docker info --format '{{.DockerRootDir}}' 2>/dev/null || echo /var/lib/docker)"
[[ -d "\$docker_root" ]] || docker_root=/
USE_PCT="\$(df --output=pcent "\$docker_root" | tail -n1 | tr -dc '0-9')"
log "disk usage at \${docker_root}: \${USE_PCT}%"
# Always prune anything older than KEEP_HOURS.
log "pruning containers/networks/images/build cache older than \${KEEP_HOURS}h"
docker container prune -f --filter "until=\${KEEP_HOURS}h" || true
docker image prune -af --filter "until=\${KEEP_HOURS}h" || true
docker network prune -f --filter "until=\${KEEP_HOURS}h" || true
docker builder prune -af --filter "until=\${KEEP_HOURS}h" || true
# If still above threshold, do a more aggressive sweep (ignores 'until').
if [[ "\${USE_PCT:-0}" -gt "\${DISK_THRESHOLD}" ]]; then
log "above \${DISK_THRESHOLD}% — running aggressive prune"
docker system prune -af --volumes || true
fi
# Trim the runner's /cache volume if it grows huge (>5GB).
if docker volume inspect runner-cache >/dev/null 2>&1; then
size_kb="\$(docker run --rm -v runner-cache:/c alpine du -sk /c 2>/dev/null | awk '{print \$1}')"
if [[ -n "\${size_kb:-}" && "\$size_kb" -gt 5242880 ]]; then
log "runner-cache volume is \$((size_kb/1024))MB — clearing"
docker run --rm -v runner-cache:/c alpine sh -c 'rm -rf /c/* /c/.[!.]* 2>/dev/null || true'
fi
fi
log "done — disk now: \$(df -h "\$docker_root" | tail -n1)"
EOF
chmod +x /usr/local/sbin/gitlab-runner-docker-cleanup
cat > /etc/systemd/system/gitlab-runner-docker-cleanup.service <<EOF
[Unit]
Description=Prune unused Docker resources for GitLab Runner
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/gitlab-runner-docker-cleanup
Nice=10
IOSchedulingClass=idle
EOF
cat > /etc/systemd/system/gitlab-runner-docker-cleanup.timer <<EOF
[Unit]
Description=Run docker cleanup on a schedule
[Timer]
OnCalendar=${CLEANUP_SCHEDULE}
Persistent=true
RandomizedDelaySec=15min
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload
systemctl enable --now gitlab-runner-docker-cleanup.timer
log "Cleanup timer active. Run on demand with: systemctl start gitlab-runner-docker-cleanup"
fi
# ---------- restart & report ----------
systemctl restart gitlab-runner
log "Status:"
gitlab-runner status || true
gitlab-runner list || true
if [[ "$INSTALL_CLEANUP" == "yes" && "$RUNNER_EXECUTOR" == "docker" ]]; then
systemctl list-timers gitlab-runner-docker-cleanup.timer --no-pager || true
fi
log "Done. Verify in GitLab → CI/CD → Runners."
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment