Frederick Sun - AI Developer Logo

Explorer posts by categories

Building a Dedicated KeePassXC Vault VM on KVM with Alpine Linux and LXQt

Building a Dedicated KeePassXC Vault VM on KVM with Alpine Linux and LXQt

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:

Terminal window
qemu-img create -f qcow2 vault-vm.qcow2 20G

This 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.iso

The 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:

Terminal window
setup-alpine

For 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: us
Hostname: vault-vm
Network: DHCP
SSH server: none
NTP: chrony
Install disk: vda
Disk mode: sys

The most important choice is the disk mode:

sys

This 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:

Terminal window
cat /etc/alpine-release
ip addr
df -h

I wanted to confirm three things:

Alpine boots from the qcow2 disk
The VM has network access during setup
The root filesystem is on the virtual disk

At 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:

Terminal window
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:

Terminal window
apk update

I also made sure the Alpine community repository was enabled in:

Terminal window
/etc/apk/repositories

For Alpine 3.23, the repository file should include both main and community, for example:

https://dl-cdn.alpinelinux.org/alpine/v3.23/main
https://dl-cdn.alpinelinux.org/alpine/v3.23/community

Then I installed LXQt using Alpine’s desktop setup helper:

Terminal window
setup-desktop lxqt

This installed LXQt, SDDM, and the required desktop components. The installer also enabled the expected desktop services:

dbus
elogind
sddm

After the desktop packages were installed, Alpine warned me to create a normal user account:

Terminal window
setup-user

I created a dedicated desktop user:

vault

I 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 appears
The vault user can log in
The mouse and keyboard work
The terminal opens
The file manager opens
The system can shut down and reboot cleanly

I 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 update
apk add keepassxc keepassxc-lang

I 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.kdbx

I verified that KeePassXC could:

Create a database
Save the database
Close the database
Reopen the database
Unlock the database with the master password
Apply the expected security settings

For 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@ssw0rd

That 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-word

not 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:

Terminal window
rc-status

And I checked what was enabled at boot:

Terminal window
rc-update show

The important desktop services were:

dbus
elogind
sddm

These 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:

Terminal window
rc-update del crond default
rc-service crond stop

I 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:

SSH
VNC
RDP
CUPS
Avahi
Samba
NFS
Docker
Bluetooth
Development daemons
File sharing services

A dedicated password vault VM does not need those.

To check for listening network services, I used:

Terminal window
ss -lntup

If ss is not installed, it can be added with:

Terminal window
apk add iproute2

The 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
:::22

Networking 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 installed
LXQt working
A normal vault desktop user
KeePassXC installed
Basic security settings configured
Unnecessary services reviewed
No real password database imported

This is the ideal moment to capture a clean recovery point.

Inside the guest, I cleaned the package cache:

Terminal window
apk cache clean

Then I removed the temporary test database:

Terminal window
rm -f /home/vault/Vaults/test-vault.kdbx

I also removed shell history files:

Terminal window
rm -f /root/.ash_history
rm -f /home/vault/.ash_history

Then I shut the VM down:

Terminal window
poweroff

I 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:

Terminal window
virsh list --all

Then I copied the qcow2 image:

Terminal window
cd /run/media/VirtStorage
cp --reflink=auto --sparse=always vault-vm.qcow2 vault-vm.clean-base.qcow2

I checked the result:

Terminal window
ls -lh vault-vm*.qcow2
qemu-img info vault-vm.clean-base.qcow2

This gave me a simple clean baseline:

vault-vm.qcow2 active working disk
vault-vm.clean-base.qcow2 clean installed baseline

If I later damage the VM configuration, I can restore the clean baseline:

Terminal window
cd /run/media/VirtStorage
mv vault-vm.qcow2 vault-vm.broken.qcow2
cp --reflink=auto --sparse=always vault-vm.clean-base.qcow2 vault-vm.qcow2

Then I can start the VM again:

Terminal window
virsh start vault-vm

This 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.

Credential Architecture

Why I chose KeePassXC

Why I use two databases

What goes into the daily database

What goes into the core database

TOTP placement strategy

The circular dependency problem

Recovery keys, backup codes, and seed material

Daily Operation Model

Console access vs remote access

Clipboard and copy-paste trade-offs

Rules for starting, unlocking, and shutting down the VM

Locking and session management

Day-to-day password retrieval workflow

What I do when I need to enter credentials into another VM

Backup and Recovery Model

What must be backed up

Offline backup strategy

Emergency recovery paths

Failure scenarios I planned for

Recovery drills

What I never want to discover during an actual emergency

Migration Plan

Auditing existing passwords and 2FA locations

Moving accounts into daily vs core databases

Reducing phone-only dependencies

Testing recovery before deleting old copies

Who This Setup Is For — and Who It Is Not For

Final Thoughts

profile image of Frederick Sun

Frederick Sun

AI Full-stack Developer | Cloud-Native Architect | Philosophy Hobbyist. Documenting my journey through code, systems, and thoughts from Shanghai.

Read all posts of Frederick