<<<< qubes-bitcoin-node-kit-2026-08-30.tar.gz >>>>
SHASUM: 2eb58041d75110e77f3031b9863b3962452fc5b807bb378b84a05111ee63cbc7
<< Raspiblitz issue for more information >>
I have spent a while getting a Bitcoin/Lightning node stack (RaspiBlitz v1.12.1)
running properly as a Qubes StandaloneVM, and three separable pieces came out of
it that seem worth sharing. Two are tools, one is just findings. All MIT, all
plain bash except one Python script.
Posting it as-is, for information and use. I am not maintaining it as a project
— take whatever is useful and adapt it.
Tarball: qubes-bitcoin-node-kit-2026-08-30.tar.gz (attached / linked above).
Up front: the two dom0 script sets are community tools, not Qubes components,
and parts of them are under-tested. Details on exactly which parts are below.
1. RaspiBlitz in a StandaloneVM — ~60 lines of changes
Mostly a Qubes storage-discovery story, and the interesting part is how small it
stays if you make two decisions correctly first:
- The data volume must carry a partition table. RaspiBlitz’s disk discovery
filters onTYPE=part. A whole-device ext4 on/dev/xvdireportsdiskand
is invisible no matter what else you change. - Build the VM from a Debian 12 template, not Debian 13. Bookworm ships
Python 3.11, which is exactly what the installer tests for;policykit-1
still exists; the pinnedgrpcio==1.59.3has a 3.11 wheel. On trixie all
four break at once and the change set goes from ~60 lines to ~700 across six
files. Also relevant: bookworm’s Debian-sources handling appends rather than
overwrites, so thequbes-vm-*repo lines survive the build.
With those two settled, the actual patch is three lines of device-name filter
(^(sd|nvme) → ^(sd|nvme|xvd) at three sites in blitz.data.sh) plus two
safety fixes.
Two things in there are worth flagging to anyone running any installer script
inside a Qubes VM, independent of Bitcoin:
apt-get remove --purge -y $pkg with an unquoted glob list. The upstream
build script does this over a list including eom*, vlc*, cups*,
firefox*. APT treats an argument containing *, . or ? as an unanchored
POSIX regex, not a shell glob — so eom* means “eo followed by zero or more
m, matched anywhere in the name”. On a Qubes VM that reaches
xserver-xorg-video-* and qubes-video-companion, with --purge -y and no
confirmation, and the following apt-get autoremove then cascades through their
dependencies.
autoremove and the Qubes guest tools. qubes-core-agent and friends are
largely Python and depend on python3-qubesdb, python3-qubesimgconverter,
python3-xcffib, python3-gi — most of which arrive as automatic
dependencies, exactly the category autoremove may delete. Lose them and you
lose clipboard, file copy, or GUI, and it surfaces much later as something
apparently unrelated. The defensive move that works regardless of which script
bites you:
qubes_pkgs=$(dpkg-query -W -f='${binary:Package} ${Status}\n' \
'qubes-*' 'python3-qubes*' 2>/dev/null | awk '/ok installed/ {print $1}')
sudo apt-mark manual $qubes_pkgs # autoremove never touches a manual package
and never run a bare apt-get autoremove in a VM without -s first.
2. btcvol / btcnode — several nodes off one copy of the chain
If you want two or three Bitcoin-adjacent StandaloneVMs (a full node, an
Electrum server, a Liquid node, a spare) you do not want to download or store
the chain two or three times.
btcvol runs in dom0 and manages LVM thin volumes: one btc_base holding a
fully synced chain, and a thin snapshot per node. A snapshot costs nothing until
it is written to, so three nodes off a 600 GB base start out using 600 GB rather
than 1.8 TB. Periodically you rotate — promote the node with the best chain
to be the new base, re-cut the others from it. That is metadata only: no
copying, no re-sync, and every node gets a current chain. It also gives you a
free rollback point before an upgrade.
btcnode runs inside each node qube and exists because nothing on the
shared volume survives a rotation. Wallets, configs, TLS certs, macaroons and
channel backups live on the qube’s own private volume under /rw/nodes/<svc>/
and get bind-mounted into place at boot, so the daemon opens one directory and
cannot tell. (Bind mounts rather than copy-out-before-wipe, because copy-out
does not run when a qube crashes or is killed, and the wipe is on a timer that
runs anyway.)
The same mechanism is a backup, and that is half the point. Because a
snapshot is free until written to, a rollback point costs almost nothing.
Rotation renames btc_base to btc_base_prev_<timestamp> rather than deleting
it, so the previous good chain is always still there, and --keep-prev decides
how many to retain. Rolling back is just rotating a retired base into place.
Take one before any upgrade or config change you are unsure of.
If a chain corrupts, the other nodes are independent copies that diverged at a
known point — you rotate from a good one instead of re-downloading. A spare node
that has been shut down for a month is effectively a warm backup that catches up
in minutes.
Be clear about which kind of backup that is: redundancy against chain
corruption, a bad upgrade or a torn LevelDB, not against hardware failure.
Every snapshot is in the same thin pool on the same disk, so a dead SSD takes
all of it at once. Wallets are the thing that needs real backup, and they are
deliberately not on these volumes.
Multi-config, decided by which disk dom0 hands over. btcnode install
appends itself to /rw/config/rc.local so the binds re-apply every boot. Which
service stack a qube runs is then chosen by reading the ext4 label of the
volume it was given: btc_node_a gets the full stack (bitcoin, lnd,
clightning, electrs, elements), any other btc_node_* gets bitcoin + lnd, and
if the volume is not mounted yet it falls back to the hostname.
That is why the nodes are lettered rather than numbered — the label is the one
thing that genuinely differs between otherwise identical qubes, so it is the
sensible thing to key off. The effect is that identical qubes become different
roles purely by which volume dom0 attaches, and you change a qube’s role by
handing it a different disk. Override per-qube with --profile, or replace the
map entirely via BTCNODE_SERVICES in /rw/config/btcnode.conf, which is just
a list of source | target | names to leave alone.
btcnode bind refuses to run if the mount point is not actually a mount. Worth
keeping if you adapt this: without that guard, a boot where the block device
failed to attach binds your identity onto the qube’s root filesystem and lets
bitcoind start a fresh 600 GB sync into it.
Four Qubes/LVM behaviours shaped the whole design, and each one cost me an
afternoon. They may be the most useful part of this post:
- Qubes hides a dom0 block device the moment anything “uses” it.
udev-block-add-changetreats a device as used if it is mounted, has entries
in/sys/class/block/<dev>/holders, or has a device-mapper open count above
zero — and it hides a disk when one of its partitions is used. So a
leftoverkpartxpartition map is precisely what makes the parent volume
vanish fromqvm-blockand from Global Config’s device list. dm-Nnumbers are handed out in activation order. The Qubes port ID is
the kernel name, sodom0:dm-7can becomedom0:dm-9after a reboot and a
persistent device assignment silently starts pointing somewhere else. Pinning
LVM minor numbers fixes it.- Thin snapshots created with
-kycarry a skip-activation flag, and a
plainlvchange -ayignores them without saying so. Everything needs
lvchange -ay -K. - A snapshot inherits the origin’s ext4 journal state and UUID.
tune2fs
quietly refuses to touch a dirty filesystem, so a label change needs
e2fsck -f -pfirst; and the duplicated UUID makesmount UUID=ambiguous
inside a qube, so fresh snapshots need--new-uuid.
There is a btcvol doctor that walks exactly the checks Qubes’ own udev helper
makes and names which one is biting you.
Also worth knowing if you use thin pools for anything: a thin pool that
reaches 100% takes every volume in it read-only. Deleting a file inside a qube
does not return the block to the pool unless something issues a discard, and the
pool needs discards=passdown or trimmed blocks are freed inside the pool but
never returned to the SSD. Read the pool’s own data% — per-volume DATA%
double-counts blocks that siblings share, so it always looks better than reality.
Testing status, plainly: volume creation and manual rotation have been used
for real a handful of times and work. Several ancillary functions —
doctor, trim, install-timers, prune, grow, pin-minors, the
auto-rotate qube orchestration, the dom0-side fsck — have not been
meaningfully exercised. Everything supports --dry-run and there are namespace
locks (only btc_* names are accepted), attachment checks read the Xen vbd
backends directly, and the base is renamed rather than deleted on rotation. But
this is dom0: read it before you run it.
The value I would claim is the design and the bulk of the work, not a finished
product. Where a helper does not do what you need, the fix is usually obvious
from the surrounding code — and it is exactly the sort of edit an LLM will do
well from the existing file. Adapt it rather than treating it as finished.
And a slight suggestion: get comfortable with a single node and the manual
maintenance first — snapshot before an upgrade, watch the pool, fstrim and
e2fsck a stopped volume — then do one manual rotation with --dry-run, and
only then go anywhere near multiple nodes, multiple service profiles, auto-trim
and auto-rotate. install-timers ships the rotate timer disabled on purpose.
3. qprof / qswap — profiling and a volatile swap tier
These two know nothing about Bitcoin. They were written for this node, but
neither cares what the VM runs, so if you use Qubes at all these are probably
the parts worth taking.
qprof profiles dom0 and selected Linux qubes concurrently and emits one
combined .tgz per run: actual Xen RAM and CPU-core equivalents per domain,
vCPU placement, Xen free memory, LVM-thin allocation, PSI, zram/zswap internals
(pool size, stored pages, writebacks, rejects, pool-limit hits, derived
compression ratio), real per-block-device reads/writes/discards, and cgroup-v2
CPU deltas — the last of which catches short-lived helper processes that PID
sampling misses entirely. It accounts for its own profiler overhead separately.
qprof -R analysis # choose the qube that receives archives, once
qprof server-vm # one 10-minute combined report
qprof -d -t 1800 -i 5 vm # deep, 30 min, 5 s interval
qprof -P server-vm # redact IP/MAC/UUID/gateway for sharing
-P deliberately does not rename VM, process, service or device names,
because those are what make a report diagnosable — so still inspect an archive
before publishing it. There is no agent to install: it self-deploys a guest mode
over qvm-run and cleans up after itself. qprof with no arguments profiles
every running qube.
Questions it answers that are otherwise tedious on Qubes: which qube is actually
making the machine swap; whether a VM is using the maxmem you gave it or has
never gone near it; what is writing to the SSD at 3am; which systemd service
inside a guest is burning CPU. That last one is where cgroup-v2 accounting
earns its place — it catches short-lived helpers that PID sampling misses
entirely, which is exactly how the background.scan finding surfaced.
qswap gives a memory-constrained qube a swap tier that does not thrash the
SSD — again, any VM you would rather not simply hand more RAM. It manages a
Qubes-native volatile swap-extra volume (save_on_stop=False, so it is
discarded every shutdown, which is what you want from swap and means it never
accumulates on disk), a bounded zstd zswap pool in front of it, the swap
discard policy, and the VM’s base/maxmem.
The zswap pool is the part that matters: it compresses cold anonymous pages in
RAM first, so most of what would have been written never reaches the device —
measured ~4.3:1 compression and ~0.14 MiB/s of actual backing-store writeback
under real memory pressure. max_pool_percent is a ceiling on how much RAM the
compressed pool may occupy; it is not preallocated and reserves no SSD space.
It also handles the zram-to-zswap transition rather than fighting it. An
existing zram package is left installed but its services blocked at boot
(simpler to reverse across upgrades than uninstalling), and an already-active
zram device is only torn down once the new swap is live and zram holds under
64 MiB — otherwise migration is deferred and logged, because doing it eagerly is
a good way to cause the OOM you were avoiding.
sudo qswap s server-vm 32G # volatile swap-extra
sudo qswap z server-vm -r # zswap + zstd + page discard, reboot
sudo qswap m server-vm 4G 6G -r # 4 GiB base, 6 GiB ceiling
sudo qswap st server-vm # status
qswap st is worth having on its own, because Qubes memory reporting is
genuinely confusing. It separates configured base memory, configured maxmem,
current Xen-assigned memory, supported-feature.memory-hotplug, the guest’s
qubes-meminfo-writer state, and Qubes Manager’s qubesmanager.maxmem_value —
which is a saved GUI value, not active maxmem, and is labelled as such because
it confused me for a while. The related gotcha it exists to explain: raising
maxmem reserves nothing, and lowering it on a running VM does not hot-remove
RAM already assigned, which is why -r restarts the VM.
Measured outcome for one busy multi-network node VM: at a 5 GiB Xen ceiling
it stayed healthy — mean memory PSI ~0.02%, no zswap pool-limit hits, ~1.8 GiB
MemAvailable, backing-swap writeback ~0.14 MiB/s, zswap compression ~4.3:1.
At 6 GiB it was quieter with often near-zero steady writeback. Those are
reference measurements for one service mix, not sizing advice — the point of
shipping the profiler is that you measure your own.
The one genuinely unsupported mechanism in the package. Qubes 4.3 has no
public qvm-volume add/remove for arbitrary internal VM volumes. So for
add/remove only, qswap requires the VM halted, takes an exclusive dom0 lock,
backs up /var/lib/qubes/qubes.xml, stops qubesd, mutates the storage model
through the Qubes Python API, saves, and restarts qubesd. The volume name is
hard-coded to swap-extra so a stock volume cannot be selected by accident, and
the guest hook verifies the exact frontend from live libvirt XML, the exact
expected byte size, whole-device status, no mount and no foreign filesystem
signature before it will mkswap. That is meant to make it failure-safe, but it
is still offline configuration mutation. If you are not comfortable with that,
use qprof alone and set memory with qvm-prefs by hand.
If Qubes ever grows a supported add/remove-volume API or CLI, this part could
become boring, which would be a clear improvement over the above.
Smaller things in there that solve general Qubes problems
Surfacing these separately because they are buried inside a Bitcoin package and
nobody will guess they exist.
btcvol doctor is a general dom0 block-device debugger. If you have ever
attached a volume in dom0 and had it simply not appear in qvm-block or Global
Config, this walks the exact checks Qubes’ own udev helper makes and names which
one is failing: inactive, no device node, empty or skipped DM_NAME, leftover
partition maps, non-zero DM open count, mounted in dom0,
DM_UDEV_DISABLE_DISK_RULES_FLAG set without the matching export rule, absent
from QubesDB. It is namespace-locked to btc_*, but the check list is the
reusable part and it is short.
QUBES_EXPORT_BLOCK_DEVICE=1 is the flag that keeps a dom0 device visible
to Global Config. btcvol install-udev installs a rule that forces it on for a
namespace. Useful to know the flag exists at all.
Persistent device assignments break silently when dm-N renumbers. The
Qubes PORT_ID is the kernel name, and minors are handed out in activation
order, so the assignment Global Config stored can quietly point at a different
volume after a reboot. pin-minors sets persistent LVM minor numbers. This
applies to any dom0 LV you attach persistently, Bitcoin or not.
RequiresMountsFor= drop-ins, and the nofail trap. With nofail in
fstab, a qube boots happily without its data disk, and whatever daemon you
installed then starts writing to the root volume. The fix is a one-line drop-in
per unit:
# /etc/systemd/system/<unit>.service.d/10-local.conf
[Unit]
RequiresMountsFor=/mnt/disk_storage
btcnode install writes that for every unit it finds. Generic pattern, worth
stealing.
The fstab options for a data volume in a qube got picked over line by line,
and three of the findings are general:
nobarrieris actively dangerous and turns up in a lot of copied fstab
lines. It lets the drive reorder writes across a journal commit, so an
ungraceful shutdown can leave ext4 internally consistent while a LevelDB or
similar store is torn. Barriers cost very little on an SSD.x-systemd.device-timeout=10s— without it, a qube booted while the
volume is detached stalls 90 seconds waiting for a device that is not coming.0 2→0 0— a boot-time fsck on a large filesystem adds minutes to
every start, and withnofaila failure drops you to an emergency shell. Run
the check from dom0 on a schedule instead.
Also: defaults expands to rw,suid,dev,exec,..., which silently contradicts a
noexec later on the same line. Last-one-wins made it work by accident.
Protecting Qubes guest tools from a third-party installer’s autoremove.
Not specific to RaspiBlitz — it applies to any vendor install script you run
inside a VM:
qubes_pkgs=$(dpkg-query -W -f='${binary:Package} ${Status}\n' \
'qubes-*' 'python3-qubes*' 2>/dev/null | awk '/ok installed/ {print $1}')
sudo apt-mark manual $qubes_pkgs # autoremove never touches a manual package
The package also derives the recursive python3-* dependency set, since those
are what actually get swept. And never run a bare apt-get autoremove in a VM
without apt-get -s autoremove first.
Trimming a volume no qube has mounted. In-qube fstrim.timer only works
while the qube is up, and never reaches a base volume nothing mounts. btcvol trim mounts the partition in dom0 just long enough to fstrim it, then
unmounts and tears the map down (-t ext4 explicitly, never auto-probed, plus
nodev,nosuid,noexec — dom0 mounting a qube-controlled filesystem is a real
attack surface, so that trade is yours to accept or skip). Volumes a running
qube holds are skipped, not forced. --dom0 also trims dom0’s own filesystems.
Thin snapshots cannot cross pools or VGs. LVM will not place a snapshot in a
different pool from its origin, so a second SSD is either a bigger pool for
the existing set or a separate set — there is no third option. And when
extending, use /dev/disk/by-id/, never /dev/nvme1n1: device ordering changes
between boots.
The dom0 safety-rail pattern, if you write dom0 scripts of your own: every
volume name goes through a regex that only accepts the managed namespace, so the
script cannot touch root, swap or another qube’s private volume even if
asked; the in-use check reads the Xen vbd backends directly rather than trusting
config state; anything that allocates refuses past 90% pool usage and warns past
75%; destructive operations rename rather than delete; and --dry-run prints
every command instead of running it. None of that is clever, but having it in
one readable file may save writing it again.
Patching an installer script locally instead of forking it to GitHub. If you
need a vendor build script to install your modified source, git clone --local --no-hardlinks is the move, with three non-obvious details: root must do the
clone if the source sits under a mode-700 home (git reports the permission
failure as “repository does not exist”, which sends you hunting for a directory
that is plainly there); drop any -b <branch> since your local branch will not
match theirs; and --no-hardlinks matters or the installed copy shares git
objects with your working tree and later edits corrupt it. git clone --local
copies the committed HEAD, not the working tree, so commit first.
Everything is in one tarball
01-raspiblitz-on-qubes/ the ~60-line change set
02-cow-volumes-multi-node/ btcvol (dom0) + btcnode (node qube)
03-memory-profiling-and-swap/ qprof + qswap + the measurements behind the defaults
UPSTREAM-RASPIBLITZ-NOTES.md findings aimed at RaspiBlitz rather than Qubes
If nothing else here is useful, the four Qubes/LVM behaviours in section 2
probably are — they are the part that generalises well beyond Bitcoin, and they
cost the most to work out.