I sometimes need a tiny cloud instance that only forwards traffic. CPU and memory can be small, and the disk is mostly wasted space. On one Alibaba Cloud ecs.t6-c4m1.large instance, a 1 GB root disk on a long contract can push the cost down to about $0.40/month, but a normal Linux install leaves too little usable space.
The trick is not complicated:
- Use Alpine Linux, because the base system is small.
- Keep
/boottiny. A 512 MB boot partition is absurd on a 1 GB disk. - Put
/on Btrfs with transparent compression. Application files, package metadata, and logs usually compress well. - Do not allocate disk swap. Use zram later if the machine needs swap-like behavior.
This post is the install recipe I use to build a minimal BIOS/MBR Alpine image, then optionally convert it to QCOW2 for reuse.
If you would rather not do this by hand, I later automated the whole thing: Building Alpine Linux Disk Images Without a VM is a script that builds the same class of image as a file, with no VM and no interactive steps, and boots under both BIOS and UEFI. This post is still the explanation of why the pieces are the way they are.
Assumptions
This guide targets a very specific VM shape:
- Legacy BIOS boot with MBR and Syslinux/Extlinux.
- One small ext4
/bootpartition and one Btrfs root partition. - No disk encryption, no LVM, no UEFI, no separate
/var. - The whole disk can be destroyed.
If your VM boots with UEFI, use an EFI system partition and GRUB instead. If your disk is NVMe, adapt the partition names to /dev/nvme0n1p1 and /dev/nvme0n1p2.
The final layout is:
| Partition | Size | Filesystem | Mountpoint |
|---|---|---|---|
/dev/vda1 | 64 MB | ext4 | /boot |
/dev/vda2 | Rest of disk (~500MB is fine) | Btrfs | / |
64 MB is enough for this single-kernel Alpine image. The boot partition usage should be around 30M with Alpine 3.23 virt kernel (6.18). If you plan to keep multiple kernels or use a larger bootloader setup, use 128 MB instead.
Before you start installing, create a VM with a ~512MB disk (yes, really). CPU and RAM can be small, because the install process is not resource-intensive. After the install, you can resize the disk to 1 GB or larger.
Always go with a small disk first because the disk image can be expanded easily, but shrinking a disk is not trivial.
Partition The Disk
Set the disk variables so the commands below are harder to mistype:
| |
Now create a DOS/MBR partition table with a tiny boot partition:
| |
Inside fdisk, enter:
| |
The important details are:
/dev/vda1should be bootable./dev/vda1should start at sector2048./dev/vda2should start at sector133120.- The second partition should use the rest of the disk.
Check that the kernel sees the new partitions:
| |
If /dev/vda1 and /dev/vda2 do not appear, reboot the live ISO. Alpine’s manual disk setup docs also recommend rebooting after manual partition creation when needed.1
Start Alpine Setup
Download the latest Alpine ISO from https://alpinelinux.org/downloads/. I usually use the x86_64 architecture and the virtual variant. As this will be used in virtualized environments, the virt kernel is smaller.
Boot the Alpine ISO and log in as root. The live environment has no root password by default.
Run the normal installer first:2
| |
Answer the usual questions for keyboard, hostname, network, DNS, root password, timezone, mirror, SSH, and NTP. I usually choose chrony for NTP.
When it asks which disk to use, type:
| |
This keeps the base configuration but skips automatic partitioning and formatting. For the later diskless-mode prompts, also choose none for configs. You can keep the default apk cache dir (/var/cache/apk).
Format And Mount
Install the filesystem tools in the live environment:
| |
Format /boot as ext4 and / as Btrfs:
| |
Mount root with compression enabled:
| |
I keep /boot on ext4 because it is small and predictable with this Syslinux/MBR setup. On a tiny image, boring boot is good boot.
Install Alpine
Install Alpine into the mounted filesystem:
| |
The options mean:
-m sys: install a traditional persistent system to disk.-s 0: do not create disk swap./mnt: use the partitions already mounted under/mnt.
This is the standard setup-disk -m sys /mnt flow, just with a manually prepared root filesystem.3
Verify fstab And initramfs
Check /mnt/etc/fstab before rebooting:
| |
The root entry should include Btrfs compression. Mine looks like this:
| |
Remove any stale cdrom or usbdisk entries if the installer generated them.
Also check that the installed initramfs knows about Btrfs:
| |
If btrfs is missing from features="...", add it before rebooting and regenerate the initramfs from a chroot. Alpine explicitly calls this out for manual Btrfs root installs.4
Fix The MBR Bootloader
On these tiny MBR images, I have seen the first-stage bootloader not get written correctly. Reinstalling the Syslinux MBR is cheap insurance:
| |
Use gptmbr.bin instead only if you are using GPT. For the DOS partition table above, mbr.bin is the right file.5
Now unmount and reboot:
| |
After the first boot, log in and check the actual space usage:
| |
Convert The Disk To QCOW2
Once the image is configured the way you want, shut it down and convert the raw disk from the host:
| |
Replace the source device with your VM disk. Do this from a stopped VM or a consistent snapshot, not from a running writable system.
You can now reuse the QCOW2 as a base image for other VMs or cloud instances.
Restore From The Provider’s Original OS
Some cloud providers do not let you upload or boot a custom disk image. If you still have VNC/serial-console access, another way is to boot the provider’s original Linux image, download your Alpine QCOW2 there, convert it to raw, then reboot into the original OS initramfs and overwrite the whole disk from there.
The important reason for the two-stage flow is tooling: the original OS probably has curl and qemu-img, while the initramfs usually does not. The initramfs is only used for the final dd, because at that point the real root filesystem is not mounted and can be safely overwritten.
This is destructive. Double-check the target disk and the old root partition before running dd.
First, in the provider’s original OS. This filesystem needs enough free space to hold both the downloaded QCOW2 and the converted raw image:
| |
Then use the provider console or VNC to reboot into initramfs. On a GRUB-based original OS, select the normal boot entry, press e, find the Linux kernel command line, append:
| |
Then press F10 or Ctrl-x to boot. This should drop you into an initramfs shell before the root filesystem is mounted.
From the initramfs shell:
| |
After booting into Alpine, apply provider-specific network settings. For a static IPv4 setup:
| |
If the provider uses DHCP, keep the interface as DHCP instead and let Alpine request the address normally.
Restore Without A Console Or Rescue Mode
The cheapest tiers take the rest of it away too: no custom-image import, no VNC, no serial console, no rescue mode. The stock OS and SSH are all you get. The initramfs trick above is unreachable there, because its one console step — interrupting GRUB — has no SSH equivalent: an initramfs shell has no network stack and no sshd.
What still works is having a script arrange that environment for you. bin456789/reinstall rewrites the stock bootloader to boot a minimal Alpine that runs entirely in RAM, and from there downloads your image and dds it over the whole disk before rebooting into it. No console is involved at any point. You watch the progress over SSH, and if the download or the write fails, the RAM system keeps its sshd up, so a failure is a retry by hand rather than a reinstall ticket.
| |
Before it does anything, the script asks for a username and a password, and it is worth being clear about what they are not: the new system’s login. They exist only inside the RAM installer environment — they are what you would type to SSH back in while the dd runs, to watch progress or clean up after a failure. A Linux image gets nothing injected; the script’s own end-of-run summary says as much, printing Password: [Depends on image] under “After Install”. So press Enter twice — root, then a random password the summary displays for copying — and let whatever the image baked in be the machine’s real credentials. --ssh-key replaces that password with one of your public keys for the installer environment, if typing nothing at all is the goal.
When you do log in mid-install, expect a host-key mismatch: the RAM environment presents its own host keys, and so does the finished image, so the client will complain about the stock OS’s key on the way in and about both of them after the final reboot. That is the process working, not something broken — clear the old entry with ssh-keygen -R or accept the new key each time. On a NAT’d box the installer’s sshd still listens on port 22 internally, so the provider’s forwarded port keeps working throughout.
Four things to get right, all of them consequences of there being no console:
The image URL must be reachable from the VPS. The download runs inside the RAM environment, over the same network the stock OS uses. For a box in mainland China that usually rules out GitHub-hosted files; one of my own servers or local object storage works better.
The image has to work as-is. dd mode does not modify a Linux image, so the network settings, the SSH keys, and the sshd port must be baked into the image before flashing — the “apply provider-specific network settings” step above has to happen inside the image, not on the machine afterwards. On a NAT’d box, where the provider forwards one public port to (say) internal port 22, changing the sshd port in the image orphans that forward and the machine. If the image comes from the builder, the 10-network and 20-ssh hooks are where this goes.
Check whether the stock OS keeps its root on LVM before rebooting. The script stages reinstall-vmlinuz and reinstall-initrd in the stock OS’s root directory and lets GRUB find them with search --file. GRUB’s LVM support is incomplete — thin-provisioned volumes are unsupported outright, and some perfectly ordinary LVM roots also defeat it for reasons that are not understood (issue #355). When that happens, GRUB stops at file '/reinstall-vmlinuz' not found and the RAM environment is never reached. On a machine with a console that is a visible error; on this kind of machine the box reboots and simply never speaks again. One lsblk settles it beforehand — if / sits on an lvm device, copy both files to /boot, which Debian-style LVM installs keep on a plain partition that GRUB reads reliably:
| |
The copies are harmless if they were not needed: search --file scans every filesystem GRUB can read, so it finds the /boot copies whenever the LVM ones are invisible. The workaround is the tool author’s own, from the issue thread. If /boot is only a directory inside the LVM root, there is no plain partition to stage onto, and I would not run this flow on such a box without a console.
Audit before the final reboot. --hold 2 stops after the dd finishes but before the reboot. SSH back in, mount the freshly written root and boot partitions read-write, and check /etc/network/interfaces, the authorized keys, and the bootloader config while a mistake is still fixable with a text editor. It is the last moment anything is.
The RAM environment is small — the project lists 256 MB of RAM as the minimum — so this works on boxes too tiny for anything else. To drive the final dd by hand instead, bash reinstall.sh alpine --hold 1 boots the same RAM Alpine and stops there, nothing written; it is also a cheap dry run for whether that environment’s networking works on the provider’s NAT at all. And if the machine still does not come back after the last reboot, the floor is the provider’s own reinstall button, back to the stock OS — annoying, not fatal.
Things I Bake Into The Image
The base install above is intentionally small. The sections below are optional knobs I usually apply before making the reusable image.
SSH
For a private forwarding box, I allow root SSH and TCP forwarding. Use key auth and firewall rules if this machine is reachable from the public Internet.
Add or change these lines in /etc/ssh/sshd_config:
| |
Restart SSH:
| |
ClientAliveInterval 60 plus ClientAliveCountMax 3 makes dead SSH forwarding sessions disappear after roughly three minutes instead of waiting for long TCP timeouts.
Community Repository
Many useful packages live in community.
Either run:
| |
Or edit /etc/apk/repositories and uncomment the matching community repository:
| |
Then refresh indexes:
| |
IPv6 DHCP
SLAAC may work without any configuration. Some cloud providers need DHCPv6, in which case add an IPv6 stanza to /etc/network/interfaces:
| |
Install the DHCP and interface tooling if your image does not already have it:
| |
Chrony
If NTP was not configured during install:
| |
The result should look like this:
| |
Enable it:
| |
Log Caps
Alpine does not install a full log rotation stack by default. On a 1 GB disk, I prefer a tiny periodic script that keeps one backup and truncates logs in place so daemons can continue writing.
| |
Then tell BusyBox syslog not to rotate by itself:
| |
APK Cache Cleanup
If apk caching is enabled later, clean old packages periodically:
| |
Timezone
| |
ZeroTier
Alpine’s ZeroTier package can lag behind upstream, so I usually use static binaries from zerotier-static.
After placing zerotier-one at /usr/local/bin/zerotier-one, create an OpenRC service:
| |
ZRAM Swap
Zram is more useful than disk swap. Install zram-init:
| |
These sysctls make the kernel more willing to use compressed swap:
| |
Cloud-init
cloud-init is convenient if the image will be imported into a cloud provider, but it pulls in Python and is not small.
| |
Read the package message after installation and enable the OpenRC services it lists. If you want automatic partition growth on first boot, also install:
| |
For the smallest image, skip cloud-init and inject network configuration another way.
Expand Root After Restoring To A Larger Disk
If you restore the QCOW2 to a larger disk, grow the root partition and then grow Btrfs.
Run:
| |
Inside fdisk:
| |
Ask the kernel to reread the partition table:
| |
If you had to reboot, continue after the reboot:
| |
I prefer this manual method over blindly using a grow tool because it actually expands and aligns, while some grow tools may not expand the partition to be 4K aligned.
BBR Congestion Control
For forwarding boxes, I usually enable BBR:
| |
Verify:
| |
Podman
If I need containers on a tiny VM, I prefer Podman over Docker because the entire stack (no containerd, crun instrad of runc, lighter daemon).
| |
If you want Docker CLI and Compose compatibility against the Podman socket:
| |
I do not use podman-docker or podman-compose here; the Docker CLI is closer to what my existing Compose files expect.
UFW
If the provider does not give you a firewall, configure one before exposing the VM:
| |
If this machine routes VPN, overlay network, or container traffic, add explicit ufw route allow ... rules for the private ranges you actually need.
sshguard
I avoid Fail2ban on this image because Python is too heavy for the target machine. sshguard is small and good enough.
| |
Do not blindly start the nftables service before checking its default ruleset from a console. On one of my Alpine images, doing that installed a default deny ruleset and locked out remote SSH. I let sshguard manage its own nftables sets instead.
| |
Common Tools
This is no longer minimal, but it makes Alpine feel closer to a general-purpose server if you find busybox too limited:
| |
And other tools
| |
Final Notes
The most important part of this setup is the partitioning discipline. On a 1 GB disk, a lazy 512 MB /boot, a swap partition, or unbounded logs will consume the entire machine faster than the application does.
With a 64 MB /boot, compressed Btrfs root, no disk swap, and a few periodic cleanup jobs, Alpine stays usable even on tiny cloud disks. It is not luxurious, but it is enough for a forwarding node or a small single-purpose service.