Why I Built a Dedicated Password Mother VM
The limits of storing everything on a phone
Why I did not want my daily workstation to be the vault
My real-world setup: Ubuntu KVM host, work VMs, and phone-based high-frequency 2FA
Design Goals and Threat Model
This is a warm vault, not a cold vault
What I wanted to protect
What I was not trying to protect against
What this VM should never become
Balancing usability, isolation, and recovery
High-Level Architecture
Ubuntu 20.04 KVM host
Dedicated qcow2 vault VM
Alpine Linux + LXQt as the guest environment
KeePassXC as the main vault interface
Phone as a secondary high-frequency 2FA device
Access boundary: console-first, no routine remote access
Trust boundary: what crosses in and out of the VM
Building the Vault VM
After deciding to build a dedicated KeePassXC vault VM, I started with a very small and boring base system: Alpine Linux, LXQt, and KeePassXC.
The goal of this VM is not to become another daily-use Linux desktop. It is not a browser machine, not an email machine, not a chat machine, and not a general-purpose workstation. It exists for one main purpose: opening and managing local KeePassXC databases in an isolated environment.
For this build, my host is an Ubuntu KVM/libvirt machine, and the vault VM uses a dedicated qcow2 disk.
Creating the qcow2 disk
I created a 20 GB qcow2 disk for the VM:
qemu-img create -f qcow2 vault-vm.qcow2 20GThis does not immediately consume 20 GB on the host. A qcow2 image grows as data is written to it, so 20G is the virtual maximum size, not necessarily the initial physical size.
Alpine Linux, LXQt, and KeePassXC do not need much disk space. The KeePassXC database files themselves are usually tiny compared with the operating system. However, I still prefer 20 GB over something like 8 GB because it leaves enough room for package updates, logs, temporary files, and future maintenance.
For a disposable test VM, 8 GB would probably be enough. For a long-lived vault VM, I think 16–20 GB is a better default. I would only go larger if I planned to store additional offline recovery material inside the VM, which is not the main purpose of this design.
The important point is that the disk should be large enough to avoid annoying maintenance problems later, but not so large that the VM starts to feel like a general-purpose desktop.
Installing Alpine Linux
I used the Alpine Linux virt ISO:
alpine-virt-3.23.4-x86_64.isoThe virt image is a good fit for a KVM guest because it is small and aimed at virtualized environments.
After booting the ISO, I logged in as root and started the Alpine installer:
setup-alpineFor this VM, I used Alpine’s normal system-disk installation mode, not diskless mode. The goal is to install Alpine permanently into the qcow2 image, then build a small LXQt desktop on top of it.
During setup-alpine, I kept the choices intentionally boring:
Keyboard: usHostname: vault-vmNetwork: DHCPSSH server: noneNTP: chronyInstall disk: vdaDisk mode: sysThe most important choice is the disk mode:
sysThis installs Alpine as a normal system onto the virtual disk. I do not want the vault VM to depend on the installer ISO after setup.
I also did not enable SSH inside the guest. This VM is meant to be accessed through the hypervisor console, not through a network service running inside the VM. If I ever need SSH for temporary maintenance, I can install and enable it later, then disable it again when finished.
After the installation completed, I shut the VM down, removed the ISO from the virtual CD-ROM, and booted from the qcow2 disk.
Inside the installed system, I checked the basic state:
cat /etc/alpine-releaseip addrdf -hI wanted to confirm three things:
Alpine boots from the qcow2 diskThe VM has network access during setupThe root filesystem is on the virtual diskAt this point, the VM was a minimal Alpine system with no desktop yet.
Installing LXQt and basic desktop utilities
Next, I installed a lightweight graphical desktop.
I wanted a GUI because KeePassXC is much more convenient as a desktop application, especially when reviewing entries, copying passwords, and managing TOTP records. However, I did not want a heavy desktop environment or a general-purpose workstation setup.
LXQt is a good fit for this kind of VM: small, simple, and boring.
Since my package downloads go through a local HTTP proxy, I first configured proxy variables in the root shell:
export http_proxy="http://192.168.199.60:8889"export https_proxy="http://192.168.199.60:8889"export HTTP_PROXY="$http_proxy"export HTTPS_PROXY="$https_proxy"Then I updated the APK repository indexes:
apk updateI also made sure the Alpine community repository was enabled in:
/etc/apk/repositoriesFor Alpine 3.23, the repository file should include both main and community, for example:
https://dl-cdn.alpinelinux.org/alpine/v3.23/mainhttps://dl-cdn.alpinelinux.org/alpine/v3.23/communityThen I installed LXQt using Alpine’s desktop setup helper:
setup-desktop lxqtThis installed LXQt, SDDM, and the required desktop components. The installer also enabled the expected desktop services:
dbuselogindsddmAfter the desktop packages were installed, Alpine warned me to create a normal user account:
setup-userI created a dedicated desktop user:
vaultI did not use root as the graphical desktop account. Root is only for system maintenance from the console. The daily graphical session should run as an unprivileged user.
I also did not configure doas or sudo for the vault user. For this VM, that is intentional. If I need to perform system maintenance, I can log in as root through the VM console. The normal desktop user does not need routine administrative privileges.
After rebooting, SDDM started automatically, and I was able to log in to LXQt with the vault account.
At this stage, I verified the basics:
The graphical login screen appearsThe vault user can log inThe mouse and keyboard workThe terminal opensThe file manager opensThe system can shut down and reboot cleanlyI still did not install a browser, email client, chat client, office suite, or development tools. Those are outside the purpose of the vault VM.
Installing KeePassXC
With LXQt working, I installed KeePassXC from Alpine’s package repository.
As root:
export http_proxy="http://192.168.199.60:8889"export https_proxy="http://192.168.199.60:8889"export HTTP_PROXY="$http_proxy"export HTTPS_PROXY="$https_proxy"
apk updateapk add keepassxc keepassxc-langI used Alpine’s package instead of downloading an AppImage or a third-party binary. This keeps the VM reproducible and easy to update through the normal package manager.
I did not install browser integration packages. This VM does not have a browser, and I do not want it to become a browser autofill environment. KeePassXC is used here as a local desktop application for opening encrypted .kdbx databases.
Before importing any real secrets, I created a temporary test database under the normal desktop user account.
For example:
/home/vault/Vaults/test-vault.kdbxI verified that KeePassXC could:
Create a databaseSave the databaseClose the databaseReopen the databaseUnlock the database with the master passwordApply the expected security settingsFor the test database, I used a deliberately weak password only to confirm the application worked. That test password was not suitable for real use and should not be used for any real secrets.
For real databases, I would not use a short “complex-looking” password like:
P@ssw0rdThat kind of password is weak because it follows common human patterns. A better approach is a long passphrase made from randomly selected words.
For the daily database, I would use around five or six random words. For the core database, I would use around eight random words.
The important part is that the words should be randomly generated, not personally chosen.
For example, the structure should look more like this:
word-word-word-word-word-wordnot like this:
MyVaultIsVerySecure2026!The daily database should be strong but still realistic to unlock regularly. The core database should be stronger and opened much less often.
This distinction matters. A vault VM should not turn every login into a punishment. If the daily database is too annoying to unlock, I will eventually work around it. If the core database is too convenient to open, I may leave high-value secrets exposed for longer than necessary.
The goal is to put friction in the right places.
Disabling unnecessary services
After installing LXQt and KeePassXC, I reviewed the services enabled inside the guest.
The goal was not to break the desktop or remove everything possible. The goal was to keep only the services required for a local graphical KeePassXC workstation.
I checked the active services:
rc-statusAnd I checked what was enabled at boot:
rc-update showThe important desktop services were:
dbuselogindsddmThese should stay enabled.
dbus is needed by the desktop environment. elogind handles graphical sessions and user session management. sddm provides the graphical login screen.
I also want time synchronization available because TOTP depends on accurate time. If chronyd is enabled, I would keep it.
On this VM, the only default service I did not need was crond. Since the vault VM does not run scheduled jobs, I disabled it:
rc-update del crond defaultrc-service crond stopI did not enable SSH inside the guest. The vault VM is accessed through the hypervisor console, not by logging into the guest over the network.
I also did not install or enable services such as:
SSHVNCRDPCUPSAvahiSambaNFSDockerBluetoothDevelopment daemonsFile sharing servicesA dedicated password vault VM does not need those.
To check for listening network services, I used:
ss -lntupIf ss is not installed, it can be added with:
apk add iproute2The desired result is that the VM does not expose unnecessary listening services. In particular, I do not want to see SSH listening on:
0.0.0.0:22:::22Networking can remain available during setup and maintenance, especially for package updates. But network access should be treated as temporary infrastructure, not as a reason to turn the vault VM into a general-purpose networked machine.
What I deliberately did not add
So I deliberately avoided adding software that would turn the vault VM into another daily workstation:
- no web browser for normal use;
- no email client;
- no chat or notification apps;
- no cloud sync client;
- no shared folders enabled by default;
- no standing SSH, VNC, or remote desktop service;
- no general-purpose development environment;
- no office suite or document workflow.
The common rule is simple: I avoid anything that consumes untrusted content, creates a persistent path between the VM and the outside world, or encourages me to keep the vault VM running longer than necessary.
This does not mean the VM is never updated or maintained. It means that maintenance is deliberate, while daily operation stays narrow. If I need to add a tool, I treat it as a change to the security model, not as a casual convenience tweak.
The vault VM should remain boring: LXQt, KeePassXC, a few basic utilities, and as little else as possible.
Taking an initial clean VM snapshot
Before importing any real secrets, I created a clean baseline of the vault VM.
At this point, the VM had:
Alpine Linux installedLXQt workingA normal vault desktop userKeePassXC installedBasic security settings configuredUnnecessary services reviewedNo real password database importedThis is the ideal moment to capture a clean recovery point.
Inside the guest, I cleaned the package cache:
apk cache cleanThen I removed the temporary test database:
rm -f /home/vault/Vaults/test-vault.kdbxI also removed shell history files:
rm -f /root/.ash_historyrm -f /home/vault/.ash_historyThen I shut the VM down:
poweroffI prefer to make this kind of baseline while the VM is fully powered off. Copying a qcow2 image while the guest is running can produce an inconsistent copy.
On the KVM host, I confirmed the VM was shut down:
virsh list --allThen I copied the qcow2 image:
cd /run/media/VirtStoragecp --reflink=auto --sparse=always vault-vm.qcow2 vault-vm.clean-base.qcow2I checked the result:
ls -lh vault-vm*.qcow2qemu-img info vault-vm.clean-base.qcow2This gave me a simple clean baseline:
vault-vm.qcow2 active working diskvault-vm.clean-base.qcow2 clean installed baselineIf I later damage the VM configuration, I can restore the clean baseline:
cd /run/media/VirtStorage
mv vault-vm.qcow2 vault-vm.broken.qcow2cp --reflink=auto --sparse=always vault-vm.clean-base.qcow2 vault-vm.qcow2Then I can start the VM again:
virsh start vault-vmThis snapshot is useful because it gives me a known-good starting point: Alpine, LXQt, and KeePassXC are installed, but no real secrets have been imported yet.
Why a snapshot is not a backup
The clean snapshot is useful, but it is not a backup strategy.
A snapshot or local qcow2 copy is mainly a rollback point. It helps if I break the VM configuration, install the wrong package, or want to return to a clean pre-secrets state.
It does not solve the bigger recovery problems.
A local snapshot is not enough because:
- It usually lives on the same host
- It may live on the same physical disk
- It may be lost if the host disk fails
- It may not contain the latest KeePassXC databases
- It may not help if the host itself is unavailable
- It does not replace offline recovery material
For this vault design, the important backups are the KeePassXC databases and the recovery paths around them, not just the VM image.
The VM image is replaceable. The secrets are not.
That means I need a separate backup strategy for the actual .kdbx files. At minimum, that should include:
- Versioned backups of the KeePassXC databases
- Offline copies
- Recovery material stored outside the VM
- A tested restore process
- A way to recover without the running vault VM
The clean VM snapshot is still worth keeping. It saves setup time and gives me a known-good base system. But it should not be confused with a real backup.
A good way to think about it is:
- Snapshot: helps me recover the VM environment
- Backup: helps me recover the data and secrets
For a password vault, that distinction matters.
If the vault VM breaks, I should be able to rebuild it.
If the KeePassXC database is lost, corrupted, or inaccessible, I need a real recovery plan.



