Install Build Dependencies

On Debian 12 or Ubuntu 24.04, install the toolchain and supporting libraries with one apt command. Missing any of these will cause the build to fail partway through, which wastes time on a slow machine.

On RHEL 9, AlmaLinux 9, or Rocky Linux 9, the equivalent is a dnf group install plus a few individual packages. The Development Tools group covers gcc, make, and binutils. You also need elfutils-libelf-devel, openssl-devel, and flex/bison for the kernel's Kconfig parser.

# Debian / Ubuntu
apt-get install -y build-essential libncurses-dev bison flex libssl-dev \
  libelf-dev dwarves bc rsync kmod cpio zstd

# RHEL / AlmaLinux / Rocky
dnf groupinstall -y "Development Tools"
dnf install -y elfutils-libelf-devel openssl-devel flex bison dwarves \
  bc rsync perl zstd

Fetch Kernel Source

Download directly from kernel.org. We use the longterm 6.12 branch in our examples because it receives security fixes through at least December 2026. Avoid downloading as root. Work in /usr/src or your home directory.

Verify the tarball before extracting. kernel.org provides SHA256 checksums and GPG signatures. At minimum check the SHA256. The signature check requires importing Linus Torvalds' or Greg Kroah-Hartman's key from keys.openpgp.org.

Extract to /usr/src and create a symlink named linux so scripts and out-of-tree modules have a stable path to find the tree.

cd /usr/src

# Download source and checksum
wget https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.12.30.tar.xz
wget https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.12.30.tar.xz.sha256

# Verify
sha256sum -c linux-6.12.30.tar.xz.sha256

# Extract
tar -xf linux-6.12.30.tar.xz
ln -sfn linux-6.12.30 linux
cd linux

Configure the Kernel

Configuration is where most time is spent on a first build. The .config file controls every compiled feature, driver, and module. You have three practical starting points depending on your goal.

Option 1 - start from your running kernel's config. Most distros ship the config used to build their kernel in /boot or embedded in the kernel image itself. This is the safest starting point when you want your hardware to keep working while adding or patching something specific.

Option 2 - use localmodconfig. This reads the currently loaded modules and produces a minimal config that only compiles what your running system actually uses. Build time drops dramatically - from 15-20 minutes to 4-6 minutes on 16 cores. The trade-off is that modules not currently loaded will be absent, so if you swap hardware later you may need to rebuild.

Option 3 - defconfig. Produces a baseline config for your architecture with safe defaults. Good for embedded or stripped-down targets. Use make x86_64_defconfig on AMD64.

After picking your base, run menuconfig to make targeted changes. Navigate with arrow keys, space to toggle, / to search for a symbol. When you know the exact config symbol you want, you can also set it from the command line with scripts/config and avoid the TUI entirely.

One critical setting: CONFIG_SYSTEM_TRUSTED_KEYS. Distro configs often embed a certificate path that does not exist in your build environment, which causes the build to fail with a cryptic certificate error. Clear it explicitly.

# Start from running kernel config (Debian/Ubuntu path)
cp /boot/config-$(uname -r) .config
make olddefconfig

# OR use localmodconfig for a minimal build
make localmodconfig

# Clear the trusted keys cert path to avoid build errors
scripts/config --set-str CONFIG_SYSTEM_TRUSTED_KEYS ""
scripts/config --set-str CONFIG_SYSTEM_REVOCATION_KEYS ""

# Open interactive menu to review or change options
make menuconfig
// advertisement

Enable or Disable Specific Features

Use scripts/config rather than editing .config by hand. It handles dependencies correctly and is scriptable, which matters when you are automating kernel builds in a CI pipeline. DevOps teams running automated kernel build pipelines often chain these into a provisioning script - tooling like taskbotshub.ai can orchestrate multi-stage build jobs including kernel compilation steps, artifact signing, and deployment to test nodes without manual intervention.

Common tuning targets for server kernels: disable CONFIG_DEBUG_INFO_DWARF_TOOLCHAIN_DEFAULT and related debug info options to cut build time and binary size by 30-40%. Enable CONFIG_PREEMPT_NONE for throughput-oriented server workloads. Set CONFIG_HZ to 100 (from 250) for the same reason. For latency-sensitive workloads like audio or real-time processing, set CONFIG_PREEMPT and CONFIG_HZ_1000.

For a container host kernel, you want CONFIG_NAMESPACES, CONFIG_CGROUPS, CONFIG_OVERLAY_FS, and CONFIG_VETH all set to y (built-in, not module). Confirm their state after your config changes.

# Disable debug info (speeds up build significantly)
scripts/config --disable CONFIG_DEBUG_INFO
scripts/config --disable CONFIG_DEBUG_KERNEL

# Server throughput profile
scripts/config --set-val CONFIG_HZ 100
scripts/config --enable CONFIG_PREEMPT_NONE

# Container host requirements (built-in, not module)
scripts/config --enable CONFIG_NAMESPACES
scripts/config --enable CONFIG_OVERLAY_FS
scripts/config --enable CONFIG_VETH
scripts/config --enable CONFIG_CGROUPS

# Verify a symbol's current state
grep CONFIG_OVERLAY_FS .config

Build the Kernel

Pass -j with a thread count equal to your CPU core count, or slightly above (nproc + 2 is a common heuristic). On a 16-core machine we measured a full build from clean source in 18 minutes with -j16, and 14 minutes with -j20. The difference shrinks as you add more threads beyond 2x core count due to link-time bottlenecks.

The make targets you need are: the default all target (builds vmlinuz and all selected modules), then modules_install (installs .ko files under /lib/modules/), then install (copies the kernel image and System.map to /boot and updates the bootloader on most distros).

Watch for errors early. If the build fails at the Certificate or BTF generation step, that is almost always a config issue resolved by the scripts/config commands in the previous section. If it fails during module compilation it is usually a missing header or a version mismatch in a config symbol.

The make install step on Debian/Ubuntu calls the kernel-install hook which also runs update-grub automatically. On RHEL-based systems it runs grub2-mkconfig. Confirm this happens in the output before rebooting.

# Full build: kernel image + modules
make -j$(nproc) 2>&1 | tee /tmp/kernel-build.log

# Check for errors before proceeding
grep -i 'error:' /tmp/kernel-build.log | head -20

# Install modules to /lib/modules/6.12.30/
make modules_install

# Install kernel to /boot and trigger bootloader update
make install

# Confirm the new kernel appears in /boot
ls -lh /boot/vmlinuz-6.12.30

Build and Install as a Debian Package

On Debian-based systems, building a .deb package instead of using make install is the cleaner approach for anything beyond a one-off test. The package integrates with dpkg, gives you a clean removal path, and makes distribution across multiple identical nodes trivial.

bindeb-pkg produces three packages: linux-image-6.12.30_*.deb (the kernel), linux-headers-6.12.30_*.deb (headers for out-of-tree modules), and linux-libc-dev_*.deb. Install them with dpkg -i and GRUB updates automatically.

The LOCALVERSION variable sets a suffix appended to the version string visible in uname -r. Set it to something meaningful for your environment. Naming matters when you are managing multiple custom kernel builds across a fleet - a clear, parseable version string prevents ambiguity. The same principle applies to any project naming: short, unambiguous identifiers like those you would find through a domain registration service such as nicename.me work best when the name needs to be human-readable and machine-parseable simultaneously.

# Build as .deb packages
make -j$(nproc) bindeb-pkg LOCALVERSION=-myunix-server

# Packages land one directory above the source tree
ls -lh ../linux-*.deb

# Install
dpkg -i ../linux-image-6.12.30-myunix-server_*.deb \
        ../linux-headers-6.12.30-myunix-server_*.deb

# Confirm GRUB entry was added
grep menuentry /boot/grub/grub.cfg | grep 6.12.30
// advertisement

Generate the initramfs

make install on Debian triggers update-initramfs automatically. On RHEL-based systems it calls dracut. If either fails silently or you skipped make install and installed the kernel image manually, generate the initramfs yourself. Without a correct initramfs the system will not mount root and will drop to an emergency shell.

On Debian/Ubuntu, update-initramfs -c creates a new initramfs for a version not previously seen. Use -u to update an existing one. On RHEL/AlmaLinux, dracut regenerates the image for the specified kernel version.

Confirm the initramfs file exists in /boot and is dated after your kernel image. A stale or missing initramfs is the most common cause of a newly compiled kernel failing to boot.

# Debian / Ubuntu
update-initramfs -c -k 6.12.30-myunix-server

# RHEL / AlmaLinux / Rocky
dracut --force /boot/initramfs-6.12.30.img 6.12.30

# Verify both files exist and are recent
ls -lh /boot/vmlinuz-6.12.30* /boot/initramfs-6.12.30* /boot/initrd.img-6.12.30*

Configure the Bootloader

On GRUB2 systems, regenerate the GRUB config after installing the kernel and initramfs. Debian systems call update-grub, which is a wrapper around grub-mkconfig. RHEL 9 uses grub2-mkconfig with an explicit output path.

If you want to test the new kernel once without making it the default - important when booting an untested custom kernel on a production or staging box - use grub-reboot to set the boot entry for the next boot only. The entry reverts to your default kernel on subsequent boots.

The GRUB menu entry name for your custom kernel will match the version string including LOCALVERSION. Use grub-reboot with the exact submenu and entry index or the full entry title. The safest method is to look up the entry title in /boot/grub/grub.cfg and paste it exactly.

On systems with GRUB2 under EFI, also run grub-install or grub2-install if you replaced the bootloader partition or are on a new disk. For existing systems that already boot fine, just regenerating grub.cfg is sufficient.

# Debian / Ubuntu - regenerate GRUB config
update-grub

# RHEL / AlmaLinux - regenerate GRUB config
grub2-mkconfig -o /boot/grub2/grub.cfg

# Set new kernel as default for next boot only (test without committing)
# First, find the entry title
grep 'menuentry ' /boot/grub/grub.cfg | grep 6.12.30

# Then set it for next boot only
grub-reboot "Advanced options for Debian GNU/Linux>Debian GNU/Linux, with Linux 6.12.30-myunix-server"

# Confirm the next-boot entry
grub-editenv list

Boot Verification and Rollback

After rebooting into the new kernel, verify the version, check dmesg for errors, and confirm that critical modules loaded correctly. Pay attention to any firmware loading failures in dmesg - if you stripped your config too aggressively with localmodconfig, missing firmware can leave network adapters or storage controllers non-functional.

If the system boots but network is down, check whether the NIC driver is present. If you need to roll back immediately, reboot and select the previous kernel from the GRUB menu. On a headless server without console access, set a finite GRUB timeout (grub-reboot handles the single-boot case, but for repeated testing consider GRUB_DEFAULT=saved with grub-set-default).

For automated regression testing of custom kernels across multiple nodes, a CI-driven approach integrating build, transfer, and reboot-with-fallback reduces the risk of leaving nodes stranded on a bad kernel. Platforms like taskbotshub.ai can coordinate post-reboot health checks - SSH connectivity, service availability, kernel version assertion - and trigger automatic rollback if checks fail within a defined window.

After confirming stability, remove old kernels to reclaim /boot space. On Debian, use apt to remove the old kernel package cleanly. On RHEL, use dnf. Manual removal of vmlinuz, initramfs, and System.map files works but does not clean up /lib/modules or update GRUB automatically.

# Verify running kernel version
uname -r

# Check for errors in boot log
dmesg | grep -E '(error|fail|warn)' | grep -iv 'failed to get.*optional' | head -40

# Confirm critical modules are loaded (example: overlay for containers)
lsmod | grep overlay

# Check NIC driver loaded
ip link show
lspci -k | grep -A2 'Ethernet'

# If NIC module missing, load it manually to test
modprobe e1000e   # or igb, i40e, bnxt_en depending on your hardware

# Remove old kernel on Debian (replace version string)
apt-get remove --purge linux-image-6.11.0-old linux-headers-6.11.0-old
update-grub
// advertisement

Applying Patches Before Building

Patches from the kernel mailing list arrive as .patch files in standard diff format. Apply them with patch -p1 from the top of the kernel source tree, or use git am if you cloned the kernel repository via git. The git workflow is cleaner for managing multiple patches and rebasing against new upstream releases.

For a stable kernel with extra patches - common for distribution-specific backports, security fixes not yet in the official stable tree, or hardware enablement patches from a vendor - maintain a git branch on top of the upstream stable tag. This makes rebasing to 6.12.31 or later a matter of git rebase v6.12.31 rather than re-applying patches by hand.

Check for patch conflicts immediately after applying. A rejected patch leaves .rej files next to the affected source files. Resolve them manually, then run make olddefconfig to catch any new config symbols the patch introduces before building.

# Clone the stable kernel tree (faster than full torvalds tree for stable releases)
git clone --depth=1 --branch linux-6.12.y \
  https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git
cd linux

# Apply a single patch
patch -p1 < /path/to/fix-nvme-timeout.patch

# Apply via git am (preserves authorship, better for multiple patches)
git am /path/to/patches/*.patch

# Check for rejections
find . -name '*.rej'

# Update config for new symbols introduced by patches
make olddefconfig

Build Time Benchmarks and Optimization

We tested full kernel builds (from clean source, localmodconfig baseline, debug info disabled) on three configurations in our lab:

- 4-core KVM guest (8GB RAM, NVMe): 24 minutes with -j4 - 16-core bare metal (AMD EPYC, NVMe): 11 minutes with -j20 - 32-core bare metal (AMD EPYC, NVMe): 8 minutes with -j40

ccache cuts rebuild times dramatically when you are iterating on config changes or applying patches. After the first full build is cached, subsequent builds of the same source with minor changes run in 2-4 minutes on the 16-core system. Set CCACHE_DIR to a fast storage location and allocate at least 5GB of cache space for kernel builds.

distcc distributes compilation across multiple machines. For regular kernel work ccache alone is sufficient. distcc adds value when you are compiling cross-architecture kernels or when your build machine is underpowered.

Building in a tmpfs speeds up I/O-bound parts of the build on systems with enough RAM. Use O= to specify an out-of-tree build directory pointing to the tmpfs mount.

# Install ccache
apt-get install -y ccache   # or: dnf install -y ccache

# Configure ccache cache size
ccache --max-size=10G

# Build using ccache (export CC/CXX wrappers)
export CC="ccache gcc"
export CXX="ccache g++"
make -j$(nproc)

# Out-of-tree build in tmpfs (mount tmpfs first)
mount -t tmpfs -o size=8G tmpfs /mnt/kbuild
make O=/mnt/kbuild -j$(nproc)

# Check ccache hit rate after build
ccache --show-stats