Skip to content

Instantly share code, notes, and snippets.

@aarmea
Created August 1, 2026 13:55
Show Gist options
  • Select an option

  • Save aarmea/bcf80341558ec689be77e2be2a0c944d to your computer and use it in GitHub Desktop.

Select an option

Save aarmea/bcf80341558ec689be77e2be2a0c944d to your computer and use it in GitHub Desktop.
pin-kernel — pick an installed kernel and make it stick.

pin-kernel

Pick an installed Linux kernel on Ubuntu and make it stick: hold its packages so apt can't reap it, make it the GRUB default, and build its initramfs if one is missing.

Ubuntu makes it easy to boot an older kernel once and surprisingly fiddly to keep one. The menu entry indices shift every time a kernel is added or removed, /etc/default/grub gets replaced by package upgrades, and unattended-upgrades will happily autoremove the kernel you were relying on. This script does the three things that actually make a pin durable, in the order that works.

Tested on Ubuntu 24.04 and 26.04. Should be fine on Debian and Mint.


Install

sudo install -m755 pin-kernel /usr/local/sbin/pin-kernel

No dependencies beyond what a stock Ubuntu install already has: bash 4+, awk, dpkg-query, apt-mark, update-grub, and either dracut or initramfs-tools.


Usage

pin-kernel [OPTIONS] [KERNEL-VERSION]

KERNEL-VERSION is a full release (6.17.0-9-generic) or any unambiguous substring of one (6.17.0-9, -lowlatency). Omit it and you get a numbered picker.

See what's actually bootable

$ sudo pin-kernel -l
  #  kernel                       status
  1  6.17.0-9-generic             running, grub default
  2  6.17.0-5-generic
  3  6.14.0-27-generic            held, no initramfs

This lists only kernels GRUB has a working menu entry for and whose vmlinuz is still on disk — not every linux-image-* package dpkg remembers.

Rehearse before committing

$ sudo pin-kernel -n 6.14
==> Kernel:     6.14.0-27-generic
    menuentry:  gnulinux-advanced-2f9a…>gnulinux-6.14.0-27-generic-advanced-2f9a…
    packages:   linux-headers-6.14.0-27 linux-headers-6.14.0-27-generic
                linux-image-6.14.0-27-generic linux-modules-6.14.0-27-generic
    initramfs:  will build with dracut
    [dry-run] dracut --force /boot/initrd.img-6.14.0-27-generic 6.14.0-27-generic
    [dry-run] apt-mark hold linux-headers-6.14.0-27 …
    [dry-run] would write GRUB_DEFAULT='gnulinux-advanced-2f9a…>…'
==> Dry run complete — nothing was changed.

--dry-run and --list don't need root.

Do it

sudo pin-kernel 6.14.0-27-generic

Then reboot. Or test the choice without committing to it first:

sudo grub-reboot "gnulinux-advanced-…>gnulinux-6.14.0-27-generic-advanced-…"
sudo reboot

(the script prints the exact command at the end). A one-shot grub-reboot reverts by itself if the kernel doesn't come up, which is the safer way to try an unfamiliar kernel on a remote box.

Undo

sudo pin-kernel --revert

Removes the GRUB override, releases the holds it created, and regenerates grub.cfg. The newest installed kernel takes over again.


Options

Option Effect
-l, --list List bootable kernels and exit. No root needed.
-y, --yes Skip the confirmation prompt.
-n, --dry-run Print every action, change nothing. No root needed.
--no-hold Set the GRUB default but leave apt marks alone.
--no-grub Hold the packages but don't touch the boot default.
--rebuild-initrd Rebuild the initramfs even if one already exists.
--revert Undo a previous pin.
-h, --help Usage.

Exit status is 0 on success, 1 on any error (no match, ambiguous match, not root, no GRUB config, missing initramfs tooling).


What it does, and why in that order

1. Build the initramfs first. grub-mkconfig only writes an initrd line for images it can actually see. Regenerating the initramfs after update-grub gives you a menu entry that boots to a kernel panic. The script only does this when /boot/initrd.img-<version> is missing or zero-length, unless you force it. It probes for dracut and falls back to update-initramfs -c -k — relevant now that Ubuntu ships dracut on some images while older installs still use initramfs-tools.

2. Hold the packages. Everything ending in the full release (linux-image-…, linux-modules-…, linux-modules-extra-…, linux-headers-…) plus the flavour-independent linux-headers-<abi>. Each gets apt-mark manual as well as apt-mark hold: manual takes it out of the autoremove candidate pool, hold makes apt refuse to remove or upgrade it even if something tries.

3. Set the GRUB default, written to /etc/default/grub.d/99-pinned-kernel.cfg rather than into /etc/default/grub. Ubuntu's grub-mkconfig sources everything in that directory, and using a drop-in means a grub-common upgrade that replaces the main config file can't silently drop your pin. If the local grub-mkconfig turns out not to read the drop-in directory, the script says so and edits /etc/default/grub instead, after taking a timestamped backup.

The value written is a menu entry ID path, not an index:

GRUB_DEFAULT='gnulinux-advanced-2f9a…>gnulinux-6.14.0-27-generic-advanced-2f9a…'

Indices renumber whenever kernels come and go; entry titles are localized and change wording between releases. The IDs are stable and derive from the kernel version and root filesystem UUID.


Caveats

  • A pin is permanent until you revert it. New kernels will keep installing, and GRUB will keep ignoring them. That's the point, but it means you can sit on an unpatched kernel without noticing. Run pin-kernel -l occasionally.
  • Held packages block related upgrades. apt upgrade will report packages kept back once a newer kernel meta-package wants to pull in something your hold conflicts with. Expected, not broken.
  • systemd-boot systems aren't supported. If /boot/grub/grub.cfg doesn't exist the script exits and points you at bootctl set-default.
  • Hand-built kernels installed outside dpkg get the GRUB and initramfs steps, but the hold step warns and skips — there are no packages to hold.
  • Secure Boot: an unsigned kernel won't boot with Secure Boot on, and this script won't tell you that in advance. It only checks that the image exists.
  • Encrypted root / unusual storage: if you regenerate an initramfs with a different tool than the one that built the others, verify it includes your crypt and LVM modules before you rely on it.

Troubleshooting

The menu doesn't appear at boot, so I can't fall back. Set a visible menu in /etc/default/grub and rerun update-grub:

GRUB_TIMEOUT_STYLE=menu
GRUB_TIMEOUT=5

I pinned a kernel and it didn't take. Check what actually landed in the generated config:

grep -m1 '^set default' /boot/grub/grub.cfg
sudo grub-editenv list

A leftover saved_entry in grubenv from an earlier GRUB_DEFAULT=saved setup will override things. Clear it with sudo grub-editenv /boot/grub/grubenv unset saved_entry.

I'm locked out after a reboot. Boot the installer in live mode, mount the root filesystem, chroot in, and run pin-kernel --revert — or just delete /etc/default/grub.d/99-pinned-kernel.cfg and run update-grub.

#!/usr/bin/env bash
#
# pin-kernel — pick an installed kernel and make it stick.
#
# * enumerates kernels that GRUB can actually still boot
# * lets you choose interactively, or pass one on the command line
# * apt-mark hold + manual, so autoremove can't reap it
# * sets it as the GRUB default via a drop-in (no clobbering /etc/default/grub)
# * regenerates the initramfs with dracut or initramfs-tools if one is missing
#
# Tested on Ubuntu 24.04 and 26.04. Requires root.
set -euo pipefail
GRUB_CFG=/boot/grub/grub.cfg
GRUB_DEFAULT_FILE=/etc/default/grub
DROPIN_DIR=/etc/default/grub.d
DROPIN="$DROPIN_DIR/99-pinned-kernel.cfg"
MARKER='# managed by pin-kernel'
DRY_RUN=0
ASSUME_YES=0
DO_HOLD=1
DO_GRUB=1
FORCE_INITRAMFS=0
ACTION=pin
WANT=''
# ---------------------------------------------------------------- output ----
if [[ -t 1 ]]; then
C_BOLD=$'\e[1m'; C_DIM=$'\e[2m'; C_RED=$'\e[31m'
C_GRN=$'\e[32m'; C_YEL=$'\e[33m'; C_OFF=$'\e[0m'
else
C_BOLD=''; C_DIM=''; C_RED=''; C_GRN=''; C_YEL=''; C_OFF=''
fi
info() { printf '%s==>%s %s\n' "$C_GRN$C_BOLD" "$C_OFF" "$*"; }
warn() { printf '%s[!]%s %s\n' "$C_YEL" "$C_OFF" "$*" >&2; }
die() { printf '%s[x]%s %s\n' "$C_RED" "$C_OFF" "$*" >&2; exit 1; }
step() { printf ' %s\n' "$*"; }
# Run a command, or just describe it under --dry-run.
run() {
if (( DRY_RUN )); then
printf ' %s[dry-run]%s %s\n' "$C_DIM" "$C_OFF" "$*"
else
step "$*"
"$@"
fi
}
usage() {
cat <<'EOF'
Usage: pin-kernel [OPTIONS] [KERNEL-VERSION]
Pin an installed kernel: hold its packages, make it the GRUB default, and
build its initramfs if one is missing.
KERNEL-VERSION may be a full release ("6.17.0-9-generic") or any unambiguous
substring of one ("6.17.0-9", "-lowlatency"). Omit it to choose interactively.
Options:
-l, --list list bootable kernels and exit
-y, --yes don't ask for confirmation
-n, --dry-run show what would happen, change nothing
--no-hold skip the apt-mark hold step
--no-grub skip the GRUB default step
--rebuild-initrd rebuild the initramfs even if one already exists
--revert undo a previous pin (drop the GRUB override, unhold)
-h, --help this text
Examples:
sudo pin-kernel # interactive picker
sudo pin-kernel 6.17.0-9-generic # non-interactive
sudo pin-kernel -n 6.14 # rehearse it first
sudo pin-kernel --revert # back to normal
EOF
}
# ------------------------------------------------------------ arg parsing ---
while (( $# )); do
case "$1" in
-l|--list) ACTION=list ;;
-y|--yes) ASSUME_YES=1 ;;
-n|--dry-run) DRY_RUN=1 ;;
--no-hold) DO_HOLD=0 ;;
--no-grub) DO_GRUB=0 ;;
--rebuild-initrd) FORCE_INITRAMFS=1 ;;
--revert) ACTION=revert ;;
-h|--help) usage; exit 0 ;;
-*) die "unknown option: $1 (try --help)" ;;
*)
[[ -n $WANT ]] && die "only one kernel version may be given"
WANT=$1
;;
esac
shift
done
if [[ $ACTION != list ]] && (( ! DRY_RUN )) && (( EUID != 0 )); then
die "needs root — rerun with sudo (or use --dry-run / --list)"
fi
[[ -r $GRUB_CFG ]] || die "$GRUB_CFG not found. If this machine boots with
systemd-boot rather than GRUB, use 'bootctl set-default' instead."
# ------------------------------------------------------------ enumeration ---
# Parse grub.cfg into "version <TAB> menuentry-path <TAB> title".
# Handles both the nested "Advanced options" submenu and a flat menu
# (GRUB_DISABLE_SUBMENU=y). Recovery entries are ignored.
parse_grub_entries() {
awk -F"'" '
/^[[:space:]]*submenu / { sub_id = $4; next }
/^}/ { sub_id = ""; next }
/^[[:space:]]*menuentry / {
title = $2; id = $4
if (title ~ /recovery mode/) next
if (!match(title, /with Linux [^ ]+/)) next
ver = substr(title, RSTART + 11, RLENGTH - 11)
print ver "\t" (sub_id == "" ? id : sub_id ">" id) "\t" title
}
' "$GRUB_CFG"
}
declare -a VERSIONS=() ENTRY_IDS=()
declare -A SEEN=()
while IFS=$'\t' read -r ver entry _title; do
[[ -n $ver && -z ${SEEN[$ver]:-} ]] || continue
# Only offer kernels whose image is genuinely still on disk.
[[ -e /boot/vmlinuz-$ver ]] || continue
SEEN[$ver]=1
VERSIONS+=("$ver")
ENTRY_IDS+=("$entry")
done < <(parse_grub_entries)
(( ${#VERSIONS[@]} )) || die "no bootable kernels found in $GRUB_CFG"
RUNNING=$(uname -r)
HELD=$(apt-mark showhold 2>/dev/null || true)
current_default() {
local d=''
[[ -r $DROPIN ]] && d=$(awk -F= '/^GRUB_DEFAULT=/{print $2}' "$DROPIN" | tr -d "\"'")
if [[ -z $d && -r $GRUB_DEFAULT_FILE ]]; then
d=$(awk -F= '/^GRUB_DEFAULT=/{print $2}' "$GRUB_DEFAULT_FILE" | tr -d "\"'")
fi
printf '%s' "$d"
}
CURRENT_DEFAULT=$(current_default)
status_flags() {
local ver=$1 idx=$2 flags=()
[[ $ver == "$RUNNING" ]] && flags+=("running")
[[ -n $CURRENT_DEFAULT && ${ENTRY_IDS[$idx]} == "$CURRENT_DEFAULT" ]] && flags+=("grub default")
grep -qx "linux-image-$ver" <<<"$HELD" && flags+=("held")
[[ -s /boot/initrd.img-$ver ]] || flags+=("no initramfs")
local out='' f
for f in "${flags[@]}"; do out+="${out:+, }$f"; done
printf '%s' "$out"
}
list_kernels() {
printf '%s%3s %-28s %s%s\n' "$C_BOLD" '#' 'kernel' 'status' "$C_OFF"
local i
for i in "${!VERSIONS[@]}"; do
printf '%3d %-28s %s%s%s\n' \
$((i + 1)) "${VERSIONS[$i]}" "$C_DIM" "$(status_flags "${VERSIONS[$i]}" "$i")" "$C_OFF"
done
}
if [[ $ACTION == list ]]; then
list_kernels
exit 0
fi
# ------------------------------------------------------------- revert path --
if [[ $ACTION == revert ]]; then
pinned=''
if [[ -r $DROPIN ]]; then
pinned=$(awk '/^# kernel: /{print $3}' "$DROPIN")
info "Removing GRUB override $DROPIN"
run rm -f "$DROPIN"
else
warn "no $DROPIN — nothing to un-pin"
fi
if [[ -n $pinned ]]; then
mapfile -t held < <(grep -F -- "$pinned" <<<"$HELD" || true)
if (( ${#held[@]} )); then
info "Releasing holds on $pinned"
run apt-mark unhold "${held[@]}"
fi
fi
info "Regenerating GRUB config"
run update-grub
info "Done. The newest installed kernel will be booted again."
exit 0
fi
# --------------------------------------------------------------- selection --
choose_index() {
local i
if [[ -n $WANT ]]; then
local -a hits=()
for i in "${!VERSIONS[@]}"; do
[[ ${VERSIONS[$i]} == "$WANT" ]] && { printf '%s' "$i"; return; }
done
for i in "${!VERSIONS[@]}"; do
[[ ${VERSIONS[$i]} == *"$WANT"* ]] && hits+=("$i")
done
case ${#hits[@]} in
1) printf '%s' "${hits[0]}"; return ;;
0) die "no installed kernel matches '$WANT' (try --list)" ;;
*) { echo "'$WANT' is ambiguous:"
for i in "${hits[@]}"; do echo " ${VERSIONS[$i]}"; done
} >&2
exit 1 ;;
esac
fi
[[ -t 0 ]] || die "no kernel given and stdin is not a terminal"
list_kernels >&2
local reply
while :; do
read -rp "Select kernel [1-${#VERSIONS[@]}, q to quit]: " reply >&2 || exit 1
[[ $reply == [qQ] ]] && exit 0
if [[ $reply =~ ^[0-9]+$ ]] && (( reply >= 1 && reply <= ${#VERSIONS[@]} )); then
printf '%s' $((reply - 1)); return
fi
echo " not a valid choice." >&2
done
}
IDX=$(choose_index)
VER=${VERSIONS[$IDX]}
ENTRY=${ENTRY_IDS[$IDX]}
ABI=${VER%-*} # 6.17.0-9-generic -> 6.17.0-9
echo
info "Kernel: $C_BOLD$VER$C_OFF"
step "menuentry: $ENTRY"
# Packages belonging to this kernel: anything ending in the full release, plus
# the flavour-independent headers package.
mapfile -t PKGS < <(
dpkg-query -W -f='${binary:Package}\t${db:Status-Abbrev}\n' 'linux-*' 2>/dev/null \
| awk -F'\t' -v v="$VER" -v abi="$ABI" '
$2 ~ /^ii/ {
n = $1; sub(/:.*/, "", n)
if (n ~ ("-" v "$") || n == "linux-headers-" abi) print n
}' | sort -u
)
(( DO_HOLD )) && step "packages: ${PKGS[*]:-<none found>}"
NEED_INITRD=0
if (( FORCE_INITRAMFS )) || [[ ! -s /boot/initrd.img-$VER ]]; then
NEED_INITRD=1
if command -v dracut >/dev/null 2>&1; then
INITRD_TOOL=dracut
elif command -v update-initramfs >/dev/null 2>&1; then
INITRD_TOOL=initramfs-tools
else
die "no initramfs for $VER and neither dracut nor initramfs-tools is installed"
fi
step "initramfs: will build with $INITRD_TOOL"
fi
if (( ! ASSUME_YES )) && (( ! DRY_RUN )); then
read -rp "Proceed? [y/N] " ok
[[ $ok == [yY]* ]] || { echo "Aborted."; exit 0; }
fi
echo
# ------------------------------------------------------------------ actions --
# 1. initramfs first — grub-mkconfig looks for it when writing menu entries.
if (( NEED_INITRD )); then
info "Building initramfs for $VER"
if [[ $INITRD_TOOL == dracut ]]; then
run dracut --force "/boot/initrd.img-$VER" "$VER"
else
run update-initramfs -c -k "$VER"
fi
fi
# 2. Hold, so unattended-upgrades and autoremove leave it alone.
if (( DO_HOLD )); then
if (( ${#PKGS[@]} )); then
info "Holding ${#PKGS[@]} package(s)"
run apt-mark manual "${PKGS[@]}"
run apt-mark hold "${PKGS[@]}"
else
warn "no dpkg packages matched $VER — kernel installed by hand? skipping hold"
fi
fi
# 3. GRUB default.
if (( DO_GRUB )); then
if grep -q '/etc/default/grub\.d' "$(command -v grub-mkconfig)" 2>/dev/null; then
info "Writing GRUB override to $DROPIN"
if (( DRY_RUN )); then
printf ' %s[dry-run]%s would write GRUB_DEFAULT=%s\n' \
"$C_DIM" "$C_OFF" "'$ENTRY'"
else
mkdir -p "$DROPIN_DIR"
cat >"$DROPIN" <<EOF
$MARKER — remove this file or run 'pin-kernel --revert' to undo
# kernel: $VER
GRUB_DEFAULT='$ENTRY'
EOF
chmod 0644 "$DROPIN"
step "GRUB_DEFAULT='$ENTRY'"
fi
else
# Older/patched grub-mkconfig that doesn't source the drop-in dir.
warn "grub-mkconfig doesn't read $DROPIN_DIR — editing $GRUB_DEFAULT_FILE"
run cp -a "$GRUB_DEFAULT_FILE" "$GRUB_DEFAULT_FILE.bak.$(date +%Y%m%d%H%M%S)"
if (( ! DRY_RUN )); then
sed -i "/^GRUB_DEFAULT=/d" "$GRUB_DEFAULT_FILE"
printf "GRUB_DEFAULT='%s' %s\n" "$ENTRY" "$MARKER" >>"$GRUB_DEFAULT_FILE"
fi
fi
info "Regenerating $GRUB_CFG"
run update-grub
fi
echo
if (( DRY_RUN )); then
info "Dry run complete — nothing was changed."
else
info "$VER is now the default. Reboot when ready."
step "one-off test without committing: sudo grub-reboot \"$ENTRY\" && sudo reboot"
step "undo everything: sudo $0 --revert"
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment