<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>VPS on Charlie Chiang's blog</title><link>https://charlie0129.github.io/blog/tags/vps/</link><description>Recent content in VPS on Charlie Chiang's blog</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Sat, 05 Sep 2026 22:00:00 +0800</lastBuildDate><atom:link href="https://charlie0129.github.io/blog/tags/vps/index.xml" rel="self" type="application/rss+xml"/><item><title>Building Alpine Linux Disk Images Without a VM</title><link>https://charlie0129.github.io/blog/p/alpine-image-builder/</link><pubDate>Sat, 05 Sep 2026 22:00:00 +0800</pubDate><guid>https://charlie0129.github.io/blog/p/alpine-image-builder/</guid><description>&lt;p>In &lt;a class="link" href="https://charlie0129.github.io/blog/p/alpine-minimal-btrfs-install/" >Minimal Alpine Linux on a 1 GB Btrfs Root Disk&lt;/a> I wrote down the recipe I use to build a tiny Alpine instance: boot the ISO in a VM, run &lt;code>setup-alpine&lt;/code> with &lt;code>disk=none&lt;/code>, partition by hand, &lt;code>setup-disk -m sys /mnt&lt;/code>, reboot, then &lt;code>qemu-img convert&lt;/code> the disk. It works, and I still use that post as the explanation of &lt;em>why&lt;/em> the pieces are the way they are.&lt;/p>
&lt;p>What I got tired of is the process. Every image costs a VM, a console session, and a sequence of interactive answers, and none of it is reproducible: if I want the same image with ext4 instead of btrfs, I do the whole thing again and hope I remember every step.&lt;/p>
&lt;p>So I replaced it with a script. It builds the disk image as a &lt;strong>file&lt;/strong>, on any Linux host, with no VM and no prompts. It also fixes the two things I did not like about the old recipe: the image now boots under &lt;strong>both BIOS and UEFI&lt;/strong>, and every choice is configurable.&lt;/p>
&lt;p>The bundle for this post contains everything:&lt;/p>
&lt;ul>
&lt;li>&lt;a class="link" href="mkalpine.sh" >&lt;code>mkalpine.sh&lt;/code>&lt;/a> — the builder&lt;/li>
&lt;li>&lt;a class="link" href="config.example.sh" >&lt;code>config.example.sh&lt;/code>&lt;/a> — every knob with comments&lt;/li>
&lt;li>&lt;a class="link" href="testboot.sh" >&lt;code>testboot.sh&lt;/code>&lt;/a> — boot the result under QEMU and check it came up&lt;/li>
&lt;li>&lt;a class="link" href="testmatrix.sh" >&lt;code>testmatrix.sh&lt;/code>&lt;/a> — build and boot every supported combination&lt;/li>
&lt;li>&lt;code>hooks/&lt;/code> — the customizations, one file each&lt;/li>
&lt;/ul>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">sudo ./mkalpine.sh -f alpine.img
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>That is the whole interface. About a minute later there is a 512 MiB sparse image, 102 MiB of it actually allocated, that boots on either firmware. The image is deliberately small — &lt;code>70-growroot&lt;/code> expands the root filesystem to fill whatever disk it lands on, so &lt;code>IMAGE_SIZE&lt;/code> only has to hold the build.&lt;/p>
&lt;h2 id="why-no-vm-is-needed">Why No VM Is Needed
&lt;/h2>&lt;p>The manual recipe suggests that installing Alpine requires a running Alpine. It does not. Once you look at what the installer actually does, almost all of it is file manipulation:&lt;/p>
&lt;ol>
&lt;li>Unpack a root filesystem.&lt;/li>
&lt;li>&lt;code>apk add&lt;/code> a kernel, an initramfs generator, and a bootloader.&lt;/li>
&lt;li>Write &lt;code>/etc/fstab&lt;/code>, &lt;code>/etc/inittab&lt;/code>, and the network config.&lt;/li>
&lt;li>Run &lt;code>mkinitfs&lt;/code> and &lt;code>grub-install&lt;/code>.&lt;/li>
&lt;li>Enable some OpenRC services, which is &lt;code>ln -s&lt;/code> in a directory.&lt;/li>
&lt;/ol>
&lt;p>Only steps 2 and 4 need to &lt;em>execute&lt;/em> Alpine binaries, and a chroot is enough for that. There is no step that needs a booted kernel, a real block device, or firmware.&lt;/p>
&lt;p>Alpine&amp;rsquo;s own &lt;a class="link" href="https://gitlab.alpinelinux.org/alpine/alpine-conf/-/blob/master/setup-disk.in" target="_blank" rel="noopener"
>&lt;code>setup-disk&lt;/code>&lt;/a> cannot be reused here, unfortunately. It sources &lt;code>libalpine.sh&lt;/code>, calls &lt;code>lbu package&lt;/code>, and assumes throughout that it is running on a booted live system. So &lt;code>mkalpine.sh&lt;/code> reimplements the same steps directly, following the structure of upstream &lt;a class="link" href="https://github.com/alpinelinux/alpine-make-vm-image" target="_blank" rel="noopener"
>&lt;code>alpine-make-vm-image&lt;/code>&lt;/a> and the bootloader logic from &lt;code>setup-disk&lt;/code>.&lt;/p>
&lt;p>The pipeline is:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-gdscript3" data-lang="gdscript3">&lt;span class="line">&lt;span class="cl">&lt;span class="n">truncate&lt;/span> &lt;span class="o">-&lt;/span>&lt;span class="n">s&lt;/span> &lt;span class="mi">512&lt;/span>&lt;span class="n">M&lt;/span> &lt;span class="n">image&lt;/span> &lt;span class="n">create&lt;/span> &lt;span class="n">a&lt;/span> &lt;span class="n">sparse&lt;/span> &lt;span class="n">file&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">sfdisk&lt;/span> &lt;span class="n">partition&lt;/span> &lt;span class="n">it&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">losetup&lt;/span> &lt;span class="o">-&lt;/span>&lt;span class="n">P&lt;/span> &lt;span class="n">get&lt;/span> &lt;span class="o">/&lt;/span>&lt;span class="n">dev&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="n">loopNp1&lt;/span>&lt;span class="o">..&lt;/span>&lt;span class="n">p3&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">mkfs&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">vfat&lt;/span> &lt;span class="o">/&lt;/span> &lt;span class="n">mkfs&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">btrfs&lt;/span> &lt;span class="n">make&lt;/span> &lt;span class="n">filesystems&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">mount&lt;/span> &lt;span class="n">mount&lt;/span> &lt;span class="n">root&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">then&lt;/span> &lt;span class="o">/&lt;/span>&lt;span class="n">boot&lt;/span> &lt;span class="n">inside&lt;/span> &lt;span class="n">it&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">tar&lt;/span> &lt;span class="o">-&lt;/span>&lt;span class="n">x&lt;/span> &lt;span class="n">minirootfs&lt;/span> &lt;span class="n">unpack&lt;/span> &lt;span class="n">the&lt;/span> &lt;span class="n">base&lt;/span> &lt;span class="n">system&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">chroot&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="n">apk&lt;/span> &lt;span class="n">add&lt;/span> &lt;span class="n">kernel&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">mkinitfs&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">grub&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">fs&lt;/span> &lt;span class="n">tools&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">mkinitfs&lt;/span> &lt;span class="o">/&lt;/span> &lt;span class="n">grub&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">install&lt;/span> &lt;span class="n">bootloader&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">both&lt;/span> &lt;span class="n">firmwares&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">hooks&lt;/span>&lt;span class="o">/&lt;/span> &lt;span class="n">customizations&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">fstrim&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="n">umount&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="n">losetup&lt;/span> &lt;span class="o">-&lt;/span>&lt;span class="n">d&lt;/span> &lt;span class="n">compact&lt;/span> &lt;span class="ow">and&lt;/span> &lt;span class="n">release&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">qemu&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">img&lt;/span> &lt;span class="o">/&lt;/span> &lt;span class="n">zstd&lt;/span> &lt;span class="o">/&lt;/span> &lt;span class="n">gzip&lt;/span> &lt;span class="n">optional&lt;/span> &lt;span class="n">output&lt;/span> &lt;span class="n">formats&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>QEMU is not needed to build. It is used only for &lt;code>qcow2&lt;/code> output and by the boot tests.&lt;/p>
&lt;p>One line of that pipeline is less innocent than it looks. &lt;code>losetup -P&lt;/code> asks the kernel to scan the partition table, but the &lt;code>/dev/loopNp*&lt;/code> nodes are created by &lt;em>udev&lt;/em>, after &lt;code>losetup&lt;/code> has already exited, so they have to be waited for — and on a host with no udev at all, such as a container whose &lt;code>/dev&lt;/code> is a plain tmpfs, they have to be created by hand from the device numbers the kernel publishes in &lt;code>/sys/block/loopN/loopNpM/dev&lt;/code>. Both paths are in the script, and the interesting part is the order they are tried in: reaching for &lt;code>mknod&lt;/code> early is a mistake, because when udev gets round to the same events it unlinks your node and makes its own, and for the instant in between the path does not exist. On a test host, that instant landed exactly on &lt;code>mkfs.vfat&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-fallback" data-lang="fallback">&lt;span class="line">&lt;span class="cl">mkfs.vfat: unable to open /dev/loop0p2: No such file or directory
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>So the escalation is now slow on purpose — &lt;code>udevadm settle&lt;/code>, a second, &lt;code>partx -a&lt;/code>, another second, and only then &lt;code>mknod&lt;/code> — and once the nodes do appear there is one more &lt;code>settle&lt;/code> before anything opens them, so a replacement in flight finishes before &lt;code>mkfs&lt;/code> runs rather than during it.&lt;/p>
&lt;h2 id="the-disk-layout">The Disk Layout
&lt;/h2>&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-fallback" data-lang="fallback">&lt;span class="line">&lt;span class="cl">GPT (default; the protective MBR still carries GRUB&amp;#39;s boot.img)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> # size type mount contents
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> 1 1 MiB ef02 BIOS boot - grub core.img (x86_64 only)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> 2 64 MiB ef00 ESP FAT32 /boot vmlinuz-virt, initramfs-virt,
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> grub/, EFI/BOOT/BOOTX64.EFI
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> 3 rest L Linux / btrfs (default), ext4 or xfs
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">BIOS : firmware -&amp;gt; MBR boot.img -&amp;gt; p1 core.img -&amp;gt; /boot/grub/grub.cfg
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">UEFI : firmware -&amp;gt; p2 /EFI/BOOT/BOOTX64.EFI -&amp;gt; /boot/grub/grub.cfg
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Two details are worth calling out.&lt;/p>
&lt;p>&lt;strong>&lt;code>/boot&lt;/code> is the ESP.&lt;/strong> This is not just to save a partition. It means GRUB only ever has to read FAT. Btrfs, ext4 and XFS roots all work without GRUB parsing them at all, and no on-disk feature the root filesystem gains later can break the bootloader. The old post used Syslinux on ext4 &lt;code>/boot&lt;/code>; this is less fragile.&lt;/p>
&lt;p>&lt;strong>GRUB is installed to the removable path.&lt;/strong> &lt;code>grub-install --removable --no-nvram&lt;/code> writes &lt;code>EFI/BOOT/BOOTX64.EFI&lt;/code> (or &lt;code>BOOTAA64.EFI&lt;/code>), which is what firmware boots when NVRAM has no entry for the disk. We cannot write the target machine&amp;rsquo;s NVRAM from a build host anyway, and cloud firmware is generally starting from a blank slate.&lt;/p>
&lt;p>On aarch64 the BIOS partition is omitted and only UEFI is set up: Alpine ships &lt;code>grub-efi&lt;/code> for arm64 but there is no &lt;code>grub-bios&lt;/code>, because the &lt;code>i386-pc&lt;/code> target is x86-only.&lt;/p>
&lt;h3 id="the-mbr-variant">The MBR Variant
&lt;/h3>&lt;p>Some providers&amp;rsquo; image import still rejects GPT. &lt;code>PARTITION_TABLE=mbr&lt;/code> handles that:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;span class="lnt">6
&lt;/span>&lt;span class="lnt">7
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-fallback" data-lang="fallback">&lt;span class="line">&lt;span class="cl">DOS/MBR
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> # start type mount contents
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> - sector 0 MBR + gap - grub boot.img (sector 0) and
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> core.img (the ~1 MiB gap before p1)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> 1 2048 0xEF, bootable /boot same as above
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> 2 after p1 0x83 / root
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>No dedicated BIOS boot partition is needed, because GRUB&amp;rsquo;s &lt;code>i386-pc&lt;/code> target embeds &lt;code>core.img&lt;/code> in the gap between the MBR and the first partition — which is about 1 MiB, given the 2048-sector start. UEFI still works on most firmware, since the ESP is located by partition type &lt;code>0xEF&lt;/code>. That is a widely followed convention rather than something the spec promises, so MBR mode is &amp;ldquo;BIOS guaranteed, UEFI very likely&amp;rdquo;, and GPT stays the default.&lt;/p>
&lt;h2 id="what-it-trusts">What It Trusts
&lt;/h2>&lt;p>The bootstrap is a single &lt;code>alpine-minirootfs&lt;/code> tarball, resolved from &lt;code>latest-releases.yaml&lt;/code> for the branch:&lt;/p>
&lt;ul>
&lt;li>TLS gets the file from the mirror.&lt;/li>
&lt;li>Alpine publishes a &lt;code>.sha256&lt;/code> sidecar next to every release artifact. The build fails and deletes the download on a mismatch.&lt;/li>
&lt;li>Everything after that is &lt;code>apk&lt;/code>, with Alpine&amp;rsquo;s signing keys, from the tarball&amp;rsquo;s own keyring.&lt;/li>
&lt;/ul>
&lt;p>I use the minirootfs rather than a pinned &lt;code>apk.static&lt;/code> deliberately: a hardcoded &lt;code>apk.static&lt;/code> checksum drifts out of sync with the branch, and the in-image &lt;code>apk&lt;/code> always matching the branch matters now that 3.23+ ships apk-tools 3.&lt;/p>
&lt;p>&lt;code>ALPINE_BRANCH=latest-stable&lt;/code> is the default and gets resolved once, at build time. The repositories written &lt;em>into&lt;/em> the image always point at the concrete branch (&lt;code>v3.24&lt;/code>), never at &lt;code>latest-stable&lt;/code>, so a later &lt;code>apk upgrade&lt;/code> on the running machine does not silently jump to the next stable release.&lt;/p>
&lt;h2 id="configuration">Configuration
&lt;/h2>&lt;p>Copy the example and edit it; &lt;code>./config.sh&lt;/code> is picked up automatically.&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">cp config.example.sh config.sh
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">$EDITOR&lt;/span> config.sh
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">sudo ./mkalpine.sh
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Every variable is &lt;code>: &amp;quot;${VAR:=default}&amp;quot;&lt;/code> in the script, so the environment works too, which is what I use for one-offs:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">sudo &lt;span class="nv">IMAGE_SIZE&lt;/span>&lt;span class="o">=&lt;/span>2G &lt;span class="nv">ROOT_FS&lt;/span>&lt;span class="o">=&lt;/span>xfs ./mkalpine.sh out.img
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Secrets default to &lt;em>nothing&lt;/em>. &lt;code>ROOT_PASSWORD_HASH=&amp;quot;&amp;quot;&lt;/code> locks the root account, so the failure mode of forgetting to set it is &amp;ldquo;cannot log in&amp;rdquo;, not &amp;ldquo;known root password&amp;rdquo;, and the example hash ships commented out next to the &lt;code>openssl passwd -6&lt;/code> that generates one. &lt;code>config.sh&lt;/code> and the image files are in &lt;code>.gitignore&lt;/code>.&lt;/p>
&lt;p>If the build host reaches the internet through a proxy, an exported &lt;code>http_proxy&lt;/code> / &lt;code>https_proxy&lt;/code> / &lt;code>no_proxy&lt;/code> is picked up as-is, and &lt;code>BUILD_HTTP_PROXY&lt;/code> / &lt;code>BUILD_HTTPS_PROXY&lt;/code> / &lt;code>BUILD_NO_PROXY&lt;/code> set one for the build alone. It covers both halves of the download: the minirootfs tarball on the host, and &lt;code>apk&lt;/code> plus whatever a hook fetches inside the chroot. Both cases of each name are set in the chroot&amp;rsquo;s environment, because curl reads only the lowercase &lt;code>http_proxy&lt;/code> while apk and git take either — and they go into the &lt;em>environment&lt;/em> rather than a profile script, so nothing about the proxy survives into the image. A proxy reachable from the build host is usually not reachable from wherever the image is deployed, and the URL frequently carries credentials.&lt;/p>
&lt;h2 id="choosing-a-root-filesystem">Choosing A Root Filesystem
&lt;/h2>&lt;p>&lt;code>ROOT_FS&lt;/code> takes &lt;code>btrfs&lt;/code>, &lt;code>ext4&lt;/code> or &lt;code>xfs&lt;/code>, each with its own mkfs and mount option strings:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>&lt;code>ROOT_FS&lt;/code>&lt;/th>
&lt;th>&lt;code>ROOT_MKFS_OPTS&lt;/code>&lt;/th>
&lt;th>&lt;code>ROOT_MOUNT_OPTS&lt;/code>&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>btrfs&lt;/code>&lt;/td>
&lt;td>&lt;code>-L alpine-root -K&lt;/code>&lt;/td>
&lt;td>&lt;code>rw,noatime,compress=zstd:3,ssd,discard=async,space_cache=v2&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>ext4&lt;/code>&lt;/td>
&lt;td>&lt;code>-L alpine-root -m 1 -E nodiscard&lt;/code>&lt;/td>
&lt;td>&lt;code>rw,noatime,commit=60&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>xfs&lt;/code>&lt;/td>
&lt;td>&lt;code>-L alpine-root&lt;/code>&lt;/td>
&lt;td>&lt;code>rw,noatime,logbsize=256k&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;code>-K&lt;/code> and &lt;code>-E nodiscard&lt;/code> skip the discard pass at mkfs time, which is pointless on a sparse file and only produces confusing errors.&lt;/p>
&lt;p>Btrfs with zstd stays the default for the same reason as in the old post — on a small disk, transparent compression is worth a lot.&lt;/p>
&lt;h3 id="tuning-mkfs-for-the-storage-underneath">Tuning mkfs For The Storage Underneath
&lt;/h3>&lt;p>The point of exposing &lt;code>ROOT_MKFS_OPTS&lt;/code> is that a cloud disk is rarely a plain disk. &lt;code>config.example.sh&lt;/code> carries these examples.&lt;/p>
&lt;p>Aligning to a 16 KiB ZFS zvol (&lt;code>volblocksize=16k&lt;/code>):&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;span class="lnt">13
&lt;/span>&lt;span class="lnt">14
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-sh" data-lang="sh">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ext4: the block size cannot exceed the kernel page size (4 KiB on x86_64),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># so -b 16384 produces a filesystem that will not mount. Align with&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># stride/stripe_width instead: stride = 16K / 4K = 4.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">ROOT_MKFS_OPTS&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;-L alpine-root -m 1 -E nodiscard,stride=4,stripe_width=4&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># or, if you really do want 16 KiB allocation clusters:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">ROOT_MKFS_OPTS&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;-L alpine-root -m 1 -O bigalloc -C 16384&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># xfs: takes the stripe unit directly.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">ROOT_MKFS_OPTS&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;-L alpine-root -d su=16k,sw=1&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># btrfs: nodesize is already 16K; set the sector size explicitly if the host&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># page size differs from the target&amp;#39;s.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">ROOT_MKFS_OPTS&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;-L alpine-root -K -s 4096&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Aligning to RAID6 with 6 data disks and a 128 KiB chunk:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;span class="lnt">6
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-sh" data-lang="sh">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ext4: stride = chunk / block = 128K / 4K = 32&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># stripe_width = stride * data disks = 32 * 4 = 128&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">ROOT_MKFS_OPTS&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;-L alpine-root -m 1 -E nodiscard,stride=32,stripe_width=128&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># xfs: su = chunk, sw = number of data disks&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">ROOT_MKFS_OPTS&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;-L alpine-root -d su=128k,sw=4&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h3 id="tuning-mount-options">Tuning Mount Options
&lt;/h3>&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;span class="lnt">13
&lt;/span>&lt;span class="lnt">14
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-sh" data-lang="sh">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Favour throughput over sync latency. You lose more recent writes on an&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># unclean shutdown, which is fine for a rebuildable instance and not fine&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># for a database.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">ROOT_MOUNT_OPTS&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;rw,noatime,commit=60,data=writeback&amp;#34;&lt;/span> &lt;span class="c1"># ext4&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">ROOT_MOUNT_OPTS&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;rw,noatime,logbsize=256k,allocsize=1m&amp;#34;&lt;/span> &lt;span class="c1"># xfs&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">ROOT_MOUNT_OPTS&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;rw,noatime,commit=120,compress=zstd:1,ssd,discard=async,space_cache=v2&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Favour density on a tiny disk. zstd:6 costs CPU on write; decompression&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># stays cheap at any level.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">ROOT_MOUNT_OPTS&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;rw,noatime,compress=zstd:6,ssd,discard=async,space_cache=v2&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Drop discard=async and rely on the weekly fstrim job instead, if your&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># provider&amp;#39;s thin pool behaves badly with continuous discards.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">ROOT_MOUNT_OPTS&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;rw,noatime,compress=zstd:3,ssd,space_cache=v2&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>These options are used to mount the image &lt;em>during the build&lt;/em> as well as being written to &lt;code>/etc/fstab&lt;/code>, so a typo fails the build instead of the first boot. There is exactly one deliberate difference between the two, and it is the subject of &lt;a class="link" href="#compression-needs-forcing-at-build-time" >Compression Needs Forcing At Build Time&lt;/a> below.&lt;/p>
&lt;h3 id="the-mount-option-gotcha">The Mount Option Gotcha
&lt;/h3>&lt;p>This one cost me a while, and it is the reason the boot test in the bundle checks mount options rather than trusting them.&lt;/p>
&lt;p>&lt;code>ROOT_MOUNT_OPTS&lt;/code> in &lt;code>/etc/fstab&lt;/code> is &lt;strong>not enough&lt;/strong>. The root filesystem is mounted by the initramfs, long before &lt;code>/etc/fstab&lt;/code> exists, and the &lt;code>mount -o remount,rw /&lt;/code> that OpenRC does afterwards cannot change every option. Most are remountable, so &lt;code>noatime&lt;/code> and &lt;code>commit=60&lt;/code> looked fine. XFS, however, fixes the log buffer size at initial mount:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-fallback" data-lang="fallback">&lt;span class="line">&lt;span class="cl"># what I asked for
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ROOT_MOUNT_OPTS=&amp;#34;rw,noatime,logbsize=256k&amp;#34;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"># what the running machine actually had
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">rw,noatime,inode64,logbufs=8,logbsize=32k,noquota
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Silently downgraded to the 32k default, with nothing in &lt;code>dmesg&lt;/code> about it. Mounting the same image on the build host with the same options honoured &lt;code>logbsize=256k&lt;/code>, which made it look like a kernel difference rather than what it was.&lt;/p>
&lt;p>The fix is to put the options on the kernel command line too, so that the &lt;em>initial&lt;/em> mount is the one I want:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-sh" data-lang="sh">&lt;span class="line">&lt;span class="cl">&lt;span class="nv">ROOTFLAGS&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="k">$(&lt;/span>&lt;span class="nb">echo&lt;/span> &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$FSTAB_ROOT_OPTS&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="p">|&lt;/span> tr &lt;span class="s1">&amp;#39;,&amp;#39;&lt;/span> &lt;span class="s1">&amp;#39;\n&amp;#39;&lt;/span> &lt;span class="p">|&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> grep -vx -e rw -e ro &lt;span class="p">|&lt;/span> tr &lt;span class="s1">&amp;#39;\n&amp;#39;&lt;/span> &lt;span class="s1">&amp;#39;,&amp;#39;&lt;/span> &lt;span class="p">|&lt;/span> sed &lt;span class="s1">&amp;#39;s/,*$//&amp;#39;&lt;/span>&lt;span class="k">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="o">[&lt;/span> -n &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$ROOTFLAGS&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="o">]&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nv">CMDLINE&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$CMDLINE&lt;/span>&lt;span class="s2"> rootflags=&lt;/span>&lt;span class="nv">$ROOTFLAGS&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>&lt;code>rw&lt;/code> and &lt;code>ro&lt;/code> are dropped because the kernel handles those separately and GRUB already passes &lt;code>ro&lt;/code>. With Btrfs you end up with two &lt;code>rootflags=&lt;/code> on the cmdline, because &lt;code>grub-mkconfig&lt;/code>&amp;rsquo;s &lt;code>10_linux&lt;/code> detects the subvolume and emits its own; ours is appended after GRUB&amp;rsquo;s and the initramfs takes the last one.&lt;/p>
&lt;p>Now:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-fallback" data-lang="fallback">&lt;span class="line">&lt;span class="cl">rw,noatime,inode64,logbufs=8,logbsize=256k,noquota
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h3 id="btrfs-subvolumes">Btrfs Subvolumes
&lt;/h3>&lt;p>&lt;code>BTRFS_SUBVOL=&amp;quot;@&amp;quot;&lt;/code> by default. It costs three lines at build time, and retrofitting a root subvolume later means moving every file, so it is worth doing even on a machine too small to keep many snapshots. Compression is also set with &lt;code>btrfs property set&lt;/code>, so it holds regardless of mount options. &lt;code>BTRFS_SUBVOL=&amp;quot;&amp;quot;&lt;/code> gives the top-level layout from the old post. GRUB does not care either way, because &lt;code>/boot&lt;/code> is FAT.&lt;/p>
&lt;h3 id="compression-needs-forcing-at-build-time">Compression Needs Forcing At Build Time
&lt;/h3>&lt;p>&lt;code>compress=zstd:3&lt;/code> does not compress everything. Btrfs decides per file, from the beginning of the file, whether compressing is worth it — and on ELF binaries it decides wrong. In a default build, 45 MiB out of 83 MiB was stored uncompressed, including &lt;code>libcrypto.so.3&lt;/code> (3.3 MiB), every &lt;code>grub-*&lt;/code> tool, &lt;code>busybox&lt;/code> and &lt;code>ld-musl&lt;/code>. All of those compress to roughly half. &lt;code>btrfs property set&lt;/code> does not help here either: a file carrying only the inode flag still goes through the same heuristic.&lt;/p>
&lt;p>&lt;code>compress-force&lt;/code> skips the decision. The build mounts the root with it, while &lt;code>/etc/fstab&lt;/code> and &lt;code>rootflags=&lt;/code> keep plain &lt;code>compress=&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-sh" data-lang="sh">&lt;span class="line">&lt;span class="cl">&lt;span class="nv">BUILD_ROOT_OPTS&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="k">$(&lt;/span>&lt;span class="nb">printf&lt;/span> &lt;span class="s1">&amp;#39;%s&amp;#39;&lt;/span> &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$ROOT_MOUNT_OPTS&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="p">|&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> sed &lt;span class="s1">&amp;#39;s/^compress=/compress-force=/; s/,compress=/,compress-force=/&amp;#39;&lt;/span>&lt;span class="k">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>That is the one place where what the build mounts deliberately differs from what the image ships. It takes 62 MiB of data on disk down to 56 MiB, the raw image from 108 MiB to 102 MiB, and &lt;code>df&lt;/code> on the booted machine from 74.5 MiB used to 67.9 MiB; &lt;code>BTRFS_FORCE_COMPRESS=no&lt;/code> restores the old behaviour. The build prints what it achieved —&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-fallback" data-lang="fallback">&lt;span class="line">&lt;span class="cl">root data 55M on disk for 86M of files (64%)
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>— because the answer depends on what the image installs, and the failure mode is otherwise silent: the mount options, and &lt;code>findmnt&lt;/code>, look exactly the same whether the files got compressed or not.&lt;/p>
&lt;p>The heuristic is left alone for the &lt;em>running&lt;/em> system on purpose. It exists to avoid burning CPU on data that will not compress, and this image contains about 10 MiB of exactly that: Alpine ships kernel modules as &lt;code>.ko.gz&lt;/code>, and &lt;code>xfs.ko.gz&lt;/code>, &lt;code>btrfs.ko.gz&lt;/code> and friends stay uncompressed whether you force it or not. Forcing only recovers the files the heuristic misjudged.&lt;/p>
&lt;p>Running &lt;code>btrfs filesystem defragment -r -czstd&lt;/code> over the finished tree is the other way to get here, and it is the worse one. It lands at 58 MiB rather than 56 MiB, the sparse image balloons to 168 MiB before &lt;code>fstrim&lt;/code> claws it back, and despite the name it does not defragment anything — compressed extents cap at 128 KiB, so the extent count went &lt;em>up&lt;/em>, 1996 → 2082.&lt;/p>
&lt;p>One caveat if you ship a compressed artifact rather than the raw image: compressing inside the image makes the outer compressor&amp;rsquo;s job harder, and the outer compressor is better at it. Forcing shrank the raw image by 5.6% and grew every compressed output — &lt;code>raw.zst&lt;/code> 74 → 77 MiB, &lt;code>raw.xz&lt;/code> 72 → 76 MiB. If upload size is the thing you actually care about, &lt;code>BTRFS_FORCE_COMPRESS=no&lt;/code> is the right setting, and the build says as much when you ask for both.&lt;/p>
&lt;h2 id="sizing-boot">Sizing &lt;code>/boot&lt;/code>
&lt;/h2>&lt;p>&lt;code>BOOT_SIZE=64M&lt;/code>. Measured usage with one kernel is 36 MiB:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;span class="lnt">6
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-fallback" data-lang="fallback">&lt;span class="line">&lt;span class="cl">13 M vmlinuz-virt
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">9.4M initramfs-virt
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">8.3M grub/ (i386-pc and x86_64-efi modules)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">6.2M System.map-6.18.48-0-virt
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">156K EFI/BOOT/BOOTX64.EFI
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">149K config-6.18.48-0-virt
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>That leaves headroom for one kernel and no more. Raise it to &lt;code>128M&lt;/code> if you want to keep a second kernel around — &lt;code>linux-lts&lt;/code> alongside &lt;code>linux-virt&lt;/code>, say — or if you want the previous kernel to survive an &lt;code>apk upgrade&lt;/code> so there is something to fall back to. The build prints &lt;code>/boot&lt;/code> usage at the end and warns when it lands above 80%.&lt;/p>
&lt;h2 id="output-formats">Output Formats
&lt;/h2>&lt;p>&lt;code>OUTPUT_FORMATS&lt;/code> is a space-separated list; each entry produces one file next to the raw image.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Entry&lt;/th>
&lt;th>Produced by&lt;/th>
&lt;th style="text-align: right">Size&lt;/th>
&lt;th>Note&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>raw&lt;/code>&lt;/td>
&lt;td>the build itself&lt;/td>
&lt;td style="text-align: right">102 MiB&lt;/td>
&lt;td>sparse; 512 MiB apparent&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>qcow2&lt;/code>&lt;/td>
&lt;td>&lt;code>qemu-img convert -c -O qcow2&lt;/code>&lt;/td>
&lt;td style="text-align: right">81 MiB&lt;/td>
&lt;td>compressed qcow2&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>raw.zst&lt;/code>&lt;/td>
&lt;td>&lt;code>zstd -19 -T0&lt;/code>&lt;/td>
&lt;td style="text-align: right">77 MiB&lt;/td>
&lt;td>best ratio for the time; widely accepted&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>raw.gz&lt;/code>&lt;/td>
&lt;td>&lt;code>gzip -9&lt;/code>&lt;/td>
&lt;td style="text-align: right">80 MiB&lt;/td>
&lt;td>widest provider support&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>raw.xz&lt;/code>&lt;/td>
&lt;td>&lt;code>xz -9 -T0&lt;/code>&lt;/td>
&lt;td style="text-align: right">76 MiB&lt;/td>
&lt;td>smallest, slowest&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The sizes are from one build of the same default btrfs image, so they are comparable to each other rather than absolute. Note how little the four compressed formats differ — 76 to 81 MiB, under 7% between the best and the worst: the root filesystem is &lt;em>already&lt;/em> zstd-compressed, so the outer compressor is mostly squeezing free space. &lt;code>raw.gz&lt;/code> is a perfectly reasonable default in exchange for its compatibility.&lt;/p>
&lt;p>That is also why &lt;code>BTRFS_FORCE_COMPRESS&lt;/code> cuts the other way here. Every one of these numbers except &lt;code>qcow2&lt;/code> is &lt;em>larger&lt;/em> than it would be with forcing off — &lt;code>raw.xz&lt;/code> most of all, 76 MiB against 72 MiB — because data that btrfs already compressed at zstd:3 in 128 KiB blocks is data &lt;code>xz -9&lt;/code> cannot compress again. Forcing wins on &lt;code>raw&lt;/code> and loses on everything else, so the build prints a reminder when you ask for both.&lt;/p>
&lt;p>Drop &lt;code>raw&lt;/code> from the list to keep only the compressed artifacts. For other hypervisors, convert the raw image yourself:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">qemu-img convert -O vmdk out.img out.vmdk &lt;span class="c1"># VMware&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">qemu-img convert -O vpc out.img out.vhd &lt;span class="c1"># Hyper-V / Azure&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">qemu-img convert -O vdi out.img out.vdi &lt;span class="c1"># VirtualBox&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h2 id="what-must-not-survive-cloning">What Must Not Survive Cloning
&lt;/h2>&lt;p>A disk image is a template that gets cloned N times, so anything unique baked into it stops being unique. This is the part a hand-built VM image usually gets wrong, and it is why I wanted a script in the first place — I am not going to remember all of this at 1am on a provider&amp;rsquo;s web console.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Thing&lt;/th>
&lt;th>Why it matters&lt;/th>
&lt;th>What the script does&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>/etc/ssh/ssh_host_*&lt;/code>&lt;/td>
&lt;td>Every VM built from the image shares one host key. Anyone holding the image can impersonate all of them, and clients get key-mismatch warnings after the first deploy.&lt;/td>
&lt;td>Delete. Regenerated on first boot.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/var/lib/seedrng/seed.credential&lt;/code> (3.17+; &lt;code>/var/lib/random-seed&lt;/code> before)&lt;/td>
&lt;td>The saved RNG seed is restored early at boot. An identical seed on every clone means the entropy pool starts identical — including for the host keys in the row above.&lt;/td>
&lt;td>Delete.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/etc/machine-id&lt;/code>&lt;/td>
&lt;td>Used to derive DHCP DUIDs and app-level instance identity, so clones can collide on DHCP leases.&lt;/td>
&lt;td>Truncate to &lt;strong>zero bytes&lt;/strong>, not delete: an empty file is the documented &amp;ldquo;generate on next boot&amp;rdquo; signal, while a missing one makes some tools fail instead.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/etc/resolv.conf&lt;/code>&lt;/td>
&lt;td>Would otherwise ship the build host&amp;rsquo;s nameservers.&lt;/td>
&lt;td>Rewritten from &lt;code>$DNS&lt;/code>.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/var/cache/apk/*&lt;/code>, &lt;code>/var/log/*&lt;/code>, shell history&lt;/td>
&lt;td>Dead weight and build-host leakage.&lt;/td>
&lt;td>Cleared.&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Filesystem UUIDs &lt;em>do&lt;/em> stay identical across clones. Regenerating them on first boot means rewriting &lt;code>fstab&lt;/code> and &lt;code>grub.cfg&lt;/code> from a running system, which is more fragile than the problem it solves — it only bites if you attach two clones to the same host.&lt;/p>
&lt;p>The &lt;code>80-firstboot&lt;/code> hook is the other half of this. It runs in the &lt;code>boot&lt;/code> runlevel, before &lt;code>networking&lt;/code> and &lt;code>sshd&lt;/code>, and generates what was stripped:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-sh" data-lang="sh">&lt;span class="line">&lt;span class="cl">head -c &lt;span class="m">16&lt;/span> /dev/urandom &lt;span class="p">|&lt;/span> od -An -tx1 &lt;span class="p">|&lt;/span> tr -d &lt;span class="s1">&amp;#39; \n&amp;#39;&lt;/span> &amp;gt;/etc/machine-id
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ssh-keygen -A
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>It also runs &lt;code>after hostname&lt;/code>, so the host keys are commented &lt;code>root@alpine&lt;/code> rather than &lt;code>root@(none)&lt;/code>. Cosmetic, but the alternative bothered me.&lt;/p>
&lt;p>Finally, the build stamps &lt;code>/etc/image-release&lt;/code> with the Alpine version, architecture, build date, builder git commit, and the resolved filesystem and hook configuration. Six months later, working out which build a running VM came from is otherwise guesswork.&lt;/p>
&lt;h2 id="staying-able-to-get-in">Staying Able To Get In
&lt;/h2>&lt;p>The single most valuable property of a cloud image is that you can get into it when networking is broken, because the provider&amp;rsquo;s serial console is then your only channel.&lt;/p>
&lt;ul>
&lt;li>&lt;code>console=ttyS0,115200&lt;/code> on x86_64, &lt;code>console=ttyAMA0,115200&lt;/code> on aarch64, with the getty in &lt;code>/etc/inittab&lt;/code> &lt;strong>and&lt;/strong> the tty listed in &lt;code>/etc/securetty&lt;/code>. Without the &lt;code>securetty&lt;/code> entry, root login on that console is refused and looks exactly like a wrong password.&lt;/li>
&lt;li>&lt;code>GRUB_TERMINAL=&amp;quot;console serial&amp;quot;&lt;/code> plus &lt;code>GRUB_SERIAL_COMMAND&lt;/code>, so the &lt;em>bootloader&lt;/em> is reachable over serial too. That is what lets you fix a bad kernel cmdline or boot an older kernel remotely.&lt;/li>
&lt;li>&lt;strong>No &lt;code>quiet&lt;/code>.&lt;/strong> Alpine&amp;rsquo;s &lt;code>setup-disk&lt;/code> defaults to &lt;code>KERNELOPTS=quiet&lt;/code>; on a machine whose only debugging channel is the serial console, hiding the boot log is the wrong trade.&lt;/li>
&lt;li>&lt;code>GRUB_TIMEOUT=1&lt;/code> rather than &lt;code>0&lt;/code>, so there is a window to interrupt.&lt;/li>
&lt;/ul>
&lt;p>&lt;code>grub.cfg&lt;/code> is generated by &lt;code>grub-mkconfig&lt;/code> rather than hand-written, which matters more than it looks: Alpine&amp;rsquo;s grub package carries &lt;code>triggers=&amp;quot;grub.trigger=/boot&amp;quot;&lt;/code>, so a later &lt;code>apk upgrade linux-virt&lt;/code> regenerates it and the image stays bootable with no intervention. A static config would just be silently overwritten by that same trigger. There is a hand-written fallback for the case where &lt;code>grub-probe&lt;/code> cannot cope with the loop device, but I have not needed it.&lt;/p>
&lt;h2 id="hooks">Hooks
&lt;/h2>&lt;p>One ordered list, in &lt;code>HOOKS&lt;/code>. Remove a name to disable it; drop a file into &lt;code>hooks/&lt;/code> and add its name to extend. Each hook is a standalone &lt;code>sh&lt;/code> script run inside the chroot with the configuration exported into its environment.&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-sh" data-lang="sh">&lt;span class="line">&lt;span class="cl">&lt;span class="nv">HOOKS&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;10-network 20-ssh 30-chrony 40-zram 50-logtruncate 60-sysctl 70-growroot 80-firstboot&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Hook&lt;/th>
&lt;th>Does&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>10-network&lt;/code>&lt;/td>
&lt;td>&lt;code>/etc/network/interfaces&lt;/code>, DHCP or static, optional real DHCPv6&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>20-ssh&lt;/code>&lt;/td>
&lt;td>&lt;code>PermitRootLogin&lt;/code>, port, keepalives, &lt;code>authorized_keys&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>30-chrony&lt;/code>&lt;/td>
&lt;td>chrony with &lt;code>makestep 1.0 -1&lt;/code> and a configurable pool&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>40-zram&lt;/code>&lt;/td>
&lt;td>zram swap sized from RAM at boot, optionally &lt;code>/tmp&lt;/code> too&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>50-logtruncate&lt;/code>&lt;/td>
&lt;td>the hourly log cap from the old post, plus a daily apk cache clean&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>60-sysctl&lt;/code>&lt;/td>
&lt;td>zram VM tunables, BBR and fq&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>70-growroot&lt;/code>&lt;/td>
&lt;td>one-shot service: &lt;code>growpart&lt;/code>, then grow the filesystem&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>80-firstboot&lt;/code>&lt;/td>
&lt;td>machine-id and SSH host key generation&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Off by default, because they are opinionated rather than essential: &lt;code>90-tools&lt;/code> (bash, coreutils, iproute2, tcpdump, htop, tmux, vim and friends — about 120 MiB), &lt;code>91-ufw&lt;/code>, &lt;code>92-sshguard&lt;/code>, &lt;code>93-podman&lt;/code>, &lt;code>94-cloud-init&lt;/code>, and &lt;code>95-dotfiles&lt;/code> (git, zsh, lsd and a dotfiles repo, with zsh as root&amp;rsquo;s login shell).&lt;/p>
&lt;p>&lt;code>91-ufw&lt;/code>, &lt;code>92-sshguard&lt;/code> and &lt;code>95-dotfiles&lt;/code> are cheap enough to add at the default &lt;code>IMAGE_SIZE&lt;/code> on any filesystem. The heavier three are where the compression default starts paying for itself: &lt;code>90-tools&lt;/code> + &lt;code>93-podman&lt;/code> + &lt;code>94-cloud-init&lt;/code> together fit on btrfs at 512 MiB — 274 MiB allocated — and run the 381 MiB XFS root out of space partway through installing podman. Raise &lt;code>IMAGE_SIZE&lt;/code> if you want them on ext4 or XFS.&lt;/p>
&lt;p>A few implementation notes:&lt;/p>
&lt;p>&lt;strong>&lt;code>20-ssh&lt;/code> rewrites existing lines rather than appending.&lt;/strong> &lt;code>sshd_config&lt;/code> takes the &lt;em>first&lt;/em> occurrence of a keyword, so appending &lt;code>PermitRootLogin prohibit-password&lt;/code> to a file that already contains a commented-out default works, and appending it to one that has an active setting silently does nothing. The hook edits in place.&lt;/p>
&lt;p>&lt;strong>&lt;code>50-logtruncate&lt;/code> truncates rather than renames.&lt;/strong> Keeping one &lt;code>.0&lt;/code> copy and then &lt;code>truncate -s 0&lt;/code> on the original preserves the inode, so daemons holding the file open keep writing to it. This is the script from the old post, unchanged.&lt;/p>
&lt;p>&lt;strong>&lt;code>70-growroot&lt;/code> uses &lt;code>growpart&lt;/code>, not &lt;code>sfdisk&lt;/code>.&lt;/strong> Growing a GPT disk also requires relocating the backup header to the new end of the device, and &lt;code>growpart&lt;/code> keeps the partition&amp;rsquo;s &lt;em>start&lt;/em> sector untouched, so whatever alignment the image was built with survives. After that it is &lt;code>btrfs filesystem resize max /&lt;/code>, &lt;code>resize2fs&lt;/code> or &lt;code>xfs_growfs&lt;/code> depending on &lt;code>ROOT_FS&lt;/code>, then a stamp file and &lt;code>rc-update del growroot default&lt;/code>.&lt;/p>
&lt;p>&lt;strong>&lt;code>91-ufw&lt;/code> exploits the fact that ufw only touches netfilter when enabled.&lt;/strong> All the rules are recorded inside the chroot with &lt;code>ufw&lt;/code> itself, and then &lt;code>ENABLED=yes&lt;/code> is set in &lt;code>ufw.conf&lt;/code> as the last step. No netfilter calls happen during the build.&lt;/p>
&lt;p>&lt;strong>&lt;code>95-dotfiles&lt;/code> is the only hook that needs the network for something other than the Alpine mirror.&lt;/strong> It shallow-clones &lt;code>DOTFILES_REPO&lt;/code>, runs the repo&amp;rsquo;s own &lt;code>bootstrap.sh&lt;/code>, and switches root&amp;rsquo;s login shell. Two parts of that were more interesting than expected.&lt;/p>
&lt;p>Changing the login shell, first: &lt;code>chsh&lt;/code> is not in a minimal Alpine — it lives in &lt;code>shadow&lt;/code>, which nothing else here wants — so the hook rewrites field 7 of root&amp;rsquo;s &lt;code>/etc/passwd&lt;/code> line directly. Writing the new file elsewhere and &lt;code>cat&lt;/code>-ing it back keeps the original inode, mode and owner, which a &lt;code>mv&lt;/code> would not:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-sh" data-lang="sh">&lt;span class="line">&lt;span class="cl">awk -F: -v &lt;span class="nv">OFS&lt;/span>&lt;span class="o">=&lt;/span>: -v &lt;span class="nv">shell&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$login_shell&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> &lt;span class="s1">&amp;#39;$1 == &amp;#34;root&amp;#34; { $7 = shell } { print }&amp;#39;&lt;/span> /etc/passwd &amp;gt;/tmp/passwd.new
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">cat /tmp/passwd.new &amp;gt;/etc/passwd
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Second, my dotfiles use &lt;a class="link" href="https://github.com/romkatv/zsh4humans" target="_blank" rel="noopener"
>zsh4humans&lt;/a>, which installs itself — plugins, plus prebuilt &lt;code>fzf&lt;/code> and &lt;code>gitstatusd&lt;/code> binaries — the first time an interactive zsh starts. Doing that during the build instead buys two things: the shell works on a machine with no internet, and a download failure fails the build rather than the first login.&lt;/p>
&lt;p>It costs about 67 MiB of files — 26 MiB of packages, 5 MiB of checkout, 28 MiB of z4h cache — which is 21 MiB of actual disk on the compressed btrfs default. &lt;code>99-selftest&lt;/code> checks it the way that matters: it starts an interactive zsh on the booted machine and confirms the repo&amp;rsquo;s &lt;code>.zshrc&lt;/code> really was sourced.&lt;/p>
&lt;p>Being the only hook that talks to GitHub also makes it the one most likely to fail a build. Set &lt;code>http_proxy&lt;/code> and &lt;code>https_proxy&lt;/code> when the path to GitHub is reliably bad rather than occasionally bad.&lt;/p>
&lt;p>&lt;strong>Weekly &lt;code>fstrim&lt;/code> is installed regardless of &lt;code>ROOT_FS&lt;/code>.&lt;/strong> Although a btrfs root already trims itself through &lt;code>discard=async&lt;/code>, the job is not all about the root filesystem. A data disk attached to the instance later is quite likely to be ext4 or XFS, and nothing else in the image would ever trim it. For those, periodic trim is the currently recommended approach over &lt;code>-o discard&lt;/code>.&lt;/p>
&lt;p>The job also cannot be a one-liner, because a minimal Alpine image does not have util-linux:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;span class="lnt">6
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-sh" data-lang="sh">&lt;span class="line">&lt;span class="cl">fstrim -a 2&amp;gt;/dev/null &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nb">exit&lt;/span> &lt;span class="m">0&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">awk &lt;span class="s1">&amp;#39;$1 ~ /^\/dev\// &amp;amp;&amp;amp; !seen[$2]++ { print $2 }&amp;#39;&lt;/span> /proc/mounts &lt;span class="p">|&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">while&lt;/span> &lt;span class="nv">IFS&lt;/span>&lt;span class="o">=&lt;/span> &lt;span class="nb">read&lt;/span> -r mp&lt;span class="p">;&lt;/span> &lt;span class="k">do&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> fstrim &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$mp&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> 2&amp;gt;/dev/null
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">done&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>&lt;code>fstrim -a&lt;/code> walks every mounted filesystem and de-duplicates devices, but that is util-linux&amp;rsquo;s &lt;code>fstrim&lt;/code>. Busybox&amp;rsquo;s applet takes exactly one mount point and has no &lt;code>-a&lt;/code> at all, so on the image as built the &lt;code>-a&lt;/code> form fails and the loop does the work. My original &lt;code>fstrim -a || fstrim /&lt;/code> looked like it handled that and did not: the fallback trimmed only the root, which is precisely the filesystem that needed it least.&lt;/p>
&lt;h3 id="writing-your-own">Writing Your Own
&lt;/h3>&lt;p>Drop a script in &lt;code>hooks/&lt;/code>, add its name to &lt;code>HOOKS&lt;/code>. The whole &lt;code>hooks/&lt;/code> directory is copied into the chroot at &lt;code>/tmp/mkalpine&lt;/code>, so a hook that needs to install a longer file can keep it in &lt;code>hooks/files/&lt;/code> and copy it from &lt;code>/tmp/mkalpine/files/&lt;/code> rather than embedding it in a heredoc:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-sh" data-lang="sh">&lt;span class="line">&lt;span class="cl">&lt;span class="cp">#!/bin/sh
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="cp">&lt;/span>&lt;span class="nb">set&lt;/span> -eu
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">apk add --quiet --no-progress my-thing
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">install -m &lt;span class="m">0644&lt;/span> /tmp/mkalpine/files/my-thing.conf /etc/my-thing.conf
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">rc-update add my-thing default
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>The number prefixes are a label, not a mechanism. This is not &lt;code>run-parts&lt;/code>: nothing sorts the directory, and the run order is simply the order of the &lt;code>HOOKS&lt;/code> list, so &lt;code>HOOKS=&amp;quot;10-network my-thing 80-firstboot&amp;quot;&lt;/code> does exactly what it looks like. Your hook can be called anything, go anywhere in the list, and it does not matter that the shipped extras have crowded the 90s — &lt;code>99-selftest&lt;/code> runs last because it is last in the list, not because of its number.&lt;/p>
&lt;p>Two things about the environment a hook runs in. Everything in &lt;code>config.sh&lt;/code> is exported into it, but nothing else is: the chroot starts from &lt;code>env -i&lt;/code>, so a hook sees &lt;code>PATH&lt;/code>, &lt;code>HOME&lt;/code>, &lt;code>TERM&lt;/code>, the config, and a proxy if one is configured. And it has no controlling terminal and &lt;code>/dev/null&lt;/code> on stdin, for the reasons in &lt;code>95-dotfiles&lt;/code> above, so a hook cannot stop a build to ask a question — a program that tries gets EOF and fails, which is a build that ends with an error rather than one that waits all night.&lt;/p>
&lt;h2 id="verifying-the-image">Verifying The Image
&lt;/h2>&lt;p>An image that builds is not an image that boots, and an image that boots is not an image that is configured the way you asked. So there are two scripts.&lt;/p>
&lt;h3 id="does-it-boot">Does It Boot
&lt;/h3>&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">./testboot.sh -m uefi alpine.img
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">./testboot.sh -m bios alpine.img
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>This boots the image headless under QEMU with &lt;code>-snapshot&lt;/code>, so the image is never modified, captures the serial console to a file, and waits for a &lt;code>login:&lt;/code> prompt. Reaching the login prompt exercises the whole chain in one go: firmware → GRUB → kernel → initramfs → root mount → OpenRC → getty. On failure it prints the last 40 lines of the console, which is usually enough to see where it stopped. &lt;code>-w&lt;/code> drops the &lt;code>-snapshot&lt;/code> and lets the guest write to the image, for when you want to inspect what a first-boot service did to the disk.&lt;/p>
&lt;p>It finds OVMF and AAVMF across the various paths different distributions use, and uses KVM when &lt;code>/dev/kvm&lt;/code> is writable. With KVM, BIOS reaches the login prompt in about 16 seconds and UEFI in about 26 — OVMF initialization is the difference.&lt;/p>
&lt;h3 id="is-it-configured-correctly">Is It Configured Correctly
&lt;/h3>&lt;p>Add the &lt;code>99-selftest&lt;/code> hook and pass &lt;code>-d&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="nv">HOOKS&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$HOOKS&lt;/span>&lt;span class="s2"> 99-selftest&amp;#34;&lt;/span> sudo ./mkalpine.sh -f alpine.img
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">./testboot.sh -d alpine.img
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>The hook installs a service that runs last in the &lt;code>default&lt;/code> runlevel and checks the running system against the configuration it was built from. The expectations are baked in at build time, which is the part that makes it useful: a hook that silently did nothing shows up as a failure rather than as a plausible-looking dump.&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;span class="lnt">13
&lt;/span>&lt;span class="lnt">14
&lt;/span>&lt;span class="lnt">15
&lt;/span>&lt;span class="lnt">16
&lt;/span>&lt;span class="lnt">17
&lt;/span>&lt;span class="lnt">18
&lt;/span>&lt;span class="lnt">19
&lt;/span>&lt;span class="lnt">20
&lt;/span>&lt;span class="lnt">21
&lt;/span>&lt;span class="lnt">22
&lt;/span>&lt;span class="lnt">23
&lt;/span>&lt;span class="lnt">24
&lt;/span>&lt;span class="lnt">25
&lt;/span>&lt;span class="lnt">26
&lt;/span>&lt;span class="lnt">27
&lt;/span>&lt;span class="lnt">28
&lt;/span>&lt;span class="lnt">29
&lt;/span>&lt;span class="lnt">30
&lt;/span>&lt;span class="lnt">31
&lt;/span>&lt;span class="lnt">32
&lt;/span>&lt;span class="lnt">33
&lt;/span>&lt;span class="lnt">34
&lt;/span>&lt;span class="lnt">35
&lt;/span>&lt;span class="lnt">36
&lt;/span>&lt;span class="lnt">37
&lt;/span>&lt;span class="lnt">38
&lt;/span>&lt;span class="lnt">39
&lt;/span>&lt;span class="lnt">40
&lt;/span>&lt;span class="lnt">41
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-fallback" data-lang="fallback">&lt;span class="line">&lt;span class="cl">===== SELFTEST BEGIN =====
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">-- identity
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> info machine-id=2767df9cefbfd2d2af2d0b8acdfb4aa5
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> info hostkey=SHA256:U242JZNiEf+Qo3swIK+MoW4m2bdNb2eCMG5W6VJAwPM (ED25519)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ok hostname is alpine
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ok machine-id is 32 hex digits
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ok timezone is UTC
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">-- filesystems
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> info root options=rw,noatime,inode64,logbufs=8,logbsize=256k,noquota
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ok root is xfs
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ok /boot is vfat
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ok root mounted with logbsize=256k
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> -- btrfs subvolume (not configured)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ok fstab references root by UUID
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">-- boot
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> info kernel=6.18.48-0-virt
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> info cmdline=BOOT_IMAGE=/vmlinuz-virt root=UUID=e95aee24-... ro
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> modules=sd-mod,usb-storage,xfs rootfstype=xfs
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> rootflags=noatime,logbsize=256k console=tty0 console=ttyS0,115200
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ok cmdline is not quiet
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ok ttyS0 in securetty
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">-- hooks
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ok 20-ssh: PermitRootLogin is prohibit-password
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ok 20-ssh: listening on 22
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ok 40-zram: swap is ~100% of RAM
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ok 60-sysctl: congestion control is bbr
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ok 70-growroot: removed itself from the default runlevel
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ok 70-growroot: root fs fills the partition
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ok 80-firstboot: host key is not the build host&amp;#39;s
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> -- 91-ufw (disabled)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">-- hygiene
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ok apk cache is empty
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ok no build scratch left behind
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ok repositories pinned to v3.24
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ok repositories do not track latest-stable
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">-- sizing
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> Filesystem Size Used Available Use% Mounted on
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> /dev/vda3 381.0M 161.2M 219.8M 42% /
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> /dev/vda2 63.0M 36.4M 26.6M 58% /boot
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">SELFTEST RESULT: 59 passed, 0 failed
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">===== SELFTEST END =====
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>(abridged; the real report is one line per check)&lt;/p>
&lt;p>&lt;code>testboot.sh -d&lt;/code> exits non-zero if any check failed, so it works in a loop.&lt;/p>
&lt;p>The checks live in &lt;code>hooks/files/selftest.initd&lt;/code> as an ordinary shell script rather than a heredoc, and &lt;code>check&lt;/code> takes a command with its arguments instead of a string to &lt;code>eval&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-sh" data-lang="sh">&lt;span class="line">&lt;span class="cl">check &lt;span class="s2">&amp;#34;root is &lt;/span>&lt;span class="nv">$EXPECT_ROOT_FS&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> fstype_is / &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$EXPECT_ROOT_FS&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">check &lt;span class="s2">&amp;#34;machine-id is populated&amp;#34;&lt;/span> &lt;span class="nb">test&lt;/span> -s /etc/machine-id
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">check &lt;span class="s2">&amp;#34;cmdline is not quiet&amp;#34;&lt;/span> not grep -qw quiet /proc/cmdline
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">check &lt;span class="s2">&amp;#34;60-sysctl: vm.swappiness is 180&amp;#34;&lt;/span> output_is &lt;span class="m">180&lt;/span> sysctl -n vm.swappiness
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Anything needing a pipeline gets a named predicate instead. The first version of this used &lt;code>eval&lt;/code> on quoted strings and was unreadable — &lt;code>check &amp;quot;root is btrfs&amp;quot; &amp;quot;awk '\$2 == \&amp;quot;/\&amp;quot; {print \$3}' /proc/mounts | grep -qx btrfs&amp;quot;&lt;/code> — which is a good sign that the abstraction was wrong.&lt;/p>
&lt;h3 id="everything-at-once">Everything At Once
&lt;/h3>&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">sudo ./testmatrix.sh -o /var/tmp/mx
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>This builds &lt;code>btrfs&lt;/code>/&lt;code>ext4&lt;/code>/&lt;code>xfs&lt;/code> × &lt;code>gpt&lt;/code>/&lt;code>mbr&lt;/code>, boot-tests all six under both BIOS and UEFI with the selftest enabled, then checks the things that only show up at runtime:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Growth.&lt;/strong> Copy an image, &lt;code>truncate -s 4G&lt;/code>, and boot it — with &lt;code>testboot.sh -w&lt;/code>, so the guest&amp;rsquo;s writes actually land in the file instead of in QEMU&amp;rsquo;s &lt;code>-snapshot&lt;/code> overlay. Then confirm the root partition grew (911,360 → 8,253,407 sectors) and that it still &lt;em>starts&lt;/em> on its original sector, because a &lt;code>growpart&lt;/code> that moved the start would silently discard the build-time alignment. Reading the table back from the un-booted copy would have made this test tautological, which is what it was until I looked closely.&lt;/li>
&lt;li>&lt;strong>Output formats.&lt;/strong> Build with all five, confirm each file exists, and boot the qcow2 directly rather than just checking that &lt;code>qemu-img&lt;/code> produced something.&lt;/li>
&lt;li>&lt;strong>Identity.&lt;/strong> Boot the same image twice and confirm the two boots produce &lt;em>different&lt;/em> SSH host key fingerprints and machine-ids.&lt;/li>
&lt;/ul>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;span class="lnt">13
&lt;/span>&lt;span class="lnt">14
&lt;/span>&lt;span class="lnt">15
&lt;/span>&lt;span class="lnt">16
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-fallback" data-lang="fallback">&lt;span class="line">&lt;span class="cl">== build and boot matrix ==
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ok build btrfs/gpt
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ok boot btrfs/gpt/uefi
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ok boot btrfs/gpt/bios
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">...
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">== growroot on a larger disk ==
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ok grow 4G
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ok grow root partition 1959936 -&amp;gt; 8253407 sectors, still at 135168
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">== output formats ==
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ok format formats.img.zst (74M)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ok boot qcow2
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">== identity is per-boot, not per-image ==
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ok hygiene two boots produced different SSH host keys
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ok hygiene two boots produced different machine-ids
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">== summary ==
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">29 passed, 0 failed
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Four real bugs in this post were found this way. The XFS &lt;code>logbsize&lt;/code> downgrade above was one, and the tautological growth check was another. The third was in the selftest itself: I had written&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-sh" data-lang="sh">&lt;span class="line">&lt;span class="cl">check &lt;span class="s2">&amp;#34;apk cache is empty&amp;#34;&lt;/span> not ls -A /var/cache/apk
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>which reads correctly and means the opposite, because &lt;code>ls -A&lt;/code> exits 0 on an empty directory. A test suite that catches bugs in its own checks is doing its job.&lt;/p>
&lt;p>The last one showed up when I dropped the default &lt;code>IMAGE_SIZE&lt;/code> from 1 GiB to 512 MiB. All three filesystems still built and booted, but ext4 and XFS started failing &lt;code>70-growroot: root fs fills the partition&lt;/code>, which had been written as &amp;ldquo;&lt;code>df&lt;/code> size is at least 90% of the partition&amp;rdquo;. Nothing was wrong with the images: &lt;code>df&lt;/code> excludes reserved blocks and fixed metadata, and on a 445 MiB root partition that fixed cost is 44 MiB for ext4 and 64 MiB for XFS — 90.0% and 85.6%. The same filesystems on a 957 MiB partition reported 96.5% and 93.3% and passed. A percentage was simply the wrong shape for the check, because the overhead it has to tolerate barely moves with the disk size while the threshold does. The failure it exists to catch is not marginal either — a root that never grew is a 445 MiB filesystem in a 4 GiB partition — so the tolerance is now the larger of 96 MiB and 10%.&lt;/p>
&lt;h2 id="uploading">Uploading
&lt;/h2>&lt;p>Most providers want a compressed raw image or a qcow2:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">sudo &lt;span class="nv">OUTPUT_FORMATS&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;raw.zst&amp;#34;&lt;/span> ./mkalpine.sh -f alpine.img
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>If your provider has no image import at all — which is the common case on the cheap tiers — the restore-over-&lt;code>dd&lt;/code> trick from the &lt;a class="link" href="https://charlie0129.github.io/blog/p/alpine-minimal-btrfs-install/#restore-from-the-providers-original-os" >old post&lt;/a> still applies: boot their stock OS, &lt;code>dd&lt;/code> the image onto the disk from a rescue environment or over SSH, and reboot. &lt;code>70-growroot&lt;/code> then handles the fact that their disk is larger than the image.&lt;/p>
&lt;p>That flow needs a console or a rescue system at one point or another. When the provider offers neither — no VNC, no serial, no rescue mode, just the stock OS and SSH, which is what the smallest NAT&amp;rsquo;d tiers look like — use the &lt;a class="link" href="https://charlie0129.github.io/blog/p/alpine-minimal-btrfs-install/#restore-without-a-console-or-rescue-mode" >no-console variant&lt;/a> in the old post instead: &lt;a class="link" href="https://github.com/bin456789/reinstall" target="_blank" rel="noopener"
>&lt;code>reinstall&lt;/code>&lt;/a> &lt;code>dd&lt;/code> mode arranges a RAM-resident Alpine by rewriting the stock bootloader, then writes the image over the whole disk and reboots, all over SSH. It accepts the &lt;code>raw.gz&lt;/code> / &lt;code>raw.zst&lt;/code> / &lt;code>raw.xz&lt;/code> artifacts from &lt;code>OUTPUT_FORMATS&lt;/code> as-is, and it does not modify a Linux image — which, on a headless box, promotes &lt;code>10-network&lt;/code> and &lt;code>20-ssh&lt;/code> from conveniences to the difference between a machine that comes back and a reinstall ticket.&lt;/p>
&lt;h2 id="what-i-kept-from-the-old-post">What I Kept From The Old Post
&lt;/h2>&lt;p>Everything about &lt;em>why&lt;/em>. The old post explains the 64 MiB &lt;code>/boot&lt;/code>, the compressed Btrfs root, the absence of disk swap, and the individual tweaks in far more detail than a config file comment can, and it is still the reference for doing this by hand on a machine you have already booted. This post is the automation of it.&lt;/p></description></item><item><title>Minimal Alpine Linux on a 1 GB Btrfs Root Disk</title><link>https://charlie0129.github.io/blog/p/alpine-minimal-btrfs-install/</link><pubDate>Thu, 25 Jun 2026 15:00:00 +0800</pubDate><guid>https://charlie0129.github.io/blog/p/alpine-minimal-btrfs-install/</guid><description>&lt;p>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 &lt;code>ecs.t6-c4m1.large&lt;/code> 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.&lt;/p>
&lt;p>The trick is not complicated:&lt;/p>
&lt;ul>
&lt;li>Use Alpine Linux, because the base system is small.&lt;/li>
&lt;li>Keep &lt;code>/boot&lt;/code> tiny. A 512 MB boot partition is absurd on a 1 GB disk.&lt;/li>
&lt;li>Put &lt;code>/&lt;/code> on Btrfs with transparent compression. Application files, package metadata, and logs usually compress well.&lt;/li>
&lt;li>Do not allocate disk swap. Use zram later if the machine needs swap-like behavior.&lt;/li>
&lt;/ul>
&lt;p>This post is the install recipe I use to build a minimal BIOS/MBR Alpine image, then optionally convert it to QCOW2 for reuse.&lt;/p>
&lt;blockquote>
&lt;p>If you would rather not do this by hand, I later automated the whole thing: &lt;a class="link" href="https://charlie0129.github.io/blog/p/alpine-image-builder/" >Building Alpine Linux Disk Images Without a VM&lt;/a> 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 &lt;em>why&lt;/em> the pieces are the way they are.&lt;/p>&lt;/blockquote>
&lt;h2 id="assumptions">Assumptions
&lt;/h2>&lt;p>This guide targets a very specific VM shape:&lt;/p>
&lt;ul>
&lt;li>Legacy BIOS boot with MBR and Syslinux/Extlinux.&lt;/li>
&lt;li>One small ext4 &lt;code>/boot&lt;/code> partition and one Btrfs root partition.&lt;/li>
&lt;li>No disk encryption, no LVM, no UEFI, no separate &lt;code>/var&lt;/code>.&lt;/li>
&lt;li>The whole disk can be destroyed.&lt;/li>
&lt;/ul>
&lt;p>If your VM boots with UEFI, use an EFI system partition and GRUB instead. If your disk is NVMe, adapt the partition names to &lt;code>/dev/nvme0n1p1&lt;/code> and &lt;code>/dev/nvme0n1p2&lt;/code>.&lt;/p>
&lt;p>The final layout is:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Partition&lt;/th>
&lt;th style="text-align: right">Size&lt;/th>
&lt;th>Filesystem&lt;/th>
&lt;th>Mountpoint&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>/dev/vda1&lt;/code>&lt;/td>
&lt;td style="text-align: right">64 MB&lt;/td>
&lt;td>ext4&lt;/td>
&lt;td>&lt;code>/boot&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/dev/vda2&lt;/code>&lt;/td>
&lt;td style="text-align: right">Rest of disk (~500MB is fine)&lt;/td>
&lt;td>Btrfs&lt;/td>
&lt;td>&lt;code>/&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>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.&lt;/p>
&lt;p>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.&lt;/p>
&lt;p>Always go with a small disk first because the disk image can be expanded easily, but shrinking a disk is not trivial.&lt;/p>
&lt;h2 id="partition-the-disk">Partition The Disk
&lt;/h2>&lt;p>Set the disk variables so the commands below are harder to mistype:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="nv">DISK&lt;/span>&lt;span class="o">=&lt;/span>/dev/vda
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">BOOT&lt;/span>&lt;span class="o">=&lt;/span>/dev/vda1
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">ROOT&lt;/span>&lt;span class="o">=&lt;/span>/dev/vda2
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Now create a DOS/MBR partition table with a tiny boot partition:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">fdisk &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$DISK&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Inside &lt;code>fdisk&lt;/code>, enter:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;span class="lnt">13
&lt;/span>&lt;span class="lnt">14
&lt;/span>&lt;span class="lnt">15
&lt;/span>&lt;span class="lnt">16
&lt;/span>&lt;span class="lnt">17
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">o # new empty DOS partition table
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">n # new partition
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">p # primary
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">1 # partition number
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">2048 # first sector, 1 MiB aligned
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">+64M # size
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">a # toggle bootable flag
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">1
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">n # new partition
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">p # primary
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">2 # partition number
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">133120 # first sector immediately after the 64 MB partition
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> # press Enter for the default end sector
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">p # verify the table
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">w # write changes
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>The important details are:&lt;/p>
&lt;ul>
&lt;li>&lt;code>/dev/vda1&lt;/code> should be bootable.&lt;/li>
&lt;li>&lt;code>/dev/vda1&lt;/code> should start at sector &lt;code>2048&lt;/code>.&lt;/li>
&lt;li>&lt;code>/dev/vda2&lt;/code> should start at sector &lt;code>133120&lt;/code>.&lt;/li>
&lt;li>The second partition should use the rest of the disk.&lt;/li>
&lt;/ul>
&lt;p>Check that the kernel sees the new partitions:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">ls -l /dev/vda*
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>If &lt;code>/dev/vda1&lt;/code> and &lt;code>/dev/vda2&lt;/code> do not appear, reboot the live ISO. Alpine&amp;rsquo;s manual disk setup docs also recommend rebooting after manual partition creation when needed.&lt;sup id="fnref:1">&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref">1&lt;/a>&lt;/sup>&lt;/p>
&lt;h2 id="start-alpine-setup">Start Alpine Setup
&lt;/h2>&lt;p>Download the latest Alpine ISO from &lt;a class="link" href="https://alpinelinux.org/downloads/" target="_blank" rel="noopener"
>https://alpinelinux.org/downloads/&lt;/a>. I usually use the &lt;code>x86_64&lt;/code> architecture and the &lt;code>virtual&lt;/code> variant. As this will be used in virtualized environments, the &lt;code>virt&lt;/code> kernel is smaller.&lt;/p>
&lt;p>Boot the Alpine ISO and log in as &lt;code>root&lt;/code>. The live environment has no root password by default.&lt;/p>
&lt;p>Run the normal installer first:&lt;sup id="fnref:2">&lt;a href="#fn:2" class="footnote-ref" role="doc-noteref">2&lt;/a>&lt;/sup>&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">setup-alpine
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Answer the usual questions for keyboard, hostname, network, DNS, root password, timezone, mirror, SSH, and NTP. I usually choose &lt;code>chrony&lt;/code> for NTP.&lt;/p>
&lt;p>When it asks which disk to use, type:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">none
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>This keeps the base configuration but skips automatic partitioning and formatting. For the later diskless-mode prompts, also choose &lt;code>none&lt;/code> for configs. You can keep the default apk cache dir (&lt;code>/var/cache/apk&lt;/code>).&lt;/p>
&lt;h2 id="format-and-mount">Format And Mount
&lt;/h2>&lt;p>Install the filesystem tools in the live environment:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">apk add btrfs-progs e2fsprogs
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Format &lt;code>/boot&lt;/code> as ext4 and &lt;code>/&lt;/code> as Btrfs:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">mkfs.ext4 -m &lt;span class="m">0&lt;/span> -L boot &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$BOOT&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">mkfs.btrfs -f -L alpine-root &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$ROOT&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Mount root with compression enabled:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;span class="lnt">6
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># adjust the compression level as desired. zstd:1 is fast and has reasonable compression. zstd:3 is slower but compresses better. Any higher level is usually overkill and comes with diminishing returns.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">mount -t btrfs -o rw,relatime,compress&lt;span class="o">=&lt;/span>zstd:3,ssd,discard&lt;span class="o">=&lt;/span>async,space_cache&lt;span class="o">=&lt;/span>v2 &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$ROOT&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> /mnt
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">btrfs property &lt;span class="nb">set&lt;/span> /mnt compression zstd:3
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">mkdir -p /mnt/boot
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">mount -t ext4 &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$BOOT&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> /mnt/boot
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>I keep &lt;code>/boot&lt;/code> on ext4 because it is small and predictable with this Syslinux/MBR setup. On a tiny image, boring boot is good boot.&lt;/p>
&lt;h2 id="install-alpine">Install Alpine
&lt;/h2>&lt;p>Install Alpine into the mounted filesystem:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">setup-disk -v -m sys -s &lt;span class="m">0&lt;/span> /mnt
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>The options mean:&lt;/p>
&lt;ul>
&lt;li>&lt;code>-m sys&lt;/code>: install a traditional persistent system to disk.&lt;/li>
&lt;li>&lt;code>-s 0&lt;/code>: do not create disk swap.&lt;/li>
&lt;li>&lt;code>/mnt&lt;/code>: use the partitions already mounted under &lt;code>/mnt&lt;/code>.&lt;/li>
&lt;/ul>
&lt;p>This is the standard &lt;code>setup-disk -m sys /mnt&lt;/code> flow, just with a manually prepared root filesystem.&lt;sup id="fnref:3">&lt;a href="#fn:3" class="footnote-ref" role="doc-noteref">3&lt;/a>&lt;/sup>&lt;/p>
&lt;h2 id="verify-fstab-and-initramfs">Verify fstab And initramfs
&lt;/h2>&lt;p>Check &lt;code>/mnt/etc/fstab&lt;/code> before rebooting:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">vi /mnt/etc/fstab
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>The root entry should include Btrfs compression. Mine looks like this:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">UUID=... / btrfs rw,relatime,compress=zstd:3,ssd,discard=async,space_cache=v2,subvolid=5,subvol=/ 0 1
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">UUID=... /boot ext4 rw,relatime 0 2
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Remove any stale &lt;code>cdrom&lt;/code> or &lt;code>usbdisk&lt;/code> entries if the installer generated them.&lt;/p>
&lt;p>Also check that the installed initramfs knows about Btrfs:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">grep -w btrfs /mnt/etc/mkinitfs/mkinitfs.conf
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>If &lt;code>btrfs&lt;/code> is missing from &lt;code>features=&amp;quot;...&amp;quot;&lt;/code>, add it before rebooting and regenerate the initramfs from a chroot. Alpine explicitly calls this out for manual Btrfs root installs.&lt;sup id="fnref:4">&lt;a href="#fn:4" class="footnote-ref" role="doc-noteref">4&lt;/a>&lt;/sup>&lt;/p>
&lt;h2 id="fix-the-mbr-bootloader">Fix The MBR Bootloader
&lt;/h2>&lt;p>On these tiny MBR images, I have seen the first-stage bootloader not get written correctly. Reinstalling the Syslinux MBR is cheap insurance:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">apk add syslinux
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">dd &lt;span class="nv">bs&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">440&lt;/span> &lt;span class="nv">count&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">1&lt;/span> &lt;span class="nv">conv&lt;/span>&lt;span class="o">=&lt;/span>notrunc &lt;span class="k">if&lt;/span>&lt;span class="o">=&lt;/span>/usr/share/syslinux/mbr.bin &lt;span class="nv">of&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$DISK&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Use &lt;code>gptmbr.bin&lt;/code> instead only if you are using GPT. For the DOS partition table above, &lt;code>mbr.bin&lt;/code> is the right file.&lt;sup id="fnref:5">&lt;a href="#fn:5" class="footnote-ref" role="doc-noteref">5&lt;/a>&lt;/sup>&lt;/p>
&lt;p>Now unmount and reboot:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="nb">cd&lt;/span> /
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">sync
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">umount /mnt/boot
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">umount /mnt
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">reboot
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>After the first boot, log in and check the actual space usage:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">df -h /
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">btrfs filesystem usage /
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h2 id="convert-the-disk-to-qcow2">Convert The Disk To QCOW2
&lt;/h2>&lt;p>Once the image is configured the way you want, shut it down and convert the raw disk from the host:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">qemu-img convert -f raw -c -p -O qcow2 /dev/mapper/ex950-vm--300--disk--0 alpine-minimal.qcow2
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Replace the source device with your VM disk. Do this from a stopped VM or a consistent snapshot, not from a running writable system.&lt;/p>
&lt;p>You can now reuse the QCOW2 as a base image for other VMs or cloud instances.&lt;/p>
&lt;h2 id="restore-from-the-providers-original-os">Restore From The Provider&amp;rsquo;s Original OS
&lt;/h2>&lt;p>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&amp;rsquo;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.&lt;/p>
&lt;p>The important reason for the two-stage flow is tooling: the original OS probably has &lt;code>curl&lt;/code> and &lt;code>qemu-img&lt;/code>, while the initramfs usually does not. The initramfs is only used for the final &lt;code>dd&lt;/code>, because at that point the real root filesystem is not mounted and can be safely overwritten.&lt;/p>
&lt;p>This is destructive. Double-check the target disk and the old root partition before running &lt;code>dd&lt;/code>.&lt;/p>
&lt;p>First, in the provider&amp;rsquo;s original OS. This filesystem needs enough free space to hold both the downloaded QCOW2 and the converted raw image:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Download the Alpine minimal image in qcow2 format and convert it to raw.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Do this in the original OS because the initramfs probably does not have&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># curl or qemu-img.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">curl -L &lt;span class="s2">&amp;#34;&amp;lt;qcow2 link&amp;gt;&amp;#34;&lt;/span> -o /os.qcow2
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">qemu-img convert -f qcow2 -O raw /os.qcow2 /os.raw
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Then use the provider console or VNC to reboot into initramfs. On a GRUB-based original OS, select the normal boot entry, press &lt;code>e&lt;/code>, find the Linux kernel command line, append:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">break=premount
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Then press &lt;code>F10&lt;/code> or &lt;code>Ctrl-x&lt;/code> to boot. This should drop you into an initramfs shell before the root filesystem is mounted.&lt;/p>
&lt;p>From the initramfs shell:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;span class="lnt">13
&lt;/span>&lt;span class="lnt">14
&lt;/span>&lt;span class="lnt">15
&lt;/span>&lt;span class="lnt">16
&lt;/span>&lt;span class="lnt">17
&lt;/span>&lt;span class="lnt">18
&lt;/span>&lt;span class="lnt">19
&lt;/span>&lt;span class="lnt">20
&lt;/span>&lt;span class="lnt">21
&lt;/span>&lt;span class="lnt">22
&lt;/span>&lt;span class="lnt">23
&lt;/span>&lt;span class="lnt">24
&lt;/span>&lt;span class="lnt">25
&lt;/span>&lt;span class="lnt">26
&lt;/span>&lt;span class="lnt">27
&lt;/span>&lt;span class="lnt">28
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Find the old root partition and the target disk.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Example:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># old root partition: /dev/sda2&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># target disk: /dev/sda&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">cat /proc/partitions
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">mkdir /tmp/rootfs
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nb">cd&lt;/span> /tmp
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Mount the old root filesystem read-only to copy the prepared image.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Replace /dev/sda2 with the root partition from the original OS, and&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># replace ext4 if the provider image uses another filesystem.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">mount -t ext4 -o ro /dev/sda2 rootfs
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Copy the raw image to initramfs memory so the old root can be unmounted&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># before overwriting the disk.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">#&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># This requires enough RAM for /os.raw. If the VM is too small, attach a&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># temporary rescue disk or use a rescue system that can stream the image&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># from the network instead.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">dd &lt;span class="k">if&lt;/span>&lt;span class="o">=&lt;/span>rootfs/os.raw &lt;span class="nv">of&lt;/span>&lt;span class="o">=&lt;/span>os.raw
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">umount rootfs
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># This overwrites the whole disk. Replace /dev/sda with the target disk.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">dd &lt;span class="k">if&lt;/span>&lt;span class="o">=&lt;/span>os.raw &lt;span class="nv">of&lt;/span>&lt;span class="o">=&lt;/span>/dev/sda
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">reboot
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>After booting into Alpine, apply provider-specific network settings. For a static IPv4 setup:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;span class="lnt">13
&lt;/span>&lt;span class="lnt">14
&lt;/span>&lt;span class="lnt">15
&lt;/span>&lt;span class="lnt">16
&lt;/span>&lt;span class="lnt">17
&lt;/span>&lt;span class="lnt">18
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">cat &lt;span class="s">&amp;lt;&amp;lt;&amp;#39;EOF&amp;#39; &amp;gt; /etc/network/interfaces
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">auto lo
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">iface lo inet loopback
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">auto eth0
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">iface eth0 inet static
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s"> address X.X.X.X/24
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s"> gateway X.X.X.X
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">EOF&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">cat &lt;span class="s">&amp;lt;&amp;lt;&amp;#39;EOF&amp;#39; &amp;gt; /etc/resolv.conf
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">nameserver 1.1.1.1
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">nameserver 1.0.0.1
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">EOF&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">setup-hostname XXXX
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">rc-service networking restart
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>If the provider uses DHCP, keep the interface as DHCP instead and let Alpine request the address normally.&lt;/p>
&lt;h2 id="restore-without-a-console-or-rescue-mode">Restore Without A Console Or Rescue Mode
&lt;/h2>&lt;p>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.&lt;/p>
&lt;p>What still works is having a script arrange that environment for you. &lt;a class="link" href="https://github.com/bin456789/reinstall" target="_blank" rel="noopener"
>bin456789/reinstall&lt;/a> rewrites the stock bootloader to boot a minimal Alpine that runs entirely in RAM, and from there downloads your image and &lt;code>dd&lt;/code>s 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.&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;span class="lnt">13
&lt;/span>&lt;span class="lnt">14
&lt;/span>&lt;span class="lnt">15
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># From the provider&amp;#39;s stock OS, as root. This is destructive: it will&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># wipe the whole disk, other partitions included.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">#&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Mainland-China boxes often cannot reach raw.githubusercontent.com; the&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># project mirrors the script at cnb.cool for exactly that case.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">curl -O https://raw.githubusercontent.com/bin456789/reinstall/main/reinstall.sh
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># curl -O https://cnb.cool/bin456789/reinstall/-/git/raw/main/reinstall.sh&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Prompts for a username/password, used to log in over SSH and watch.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># .gz / .xz / .zst compressed raw images are accepted directly.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">bash reinstall.sh dd --img&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;https://example.com/alpine.img.gz&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Still reversible until here: `bash reinstall.sh reset` restores the&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># original bootloader config.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">reboot
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>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&amp;rsquo;s login. They exist only inside the RAM installer environment — they are what you would type to SSH back in while the &lt;code>dd&lt;/code> runs, to watch progress or clean up after a failure. A Linux image gets nothing injected; the script&amp;rsquo;s own end-of-run summary says as much, printing &lt;code>Password: [Depends on image]&lt;/code> under &amp;ldquo;After Install&amp;rdquo;. So press Enter twice — &lt;code>root&lt;/code>, then a random password the summary displays for copying — and let whatever the image baked in be the machine&amp;rsquo;s real credentials. &lt;code>--ssh-key&lt;/code> replaces that password with one of your public keys for the installer environment, if typing nothing at all is the goal.&lt;/p>
&lt;p>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&amp;rsquo;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 &lt;code>ssh-keygen -R&lt;/code> or accept the new key each time. On a NAT&amp;rsquo;d box the installer&amp;rsquo;s sshd still listens on port 22 internally, so the provider&amp;rsquo;s forwarded port keeps working throughout.&lt;/p>
&lt;p>Four things to get right, all of them consequences of there being no console:&lt;/p>
&lt;p>&lt;strong>The image URL must be reachable from the VPS.&lt;/strong> 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.&lt;/p>
&lt;p>&lt;strong>The image has to work as-is.&lt;/strong> &lt;code>dd&lt;/code> mode does not modify a Linux image, so the network settings, the SSH keys, and the sshd port must be baked into the image &lt;em>before&lt;/em> flashing — the &amp;ldquo;apply provider-specific network settings&amp;rdquo; step above has to happen inside the image, not on the machine afterwards. On a NAT&amp;rsquo;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 &lt;a class="link" href="https://charlie0129.github.io/blog/p/alpine-image-builder/" >the builder&lt;/a>, the &lt;code>10-network&lt;/code> and &lt;code>20-ssh&lt;/code> hooks are where this goes.&lt;/p>
&lt;p>&lt;strong>Check whether the stock OS keeps its root on LVM before rebooting.&lt;/strong> The script stages &lt;code>reinstall-vmlinuz&lt;/code> and &lt;code>reinstall-initrd&lt;/code> in the stock OS&amp;rsquo;s root directory and lets GRUB find them with &lt;code>search --file&lt;/code>. GRUB&amp;rsquo;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 (&lt;a class="link" href="https://github.com/bin456789/reinstall/issues/355" target="_blank" rel="noopener"
>issue #355&lt;/a>). When that happens, GRUB stops at &lt;code>file '/reinstall-vmlinuz' not found&lt;/code> 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 &lt;code>lsblk&lt;/code> settles it beforehand — if &lt;code>/&lt;/code> sits on an &lt;code>lvm&lt;/code> device, copy both files to &lt;code>/boot&lt;/code>, which Debian-style LVM installs keep on a plain partition that GRUB reads reliably:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">lsblk -o NAME,TYPE,FSTYPE,MOUNTPOINTS &lt;span class="c1"># is / on an lvm device?&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">cp /reinstall-vmlinuz /reinstall-initrd /boot/
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>The copies are harmless if they were not needed: &lt;code>search --file&lt;/code> scans every filesystem GRUB can read, so it finds the &lt;code>/boot&lt;/code> copies whenever the LVM ones are invisible. The workaround is the tool author&amp;rsquo;s own, from the issue thread. If &lt;code>/boot&lt;/code> 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.&lt;/p>
&lt;p>&lt;strong>Audit before the final reboot.&lt;/strong> &lt;code>--hold 2&lt;/code> stops after the &lt;code>dd&lt;/code> finishes but before the reboot. SSH back in, mount the freshly written root and boot partitions read-write, and check &lt;code>/etc/network/interfaces&lt;/code>, the authorized keys, and the bootloader config while a mistake is still fixable with a text editor. It is the last moment anything is.&lt;/p>
&lt;p>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 &lt;code>dd&lt;/code> by hand instead, &lt;code>bash reinstall.sh alpine --hold 1&lt;/code> boots the same RAM Alpine and stops there, nothing written; it is also a cheap dry run for whether that environment&amp;rsquo;s networking works on the provider&amp;rsquo;s NAT at all. And if the machine still does not come back after the last reboot, the floor is the provider&amp;rsquo;s own reinstall button, back to the stock OS — annoying, not fatal.&lt;/p>
&lt;h2 id="things-i-bake-into-the-image">Things I Bake Into The Image
&lt;/h2>&lt;p>The base install above is intentionally small. The sections below are optional knobs I usually apply before making the reusable image.&lt;/p>
&lt;h3 id="ssh">SSH
&lt;/h3>&lt;p>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.&lt;/p>
&lt;p>Add or change these lines in &lt;code>/etc/ssh/sshd_config&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">PermitRootLogin yes
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">AllowTcpForwarding yes
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ClientAliveInterval 60
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ClientAliveCountMax 3
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Restart SSH:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">rc-service sshd restart
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>&lt;code>ClientAliveInterval 60&lt;/code> plus &lt;code>ClientAliveCountMax 3&lt;/code> makes dead SSH forwarding sessions disappear after roughly three minutes instead of waiting for long TCP timeouts.&lt;/p>
&lt;h3 id="community-repository">Community Repository
&lt;/h3>&lt;p>Many useful packages live in &lt;code>community&lt;/code>.&lt;/p>
&lt;p>Either run:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">setup-apkrepos -c
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Or edit &lt;code>/etc/apk/repositories&lt;/code> and uncomment the matching community repository:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl"># http://dl-cdn.alpinelinux.org/alpine/&amp;lt;alpine version&amp;gt;/community
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Then refresh indexes:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">apk update
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h3 id="ipv6-dhcp">IPv6 DHCP
&lt;/h3>&lt;p>SLAAC may work without any configuration. Some cloud providers need DHCPv6, in which case add an IPv6 stanza to &lt;code>/etc/network/interfaces&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">auto eth0
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">iface eth0 inet dhcp
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">iface eth0 inet6 dhcp
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Install the DHCP and interface tooling if your image does not already have it:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">apk add dhcpcd ifupdown-ng
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">rc-service networking restart
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h3 id="chrony">Chrony
&lt;/h3>&lt;p>If NTP was not configured during install:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;span class="lnt">6
&lt;/span>&lt;span class="lnt">7
&lt;/span>&lt;span class="lnt">8
&lt;/span>&lt;span class="lnt">9
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">apk add chrony
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vi /etc/chrony/chrony.conf
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Use `ntp.aliyun.com` if you are in mainland China; `pool.ntp.org`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># is often unreliable from there. Elsewhere, the default pool is fine.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">#&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Also add `makestep 1.0 -1` so chrony can correct large time offsets&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># on startup, which is common in VMs. You can remove the default&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># `initstepslew ...` line if it exists.&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>The result should look like this:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;span class="lnt">6
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-gdscript3" data-lang="gdscript3">&lt;span class="line">&lt;span class="cl">&lt;span class="n">pool&lt;/span> &lt;span class="n">pool&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">ntp&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">org&lt;/span> &lt;span class="n">iburst&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">initstepslew&lt;/span> &lt;span class="mi">10&lt;/span> &lt;span class="n">pool&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">ntp&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">org&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">driftfile&lt;/span> &lt;span class="o">/&lt;/span>&lt;span class="k">var&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="n">lib&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="n">chrony&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="n">chrony&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">drift&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">rtcsync&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">cmdport&lt;/span> &lt;span class="mi">0&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">makestep&lt;/span> &lt;span class="mf">1.0&lt;/span> &lt;span class="o">-&lt;/span>&lt;span class="mi">1&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Enable it:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">rc-update add chronyd default
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">rc-service chronyd restart
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h3 id="log-caps">Log Caps
&lt;/h3>&lt;p>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.&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;span class="lnt">13
&lt;/span>&lt;span class="lnt">14
&lt;/span>&lt;span class="lnt">15
&lt;/span>&lt;span class="lnt">16
&lt;/span>&lt;span class="lnt">17
&lt;/span>&lt;span class="lnt">18
&lt;/span>&lt;span class="lnt">19
&lt;/span>&lt;span class="lnt">20
&lt;/span>&lt;span class="lnt">21
&lt;/span>&lt;span class="lnt">22
&lt;/span>&lt;span class="lnt">23
&lt;/span>&lt;span class="lnt">24
&lt;/span>&lt;span class="lnt">25
&lt;/span>&lt;span class="lnt">26
&lt;/span>&lt;span class="lnt">27
&lt;/span>&lt;span class="lnt">28
&lt;/span>&lt;span class="lnt">29
&lt;/span>&lt;span class="lnt">30
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">cat &lt;span class="s">&amp;lt;&amp;lt;&amp;#39;EOF&amp;#39; &amp;gt; /etc/periodic/hourly/logtruncate
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">#!/bin/sh
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s"># Rotate log files larger than 4M.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s"># Keep one .0 backup and truncate the original in place.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">LOG_DIR=&amp;#34;/var/log&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">MAX_SIZE=$((4 * 1024 * 1024)) # 4M in bytes
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">find &amp;#34;$LOG_DIR&amp;#34; -type f ! -name &amp;#39;*.0&amp;#39; | while read -r file; do
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s"> # Works with both GNU and BusyBox stat.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s"> size=$(stat -c %s &amp;#34;$file&amp;#34; 2&amp;gt;/dev/null || stat -f %z &amp;#34;$file&amp;#34; 2&amp;gt;/dev/null || echo 0)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s"> if [ &amp;#34;$size&amp;#34; -gt &amp;#34;$MAX_SIZE&amp;#34; ]; then
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s"> logger -t logtruncate &amp;#34;Rotating $file ($size bytes)&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s"> # Copy current content to .0 backup, overwriting the old backup.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s"> cp &amp;#34;$file&amp;#34; &amp;#34;$file.0&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s"> # Preserve the inode so daemons can continue writing.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s"> truncate -s 0 &amp;#34;$file&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s"> logger -t logtruncate &amp;#34;Rotated $file (backup: $file.0)&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s"> fi
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">done
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">EOF&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">chmod +x /etc/periodic/hourly/logtruncate
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">rc-update add crond default
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">rc-service crond start
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Then tell BusyBox syslog not to rotate by itself:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">sed -i &lt;span class="s1">&amp;#39;s/^SYSLOGD_OPTS=.*/SYSLOGD_OPTS=&amp;#34;-t -s 0&amp;#34;/&amp;#39;&lt;/span> /etc/conf.d/syslog
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">rc-service syslog restart
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h3 id="apk-cache-cleanup">APK Cache Cleanup
&lt;/h3>&lt;p>If apk caching is enabled later, clean old packages periodically:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;span class="lnt">6
&lt;/span>&lt;span class="lnt">7
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">cat &lt;span class="s">&amp;lt;&amp;lt;&amp;#39;EOF&amp;#39; &amp;gt; /etc/periodic/daily/apkcacheclean
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">#!/bin/sh
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">find /var/cache/apk -type f -name &amp;#39;*.apk&amp;#39; -mtime +7 -delete
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">find /var/cache/apk -type f -name &amp;#39;*.tar.gz&amp;#39; -mtime +90 -delete
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">EOF&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">chmod +x /etc/periodic/daily/apkcacheclean
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h3 id="timezone">Timezone
&lt;/h3>&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">apk add tzdata
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">setup-timezone -z Asia/Shanghai &lt;span class="c1"># replace with your timezone&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h3 id="zerotier">ZeroTier
&lt;/h3>&lt;p>Alpine&amp;rsquo;s ZeroTier package can lag behind upstream, so I usually use static binaries from &lt;a class="link" href="https://github.com/charlie0129/zerotier-static" target="_blank" rel="noopener"
>zerotier-static&lt;/a>.&lt;/p>
&lt;p>After placing &lt;code>zerotier-one&lt;/code> at &lt;code>/usr/local/bin/zerotier-one&lt;/code>, create an OpenRC service:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;span class="lnt">13
&lt;/span>&lt;span class="lnt">14
&lt;/span>&lt;span class="lnt">15
&lt;/span>&lt;span class="lnt">16
&lt;/span>&lt;span class="lnt">17
&lt;/span>&lt;span class="lnt">18
&lt;/span>&lt;span class="lnt">19
&lt;/span>&lt;span class="lnt">20
&lt;/span>&lt;span class="lnt">21
&lt;/span>&lt;span class="lnt">22
&lt;/span>&lt;span class="lnt">23
&lt;/span>&lt;span class="lnt">24
&lt;/span>&lt;span class="lnt">25
&lt;/span>&lt;span class="lnt">26
&lt;/span>&lt;span class="lnt">27
&lt;/span>&lt;span class="lnt">28
&lt;/span>&lt;span class="lnt">29
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">cat &lt;span class="s">&amp;lt;&amp;lt;&amp;#39;EOF&amp;#39; &amp;gt; /etc/init.d/zerotier-one
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">#!/sbin/openrc-run
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">depend() {
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s"> after network-online
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s"> want cgroups
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">}
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">start_pre() {
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s"> /sbin/modprobe tun
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">}
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">supervisor=supervise-daemon
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">name=zerotier-one
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">command=&amp;#34;/usr/local/bin/zerotier-one&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">command_args=&amp;#34; \
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s"> &amp;gt;&amp;gt;/var/log/zerotier-one.log 2&amp;gt;&amp;amp;1&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">output_log=/var/log/zerotier-one.log
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">error_log=/var/log/zerotier-one.log
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">pidfile=&amp;#34;/var/run/zerotier-one.pid&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">respawn_delay=5
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">respawn_max=0
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">EOF&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">chmod +x /etc/init.d/zerotier-one
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">rc-update add zerotier-one default
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">rc-service zerotier-one start
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h3 id="zram-swap">ZRAM Swap
&lt;/h3>&lt;p>Zram is more useful than disk swap. Install &lt;code>zram-init&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;span class="lnt">13
&lt;/span>&lt;span class="lnt">14
&lt;/span>&lt;span class="lnt">15
&lt;/span>&lt;span class="lnt">16
&lt;/span>&lt;span class="lnt">17
&lt;/span>&lt;span class="lnt">18
&lt;/span>&lt;span class="lnt">19
&lt;/span>&lt;span class="lnt">20
&lt;/span>&lt;span class="lnt">21
&lt;/span>&lt;span class="lnt">22
&lt;/span>&lt;span class="lnt">23
&lt;/span>&lt;span class="lnt">24
&lt;/span>&lt;span class="lnt">25
&lt;/span>&lt;span class="lnt">26
&lt;/span>&lt;span class="lnt">27
&lt;/span>&lt;span class="lnt">28
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">apk add zram-init
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Adjust as needed. The default config creates swap and /tmp on zram.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">#&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># I usually set swap zram to roughly the same size as RAM and use lz4&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># for speed. Because zram compresses memory, that does not mean it&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># immediately consumes that much physical RAM.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">#&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># To set swap zram to the same size as RAM, remove the default&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># `size0=512M` line and use:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># size0=`LC_ALL=C free -m | awk &amp;#39;/^Mem:/{print int($2)}&amp;#39;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">#&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># To use lz4:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># algo0=lz4&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">#&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># The /tmp zram can be left untouched. If you want a smaller /tmp, use&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># something like:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># size1=`LC_ALL=C free -m | awk &amp;#39;/^Mem:/{print int($2/4)}&amp;#39;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">#&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Remove `blck1=1024` if your default config has it. The minimum block&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># size is 4 KiB, so 1024 bytes is not valid.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">#&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># If /tmp is already mounted as tmpfs in /etc/fstab, remove that line&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># when /tmp is provided by zram-init.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vi /etc/conf.d/zram-init
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">rc-update add zram-init boot
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">rc-service zram-init start
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>These sysctls make the kernel more willing to use compressed swap:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;span class="lnt">6
&lt;/span>&lt;span class="lnt">7
&lt;/span>&lt;span class="lnt">8
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">cat &lt;span class="s">&amp;lt;&amp;lt;&amp;#39;EOF&amp;#39; &amp;gt; /etc/sysctl.d/01-zram.conf
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">vm.swappiness = 180
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">vm.watermark_boost_factor = 0
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">vm.watermark_scale_factor = 125
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">vm.page-cluster = 0
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">EOF&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">sysctl -p /etc/sysctl.d/01-zram.conf
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h3 id="cloud-init">Cloud-init
&lt;/h3>&lt;p>&lt;code>cloud-init&lt;/code> is convenient if the image will be imported into a cloud provider, but it pulls in Python and is not small.&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">apk add cloud-init
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Read the installation notes displayed by apk. It will show which&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># cloud-init services should be enabled for your target environment.&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Read the package message after installation and enable the OpenRC services it lists. If you want automatic partition growth on first boot, also install:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">apk add cloud-utils-growpart
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>For the smallest image, skip cloud-init and inject network configuration another way.&lt;/p>
&lt;h3 id="expand-root-after-restoring-to-a-larger-disk">Expand Root After Restoring To A Larger Disk
&lt;/h3>&lt;p>If you restore the QCOW2 to a larger disk, grow the root partition and then grow Btrfs.&lt;/p>
&lt;p>Run:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">fdisk /dev/vda
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Inside &lt;code>fdisk&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;span class="lnt">13
&lt;/span>&lt;span class="lnt">14
&lt;/span>&lt;span class="lnt">15
&lt;/span>&lt;span class="lnt">16
&lt;/span>&lt;span class="lnt">17
&lt;/span>&lt;span class="lnt">18
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">p # print; write down the current start sector of vda2, usually 133120
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">d # delete partition
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">2 # select vda2
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">n # new partition
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">p # primary
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">2 # partition number 2
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">133120 # use the exact old start sector
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&amp;lt;ENTER&amp;gt; # default end, use the rest of the disk
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&amp;gt; If asked:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&amp;gt; Partition #2 contains a btrfs signature.
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&amp;gt; Do you want to remove the signature? [Y]es/[N]o:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">N # DO NOT remove the signature
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">p # verify vda2 starts at the same sector and uses the rest of the disk
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">w # write changes
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Ask the kernel to reread the partition table:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">apk add parted
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">partprobe /dev/vda &lt;span class="o">||&lt;/span> reboot
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>If you had to reboot, continue after the reboot:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">btrfs filesystem resize max /
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">df -h /
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>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.&lt;/p>
&lt;h3 id="bbr-congestion-control">BBR Congestion Control
&lt;/h3>&lt;p>For forwarding boxes, I usually enable BBR:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;span class="lnt">6
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">cat &lt;span class="s">&amp;lt;&amp;lt;&amp;#39;EOF&amp;#39; &amp;gt; /etc/sysctl.d/02-bbr.conf
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">net.core.default_qdisc = fq
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">net.ipv4.tcp_congestion_control = bbr
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">EOF&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">sysctl -p /etc/sysctl.d/02-bbr.conf
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Verify:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">sysctl net.ipv4.tcp_congestion_control
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h3 id="podman">Podman
&lt;/h3>&lt;p>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).&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;span class="lnt">13
&lt;/span>&lt;span class="lnt">14
&lt;/span>&lt;span class="lnt">15
&lt;/span>&lt;span class="lnt">16
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">apk add podman
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Keep the overlay storage driver. It is usually more stable and has&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># better performance for containers, even though the underlying&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># filesystem is Btrfs.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">#&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Limit container logs to 1M so they do not fill the disk.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">sed -i &lt;span class="s1">&amp;#39;s/^#log_size_max =.*/log_size_max = 1048576/&amp;#39;&lt;/span> /etc/containers/containers.conf
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Enable cgroups v2 for better compatibility with modern container runtimes.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">rc-update add cgroups default
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">rc-service cgroups start
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Start containers that have a restart policy such as always or unless-stopped.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">rc-update add podman default
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">rc-service podman start
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>If you want Docker CLI and Compose compatibility against the Podman socket:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">apk add docker-cli docker-cli-compose
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">docker context create podman --docker &lt;span class="s2">&amp;#34;host=unix:///run/podman/podman.sock&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">docker context use podman
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>I do not use &lt;code>podman-docker&lt;/code> or &lt;code>podman-compose&lt;/code> here; the Docker CLI is closer to what my existing Compose files expect.&lt;/p>
&lt;h3 id="ufw">UFW
&lt;/h3>&lt;p>If the provider does not give you a firewall, configure one before exposing the VM:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;span class="lnt">13
&lt;/span>&lt;span class="lnt">14
&lt;/span>&lt;span class="lnt">15
&lt;/span>&lt;span class="lnt">16
&lt;/span>&lt;span class="lnt">17
&lt;/span>&lt;span class="lnt">18
&lt;/span>&lt;span class="lnt">19
&lt;/span>&lt;span class="lnt">20
&lt;/span>&lt;span class="lnt">21
&lt;/span>&lt;span class="lnt">22
&lt;/span>&lt;span class="lnt">23
&lt;/span>&lt;span class="lnt">24
&lt;/span>&lt;span class="lnt">25
&lt;/span>&lt;span class="lnt">26
&lt;/span>&lt;span class="lnt">27
&lt;/span>&lt;span class="lnt">28
&lt;/span>&lt;span class="lnt">29
&lt;/span>&lt;span class="lnt">30
&lt;/span>&lt;span class="lnt">31
&lt;/span>&lt;span class="lnt">32
&lt;/span>&lt;span class="lnt">33
&lt;/span>&lt;span class="lnt">34
&lt;/span>&lt;span class="lnt">35
&lt;/span>&lt;span class="lnt">36
&lt;/span>&lt;span class="lnt">37
&lt;/span>&lt;span class="lnt">38
&lt;/span>&lt;span class="lnt">39
&lt;/span>&lt;span class="lnt">40
&lt;/span>&lt;span class="lnt">41
&lt;/span>&lt;span class="lnt">42
&lt;/span>&lt;span class="lnt">43
&lt;/span>&lt;span class="lnt">44
&lt;/span>&lt;span class="lnt">45
&lt;/span>&lt;span class="lnt">46
&lt;/span>&lt;span class="lnt">47
&lt;/span>&lt;span class="lnt">48
&lt;/span>&lt;span class="lnt">49
&lt;/span>&lt;span class="lnt">50
&lt;/span>&lt;span class="lnt">51
&lt;/span>&lt;span class="lnt">52
&lt;/span>&lt;span class="lnt">53
&lt;/span>&lt;span class="lnt">54
&lt;/span>&lt;span class="lnt">55
&lt;/span>&lt;span class="lnt">56
&lt;/span>&lt;span class="lnt">57
&lt;/span>&lt;span class="lnt">58
&lt;/span>&lt;span class="lnt">59
&lt;/span>&lt;span class="lnt">60
&lt;/span>&lt;span class="lnt">61
&lt;/span>&lt;span class="lnt">62
&lt;/span>&lt;span class="lnt">63
&lt;/span>&lt;span class="lnt">64
&lt;/span>&lt;span class="lnt">65
&lt;/span>&lt;span class="lnt">66
&lt;/span>&lt;span class="lnt">67
&lt;/span>&lt;span class="lnt">68
&lt;/span>&lt;span class="lnt">69
&lt;/span>&lt;span class="lnt">70
&lt;/span>&lt;span class="lnt">71
&lt;/span>&lt;span class="lnt">72
&lt;/span>&lt;span class="lnt">73
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">apk add ip6tables ufw
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Start ufw later, after the allow rules are in place.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Default policies&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ufw default deny incoming
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ufw default allow outgoing
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Allow loopback&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ufw allow in on lo
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># -------------------------&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># SSH&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># -------------------------&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># SSH port; change this if you use a non-standard SSH port.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ufw allow 22/tcp comment &lt;span class="s1">&amp;#39;SSH&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Optional: rate limit SSH brute force.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ufw limit 22/tcp&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># -------------------------&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Private / intranet ranges&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># -------------------------&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># RFC1918 IPv4&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ufw allow from 10.0.0.0/8
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ufw allow from 172.16.0.0/12
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ufw allow from 192.168.0.0/16
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Optional CGNAT range&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ufw allow from 100.64.0.0/10&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Optional IPv6 ULA&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ufw allow from fc00::/7
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Also allow forwarding to RFC1918 and ULA ranges if this server is a&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># gateway or VPN server, or if you use Podman containers with published&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ports. Published container ports usually pass through DNAT and are&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># handled by forward rules.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">#&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Note that if you are using Podman containers with published ports,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># these route rules can allow access to all published ports even if the&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># input rules only allow selected ports. If that is not what you want,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># only allow specific forwarded ports here.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ufw route allow to 10.0.0.0/8&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ufw route allow to 172.16.0.0/12&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ufw route allow to 192.168.0.0/16&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ufw route allow to fc00::/7&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># -------------------------&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Public services&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># -------------------------&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Web&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ufw allow 80/tcp&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ufw allow 443/tcp&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ufw allow 443/udp&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Example app ports&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ufw allow 3000/tcp&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ufw allow 9090/tcp&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># -------------------------&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Logging&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># -------------------------&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ufw logging low
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Enable firewall&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ufw &lt;span class="nb">enable&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">rc-update add ufw default
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">rc-service ufw start
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>If this machine routes VPN, overlay network, or container traffic, add explicit &lt;code>ufw route allow ...&lt;/code> rules for the private ranges you actually need.&lt;/p>
&lt;h3 id="sshguard">sshguard
&lt;/h3>&lt;p>I avoid Fail2ban on this image because Python is too heavy for the target machine. &lt;code>sshguard&lt;/code> is small and good enough.&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">apk add sshguard nftables
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Do not blindly start the &lt;code>nftables&lt;/code> 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 &lt;code>sshguard&lt;/code> manage its own nftables sets instead.&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;span class="lnt">13
&lt;/span>&lt;span class="lnt">14
&lt;/span>&lt;span class="lnt">15
&lt;/span>&lt;span class="lnt">16
&lt;/span>&lt;span class="lnt">17
&lt;/span>&lt;span class="lnt">18
&lt;/span>&lt;span class="lnt">19
&lt;/span>&lt;span class="lnt">20
&lt;/span>&lt;span class="lnt">21
&lt;/span>&lt;span class="lnt">22
&lt;/span>&lt;span class="lnt">23
&lt;/span>&lt;span class="lnt">24
&lt;/span>&lt;span class="lnt">25
&lt;/span>&lt;span class="lnt">26
&lt;/span>&lt;span class="lnt">27
&lt;/span>&lt;span class="lnt">28
&lt;/span>&lt;span class="lnt">29
&lt;/span>&lt;span class="lnt">30
&lt;/span>&lt;span class="lnt">31
&lt;/span>&lt;span class="lnt">32
&lt;/span>&lt;span class="lnt">33
&lt;/span>&lt;span class="lnt">34
&lt;/span>&lt;span class="lnt">35
&lt;/span>&lt;span class="lnt">36
&lt;/span>&lt;span class="lnt">37
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># IMPORTANT: Do not run `rc-update add nftables &amp;amp;&amp;amp; rc-service nftables start`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># unless you have checked the ruleset from a console. We will let sshguard&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># manage its nftables sets.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">mkdir -p /var/lib/sshguard
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">cat &lt;span class="s">&amp;lt;&amp;lt;&amp;#39;EOF&amp;#39; &amp;gt; /etc/sshguard.conf
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s"># Full path to backend executable. Required; there is no default.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">BACKEND=&amp;#39;/usr/libexec/sshg-fw-nft-sets&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s"># Space-separated list of log files to monitor.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">FILES=&amp;#39;/var/log/messages&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s"># Block attackers when their cumulative attack score exceeds THRESHOLD.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s"># Most attacks have a score of 10.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">THRESHOLD=20
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s"># Block attackers for initially BLOCK_TIME seconds after exceeding THRESHOLD.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s"># Subsequent blocks increase by a factor of 1.5.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">BLOCK_TIME=180
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s"># Remember potential attackers for up to DETECTION_TIME seconds before
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s"># resetting their score.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">DETECTION_TIME=3600
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s"># Permanently blacklist attackers when their cumulative score exceeds threshold.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">BLACKLIST_FILE=100:/var/lib/sshguard/blacklist.db
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s"># Size of IPv6 subnet to block. Defaults to a single address.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">IPV6_SUBNET=48
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s"># Size of IPv4 subnet to block. Defaults to a single address.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">IPV4_SUBNET=24
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s">EOF&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">rc-update add sshguard default
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">rc-service sshguard start
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h3 id="common-tools">Common Tools
&lt;/h3>&lt;p>This is no longer minimal, but it makes Alpine feel closer to a general-purpose server if you find busybox too limited:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">apk add bash coreutils findutils tar grep sed gawk diffutils procps util-linux shadow curl wget iproute2 bind-tools gcompat pciutils
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">apk add netcat-openbsd socat tcpdump iftop iptraf-ng ethtool traceroute zsh git htop tmux vim less jq iperf3 sysstat rsync file
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>And other tools&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">apk add bat dufs gdu lsd yazi
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h2 id="final-notes">Final Notes
&lt;/h2>&lt;p>The most important part of this setup is the partitioning discipline. On a 1 GB disk, a lazy 512 MB &lt;code>/boot&lt;/code>, a swap partition, or unbounded logs will consume the entire machine faster than the application does.&lt;/p>
&lt;p>With a 64 MB &lt;code>/boot&lt;/code>, 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.&lt;/p>
&lt;div class="footnotes" role="doc-endnotes">
&lt;hr>
&lt;ol>
&lt;li id="fn:1">
&lt;p>&lt;a class="link" href="https://wiki.alpinelinux.org/wiki/Setting_up_disks_manually" target="_blank" rel="noopener"
>Setting up disks manually - Alpine Linux&lt;/a>&amp;#160;&lt;a href="#fnref:1" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&lt;/p>
&lt;/li>
&lt;li id="fn:2">
&lt;p>&lt;a class="link" href="https://docs.alpinelinux.org/user-handbook/0.1a/Installing/setup_alpine.html" target="_blank" rel="noopener"
>setup-alpine - Alpine Linux Documentation&lt;/a>&amp;#160;&lt;a href="#fnref:2" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&lt;/p>
&lt;/li>
&lt;li id="fn:3">
&lt;p>&lt;a class="link" href="https://wiki.alpinelinux.org/w/index.php?title=Install_to_disk" target="_blank" rel="noopener"
>System Disk Mode - Alpine Linux&lt;/a>&amp;#160;&lt;a href="#fnref:3" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&lt;/p>
&lt;/li>
&lt;li id="fn:4">
&lt;p>&lt;a class="link" href="https://wiki.alpinelinux.org/wiki/Btrfs" target="_blank" rel="noopener"
>Btrfs - Alpine Linux&lt;/a>&amp;#160;&lt;a href="#fnref:4" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&lt;/p>
&lt;/li>
&lt;li id="fn:5">
&lt;p>&lt;a class="link" href="https://wiki.alpinelinux.org/wiki/Bootloaders" target="_blank" rel="noopener"
>Bootloaders - Alpine Linux&lt;/a>&amp;#160;&lt;a href="#fnref:5" class="footnote-backref" role="doc-backlink">&amp;#x21a9;&amp;#xfe0e;&lt;/a>&lt;/p>
&lt;/li>
&lt;/ol>
&lt;/div></description></item></channel></rss>