Minimising Debian 13 on a KVM VPS

This guide documents how to strip a freshly deployed Debian 13 (trixie) VPS down to the minimum RAM and disk footprint without breaking it.

This targets KVM-based VPS instances with a virtio-blk disk (/dev/vda), a single network interface with a static IP assigned at deployment, and BIOS boot. The instance is a NAT VPS: SSH is exposed on a forwarded external port, not directly on port 22. Adjust the port forwarding in your provider portal where noted if you use NAT or just use your external IP and port 22 if a fixed IP is assigned.


Before

Fresh deploy, cloud-init has run, nothing changed yet.

               total        used        free      shared  buff/cache   available
Mem:             213          88          27           0         108         124
Swap:              0           0           0
Filesystem      Size  Used Avail Use% Mounted on
/dev/vda1       2.8G  832M  1.8G  32% /

Note your network details before starting

Stage 5 replaces systemd-networkd with a static /etc/network/interfaces file. old school debain style, before making any changes, record your interface name, IP address, and gateway. You will need them later.

ip a
ip route show

On TierHive the interface is ens3, this may vary (but probably not) The IP and gateway are shown in the VPS control panel and in the output above. Note them down before proceeding.


Stage 1: GRUB Cmdline

Reduce the boot timeout (Because why not) and add kernel parameters to cut memory overhead.

sed -i 's/^GRUB_TIMEOUT=5/GRUB_TIMEOUT=1/' /etc/default/grub
sed -i 's|^GRUB_DISTRIBUTOR=.*|GRUB_DISTRIBUTOR=Debian|' /etc/default/grub
sed -i 's|^GRUB_CMDLINE_LINUX=.*|GRUB_CMDLINE_LINUX="mitigations=off console=tty0 console=ttyS0,115200 earlyprintk=ttyS0,115200 consoleblank=0 ipv6.disable=1 audit=0 nowatchdog"|' /etc/default/grub
update-grub

The GRUB_DISTRIBUTOR line is changed from a shell call to lsb_release to a static string. The lsb-release package is removed later; leaving the shell call in place would cause update-grub to fail after that point.

Remove the GRUB locale files. They exist to translate GRUB menu entries. On a headless server booting via serial console, you can live without them:

rm -rf /boot/grub/locale

The details on the parameters added to GRUB_CMDLINE_LINUX:

  • mitigations=off disables all Spectre and Meltdown mitigations. On a single-user VPS where you control all running code, these protect against nobody as VM-to-VM isolation is handled by the hypervisor. This eliminates the overhead of PTI (page table isolation on every syscall), IBRS, IBPB, and several other mitigations Skip this if you run untrusted code.
  • ipv6.disable=1 disables IPv6 at kernel level. This is a NAT VPS with an IPv4 address only. Skip this if you use IPv6 or are @yoursunny
  • audit=0 disables the Linux audit subsystem. you can live without it.
  • nowatchdog disables the softlockup and hardlockup detectors.

Stage 2: Replace OpenSSH with Dropbear

Dropbear is a minimal SSH server designed for low-resource systems. It is significantly smaller than OpenSSH and links against far fewer libraries. On a NAT VPS with a single exposed port, the switch must be done atomically: stop sshd and start dropbear in one command or you will lose access, you might anyway, but should be able to get back in fine after.

DEBIAN_FRONTEND=noninteractive apt-get install -y dropbear

Dropbear is now installed but cannot start because OpenSSH holds port 22. Set the empty extra-args variable to suppress a harmless boot warning:

sed -i 's/#DROPBEAR_EXTRA_ARGS=""/DROPBEAR_EXTRA_ARGS=""/' /etc/default/dropbear

Now do the atomic swap. Debian 13 uses socket activation for SSH stopping ssh.service alone does not release port 22 because ssh.socket continues to hold it. Both must be stopped together:

systemctl stop ssh.socket ssh && systemctl start dropbear && systemctl disable ssh ssh.socket

Your session will drop. Reconnect on the same external port as before. Dropbear converts and reuses the existing OpenSSH host keys, so the host fingerprint is unchanged.

Once reconnected, remove OpenSSH:

DEBIAN_FRONTEND=noninteractive apt-get purge -y \
    openssh-server openssh-sftp-server openssh-client ssh-import-id
apt-get autoremove -y --purge

Stage 3: Remove Cloud-Init and Python

Cloud-init runs once at first boot to configure the instance. It has already run. It pulls in Python 3 and approximately 40 Python packages. All of it can be removed.

First disable the unattended-upgrades service and its associated timers, which also depend on Python:

systemctl stop unattended-upgrades
systemctl disable unattended-upgrades apt-daily.timer apt-daily-upgrade.timer apt-listchanges.timer man-db.timer dpkg-db-backup.timer

Remove cloud-init, its utilities, unattended-upgrades, and netplan. Netplan was used by cloud-init to generate the systemd-networkd configuration. The generated network config file persists after netplan is removed and will be used again in Stage 5:

DEBIAN_FRONTEND=noninteractive apt-get purge -y cloud-init cloud-guest-utils cloud-image-utils cloud-utils cloud-initramfs-growroot unattended-upgrades apt-listchanges  netplan.io python3-netplan reportbug

DEBIAN_FRONTEND=noninteractive apt-get purge -y $(dpkg -l | grep '^ii' | awk '{print $2}' | grep -E '^(python3|python-apt-common|libpython3)')

apt-get autoremove -y --purge

Removing cloud-initramfs-growroot triggers an automatic update-initramfs run via the package post-remove hook. This is expected. The initramfs will be rebuilt again with optimised settings in Stage 7.

Remove the cloud-init data directory, which persists after the package is removed:

rm -rf /var/lib/cloud

Stage 4: Package Cleanup

Remove packages that serve no purpose on a running headless VPS:

DEBIAN_FRONTEND=noninteractive apt-get purge -y vim vim-runtime vim-tiny man-db groff-base manpages locales libc-l10n sudo qemu-guest-agent qemu-utils polkitd pciutils bind9-host traceroute socat wget ethtool screen apparmor lsb-release

apt-get autoremove -y --purge
apt-get clean

The autoremove step may remove procps as an orphaned dependency. Reinstall it explicitly. ps, free, and kill are probably wanted even when cut to the bone.

apt-get install -y procps

Configure dpkg to suppress docs and man pages on future installs. Without this, any subsequent apt-get install will reinstall them:

cat > /etc/dpkg/dpkg.cfg.d/nodoc << 'EOF'
path-exclude=/usr/share/doc/*
path-include=/usr/share/doc/*/copyright
path-exclude=/usr/share/man/*
path-exclude=/usr/share/groff/*
path-exclude=/usr/share/info/*
path-exclude=/usr/share/lintian/*
EOF

Remove the doc and man page files already installed by previous packages:

find /usr/share/doc -depth -type f ! -name 'copyright' -delete
find /usr/share/doc -depth -empty -type d -delete
rm -rf /usr/share/man /usr/share/groff /usr/share/info /usr/share/lintian

Remove non-English locale files. The locales package was removed above, but locale data installed by glibc and other packages remains. Only the en_US directory is kept:

find /usr/share/locale -mindepth 1 -maxdepth 1 -type d ! -name 'en_US' -exec rm -rf {} +
find /usr/share/locale -mindepth 1 -maxdepth 1 ! -type d -delete

Remove all timezone data except UTC. The system clock is set to UTC at deployment and the hypervisor maintains it:

find /usr/share/zoneinfo -mindepth 1 -maxdepth 1 ! -name 'Etc' -exec rm -rf {} +
find /usr/share/zoneinfo/Etc -mindepth 1 ! -name 'UTC' -delete

Remove the deb-src lines from the apt sources file. Source package lists are never needed on a production server and regenerate as 55MB on every apt-get update if left in place:

sed -i 's/^Types: deb deb-src/Types: deb/' /etc/apt/sources.list.d/debian.sources

Stage 5: Replace systemd-networkd with ifupdown

systemd-networkd runs as a persistent daemon consuming approximately 11MB of RAM. For a server with a static IP that never changes, a traditional /etc/network/interfaces file managed by the lightweight ifupdown package is sufficient and leaves no daemon running after the interface is up.

Fix DNS before removing systemd-resolved. The current /etc/resolv.conf is a symlink to the resolved stub. Replace it with a static file first:

rm /etc/resolv.conf
printf 'nameserver 1.1.1.1\nnameserver 1.0.0.1\n' > /etc/resolv.conf

Remove the resolve NSS module reference from /etc/nsswitch.conf, then remove the package:

sed -i 's/resolve \[!UNAVAIL=return\] //g' /etc/nsswitch.conf
sed -i 's/ resolve//g' /etc/nsswitch.conf
systemctl stop systemd-resolved
DEBIAN_FRONTEND=noninteractive apt-get purge -y systemd-resolved libnss-resolve

Install ifupdown. It pulls in dhcpcd-base as a dependency; remove it immediately since the IP is static:

DEBIAN_FRONTEND=noninteractive apt-get install -y ifupdown
DEBIAN_FRONTEND=noninteractive apt-get purge -y dhcpcd-base

Write the network configuration. Replace the address and gateway with the values you recorded before starting:

cat > /etc/network/interfaces << 'EOF'
auto lo
iface lo inet loopback

auto ens3
iface ens3 inet static
    address YOUR_IP/24
    gateway YOUR_GATEWAY
EOF

The interface name on TierHive KVM instances is ens3. If yours differs, use the name shown by ip a. The subnet prefix /24 is standard for TierHive instances; adjust if your allocation differs on your host.

Disable and mask systemd-networkd, then remove the network configuration directory it managed:

systemctl disable systemd-networkd systemd-networkd.socket systemd-network-generator.service systemctl mask systemd-networkd systemd-networkd.socket systemctl mask systemd-networkd-wait-online.service
rm -rf /etc/systemd/network/

ifupdown's networking.service was enabled automatically when the package was installed. It is a oneshot service that brings up interfaces at boot and exits, leaving no daemon.


Stage 6: Service Cleanup

Disable systemd-timesyncd. On KVM the guest clock is disciplined by the hypervisor via kvm-clock. The hypervisor keeps the clock accurate and timesyncd adds no value:

systemctl disable --now systemd-timesyncd

Mask kernel debug and config filesystems, EFI pstore, binfmt_misc, and timers that have no purpose on a headless VPS:

systemctl mask sys-kernel-config.mount
systemctl mask sys-kernel-debug.mount
systemctl mask sys-kernel-tracing.mount
systemctl mask systemd-pstore.service
systemctl mask proc-sys-fs-binfmt_misc.automount
systemctl mask proc-sys-fs-binfmt_misc.mount
systemctl disable fstrim.timer
systemctl mask uuidd.socket
systemctl disable e2scrub_reap.service e2scrub_all.timer
  • sys-kernel-config.mount — configfs for USB gadgets and iSCSI
  • sys-kernel-debug.mount / sys-kernel-tracing.mount — kernel debug filesystems
  • systemd-pstore.service — EFI pstore crash dump collection; the efi_pstore module is blacklisted in Stage 7
  • proc-sys-fs-binfmt_misc — binary format handlers for Wine, Java, etc.
  • fstrim.timer — TRIM does not pass through virtual storage
  • uuidd.socket — UUID daemon, not needed
  • e2scrub — online ext4 filesystem checks, not needed on a VPS

Remove PAM session tracking. The pam_systemd.so module registers each login session with systemd-logind via dbus. On a root-only dropbear server there is no use for session tracking. Without this change, every SSH login activates dbus, which then auto-activates logind:

sed -i '/pam_systemd\.so/s/^/# /' /etc/pam.d/common-session

Mask systemd-logind. Logind is wired into multi-user.target by the systemd package and starts at every boot. It manages user seats and sessions, neither of which exist on a headless server:

systemctl mask systemd-logind.service

Provide clean reboot and poweroff commands. Masking logind causes the standard reboot binary to print errors when it attempts to notify logind via dbus before falling back to systemd's private socket. The reboot succeeds either way, but the errors are avoidable. Replace the commands with wrappers that go directly to systemd:

cat > /usr/local/sbin/reboot << 'EOF'
#!/bin/sh
exec systemctl reboot --no-wall 2>/dev/null
EOF
chmod +x /usr/local/sbin/reboot

cat > /usr/local/sbin/poweroff << 'EOF'
#!/bin/sh
exec systemctl poweroff --no-wall 2>/dev/null
EOF
chmod +x /usr/local/sbin/poweroff

Stage 7: Kernel Module Blacklist and Initramfs

Set the explicit module list and switch the initramfs from most (load everything) to dep (load only what this hardware needs). The three lines below are a safety net: with MODULES=dep, update-initramfs scans running modules and their dependencies, so virtio_blk and ext4 would be detected automatically. The explicit list ensures they are included even if detection misses them:

cat > /etc/initramfs-tools/modules << 'EOF'
virtio_blk
virtio_net
ext4
EOF

sed -i 's/^MODULES=most/MODULES=dep/' /etc/initramfs-tools/initramfs.conf

Create the module blacklist:

cat > /etc/modprobe.d/blacklist-vps.conf << 'EOF'
# CD/ISO (no optical drive on VPS)
blacklist isofs
blacklist sr_mod
blacklist cdrom

# KVM (not nesting VMs)
blacklist kvm_intel
blacklist kvm
install kvm /bin/true
install kvm_intel /bin/true

# Memory ballooning
blacklist virtio_balloon

# ATA/IDE (using virtio-blk, not ATA)
blacklist ata_piix
blacklist ata_generic
blacklist libata

# Input devices (headless)
blacklist evdev
blacklist button
blacklist serio_raw

# VMware VSOCK stack (not VMware)
blacklist vmw_vmci
blacklist vmw_vsock_vmci_transport
blacklist vmw_vsock_virtio_transport_common
blacklist vsock_loopback
blacklist vsock
install vsock /bin/true

# QEMU firmware config (already booted)
blacklist qemu_fw_cfg

# SCSI generic (no SCSI devices)
blacklist sg

# i6300ESB watchdog
blacklist i6300esb

# Intel RAPL power management (not needed on VPS)
blacklist intel_rapl_msr
blacklist intel_rapl_common
blacklist iosf_mbi
blacklist rapl

# EFI pstore (crash dump storage in EFI vars, not needed)
blacklist efi_pstore

# Binary format handlers (Wine, Java, etc)
blacklist binfmt_misc

# Automount
blacklist autofs4

# Watchdog hardware driver
blacklist watchdog

# configfs (USB gadgets, iSCSI config filesystem)
blacklist configfs
install configfs /bin/true

# T10 DIF SCSI data integrity (no SCSI on this VPS)
blacklist crct10dif_pclmul
EOF

Rebuild the initramfs once with all of the above in place:

update-initramfs -u -k all

Stage 8: System Tuning

Sysctl tuning. The file is named 90- to ensure it loads after systemd's /usr/lib/sysctl.d/50-pid-max.conf, which sets kernel.pid_max = 4194304. A file with a lower prefix would be overridden by it:

mkdir -p /etc/sysctl.d
cat > /etc/sysctl.d/90-minvps.conf << 'EOF'
# Reduce network socket buffers
net.core.rmem_default = 32768
net.core.wmem_default = 32768
net.core.rmem_max = 131072
net.core.wmem_max = 131072
net.core.netdev_max_backlog = 64
net.core.somaxconn = 128

# Reclaim inode and dentry caches more aggressively under memory pressure
vm.vfs_cache_pressure = 500

# Reduce PID table overhead
kernel.pid_max = 4096

# Dirty page writeback thresholds
vm.dirty_background_ratio = 5
vm.dirty_ratio = 10

# Disable watchdog
kernel.watchdog = 0
EOF

Reduce block device read-ahead. The kernel defaults to 8MB of read-ahead on the block device. On virtual storage this is wasted memory. 128KB is sufficient as it only needs to exist, it does nothing this is a legacy thing from days gone by that still exists for physical spinners I guess but it holds some Ram.

echo 128 > /sys/block/vda/queue/read_ahead_kb

cat > /etc/systemd/system/readahead.service << 'EOF'
[Unit]
Description=Set block device read-ahead
After=local-fs.target

[Service]
Type=oneshot
ExecStart=/bin/sh -c 'echo 128 > /sys/block/vda/queue/read_ahead_kb'
RemainAfterExit=yes

[Install]
WantedBy=multi-user.target
EOF

systemctl enable readahead.service

Replace systemd-journald with busybox syslogd. journald runs as a persistent 8MB process. Busybox syslogd with a 64KB in-memory circular buffer does the same job for a minimal server at a fraction of the cost. Logs are accessed with busybox logread:

DEBIAN_FRONTEND=noninteractive apt-get install -y busybox

cat > /etc/systemd/system/syslogd.service << 'EOF'
[Unit]
Description=Busybox syslogd
DefaultDependencies=false
After=systemd-tmpfiles-setup.service
Before=sysinit.target

[Service]
Type=simple
ExecStart=/bin/busybox syslogd -n -C64
Restart=on-failure

[Install]
WantedBy=multi-user.target
EOF

systemctl enable syslogd.service

systemctl mask systemd-journald.service
systemctl mask systemd-journald.socket
systemctl mask systemd-journald-dev-log.socket
systemctl mask systemd-journald-audit.socket 2>/dev/null

journalctl no longer works after this step. Use busybox logread to view logs and busybox logread -f to follow them. systemctl status continues to work for checking individual service states as it uses systemd's own data, not the journal.


Final Cleanup

All package operations are now complete. Clear the apt package lists and cache binaries. They consume around 150MB and are not needed until the next time packages are installed, at which point apt-get update will regenerate them:

rm -rf /var/lib/apt/lists/* /var/cache/apt/*.bin
apt-get clean

Reboot

reboot

After

               total        used        free      shared  buff/cache   available
Mem:             213          38         141           0          41         174
Swap:              0           0           0
Filesystem      Size  Used Avail Use% Mounted on
/dev/vda1       2.8G  275M  2.4G  11% /

RAM down from 88MB to 38MB. Disk down from 832MB to 275MB, meaning total ram use for kernel and userspace is 82mb so this now fits into 128mb ram, if your provider supports that, you can now downgrade and save some money :)


What Is Still Running

The running userspace processes after boot are systemd (PID 1, 13MB), systemd-udevd (9.5MB), dbus-daemon (2MB), busybox syslogd in circular buffer mode (2MB), dropbear (3MB), and two getty processes: one on ttyS0 for serial console access and one on tty1 for the browser-based console panel.

dbus-daemon starts at boot via socket activation and runs persistently. The PAM change in Stage 6 prevents SSH logins from activating systemd-logind through it, but dbus itself is socket-activated early in the boot sequence and stays running.

The loaded kernel modules after reboot are: the virtio stack (virtio_blk, virtio_net, virtio_rng), the EFI partition (vfat, fat, nls_ascii, nls_cp437), hardware AES acceleration (aesni_intel, gf128mul, crypto_simd, cryptd, ghash_clmulni_intel), SHA acceleration (sha256_ssse3, sha512_ssse3, sha1_ssse3), CRC (crc32c_intel, crc32_pclmul), netfilter (ip_tables, x_tables, nfnetlink), and network failover (net_failover, failover).