Encrypted Pool for Secret Hidden LUKS Encrypted AppVMs / Templates / HVMs

In this guide, we will create an encrypted storage pool for sensitive VMs - such as a vault - that is protected by its own separate LUKS passphrase. This is not your system disk password entered at boot; it is an independent passphrase used exclusively to unlock the vault pool when you choose to open it. Secret VMs are hidden when the pool is closed (hidden from app menu and qube manager).

$ qvm-pool
NAME          DRIVER
varlibqubes   file
linux-kernel  linux-kernel
vm-pool       lvm_thin
vault         lvm_thin

:white_check_mark: This guide solves the old problems:

Many users have been requesting this functionality for a long time. In my anti-forensics guide, I used an ephemeral encrypted dm-container in overlay mode, but we can build a persistent encrypted container protected by a passphrase, which will serve as the backing store for a dedicated secret pool.

How it works: the setup script performs the following steps. First, it allocates a large sparse file (vault.img) and attaches it as a loop device. Then it formats the loop device with LUKS encryption and opens it, creating a decrypted mapper device. On top of that mapper, we build an LVM thin pool (PV → VG → thin LV). The thin pool is then registered with Qubes as a custom lvm_thin pool named vault. Two helper scripts are installed in /usr/local/bin/:

  • vault-open - attaches the loop device, unlocks the LUKS container with your passphrase, activates the volume group, registers the pool in Qubes, adds hidden VMs to the app menu and qube manager, so your secret VMs can boot.
  • vault-close - gracefully shuts down any running VMs from the pool, deactivates LVM, closes the LUKS mapper,detaches the loop device, returning the pool to a fully encrypted offline state, hides secret VMs from the app menu and qube manager.

This gives you a seamless workflow: run sudo vault-open, start your sensitive VMs, and when finished, run sudo vault-close to lock everything away.

When the pool is closed, cryptsetup close removes the existing mapping and wipes the encryption key from kernel memory - this is explicitly documented in the cryptsetup manual. Once the key is erased from RAM, the encrypted container becomes inaccessible, and even a physical memory dump or cold boot attack cannot recover the volume key. The kernel securely erases the passphrase and any derived keys from memory, making this approach resistant to forensic memory analysis. This is a significant improvement over simply unmounting a filesystem, where encryption keys might linger in RAM.

:gear: Install:

  1. Make sure there is sufficient free space (more than 5 GB) in dom0. You can resize dom0 these commands (edit 5G if need mode vault space):
sudo lvresize --size 5G /dev/mapper/qubes_dom0-root
sudo resize2fs /dev/mapper/qubes_dom0-root
sudo lvresize -L +5G qubes_dom0/root-pool
  1. I recommend disabling disk swap for maximum security: Qubes OS uses zram for compressed RAM swap, but also maintains a fallback swap partition at /dev/dm-5 which is not encrypted. For a vault pool setup, this is a security risk - sensitive memory pages from your secret VMs could be written to disk in plaintext.
sudo sed -i '/\/dev\/mapper\/swap/!{/^[[:space:]]*#/!{/\<swap\>/s/^/# /}}' /etc/fstab
sudo sed -i '/\/dev\/mapper\/swap/b; /[[:space:]]\+swap[[:space:]]\+/s/^/# /' /etc/fstab
sudo dracut --force
  1. Run this simple script for create 5 GB encrypted pool vault
    (edit POOL_SIZE="5G" if you need a different pool size)
#!/bin/bash

set -euo pipefail

POOL_DIR="/var/lib/qubes/pools"
POOL_IMG="$POOL_DIR/vault.img"
POOL_SIZE="5G"
VG_NAME="vault_vg"
LV_NAME="vault_thin"
MAPPER_NAME="vault_crypt"

cleanup() {
    local dev="${LOOP_DEV:-}"
    if [ -n "$dev" ] && losetup -a | grep -q "$dev"; then
        echo "[*] Cleanup: detaching $dev"
        losetup -d "$dev" 2>/dev/null || true
    fi
}
trap cleanup EXIT

echo "========================================"
echo "  Creating an encrypted Qubes pool"
echo "========================================"
echo

if [ "$EUID" -ne 0 ]; then
    echo "[!] This script must be run as root (dom0)"
    exit 1
fi

if [ -f "$POOL_IMG" ]; then
    echo "[!] File $POOL_IMG already exists."
    read -r -p "    Delete and recreate? Type YES to confirm: " confirm
    if [ "$confirm" != "YES" ]; then
        echo "[!] Aborted by user"
        exit 1
    fi
    rm -f "$POOL_IMG"
fi

echo "[*] Creating directory $POOL_DIR"
mkdir -p "$POOL_DIR"

echo "[*] Creating a loop file of size $POOL_SIZE"
truncate -s "$POOL_SIZE" "$POOL_IMG"

echo "[*] Attaching loop device"
LOOP_DEV=$(losetup -f --show "$POOL_IMG")
echo "    Device: $LOOP_DEV"

echo
echo "[*] Encrypting device $LOOP_DEV"
echo "    Enter the LUKS passphrase (twice)"
cryptsetup luksFormat "$LOOP_DEV"

echo
echo "[*] Opening the LUKS container"
cryptsetup open "$LOOP_DEV" "$MAPPER_NAME"

echo
echo "[*] Creating LVM: PV -> VG -> Thin Pool"
pvcreate "/dev/mapper/$MAPPER_NAME"
vgcreate "$VG_NAME" "/dev/mapper/$MAPPER_NAME"
lvcreate -T -n "$LV_NAME" -l +100%FREE "$VG_NAME"

echo
echo "[*] Detaching the loop device (LVM stays active)"
losetup -d "$LOOP_DEV"
unset LOOP_DEV

qvm-pool --add vault lvm_thin \
    -o volume_group=vault_vg,thin_pool=vault_thin,revisions_to_keep=2
echo
echo "========================================"
echo "  Pool created successfully!"
echo "========================================"

cat > /usr/local/bin/vault-open << 'EOF'
#!/bin/bash
# open-and-register-vault.sh

set -euo pipefail

if [ "$EUID" -ne 0 ]; then
    echo "[!] Must be run as root"
    exit 1
fi

# 1. Attach loop
LOOP_DEV=$(losetup -f --show /var/lib/qubes/pools/vault.img)
echo "[*] Loop: $LOOP_DEV"

# 2. Unlock LUKS
cryptsetup open "$LOOP_DEV" vault_crypt
echo "[*] LUKS opened"

# 3. Activate LVM
vgchange -ay vault_vg
echo "[*] LVM activated"

# 4. Register pool in Qubes if not already present
if ! qvm-pool --list | grep -q "^vault "; then
    echo "[*] Registering pool in Qubes"
    qvm-pool --add vault lvm_thin \
        -o volume_group=vault_vg,thin_pool=vault_thin,revisions_to_keep=2
else
    echo "[*] Pool already registered"
fi
echo "[*] Restoring visibility of VMs..."
for vm in $(qvm-ls --raw-list 2>/dev/null); do
    [ -n "$vm" ] || continue
    if qvm-volume list "$vm" 2>/dev/null | grep -q "${POOL_NAME}"; then
        current_internal=$(qvm-features "$vm" internal 2>/dev/null || echo "")
        if [ "$current_internal" = "1" ]; then
            echo " -> Unhiding: $vm"
            qvm-features "$vm" internal '' 2>/dev/null || true
        fi
    fi
done
echo "[*] Done. Verification:"
qvm-pool --info vault
EOF

cat > /usr/local/bin/vault-close << 'EOF'
#!/bin/bash
# close-vault.sh

set -euo pipefail

if [ "$EUID" -ne 0 ]; then
    echo "[!] Must be run as root"
    exit 1
fi

# 1. Stop all running VMs from the vault pool
RUNNING_VMS=$(qvm-ls --running --fields=name,pool | grep vault | awk '{print $1}' || true)
if [ -n "$RUNNING_VMS" ]; then
    echo "[*] Stopping VMs from the vault pool..."
    for vm in $RUNNING_VMS; do
        echo "    -> $vm"
        qvm-shutdown --wait "$vm"
    done
    echo "[*] All vault pool VMs stopped"
fi

# Hide secret VMs
for vm in $(qvm-ls --raw-list 2>/dev/null); do
    [ -n "$vm" ] || continue
    if qvm-volume list "$vm" 2>/dev/null | grep -q "${POOL_NAME}"; then
        echo " -> Hiding: $vm"
        qvm-features "$vm" internal 1 2>/dev/null || true
    fi
done

# 2. Deactivate LVM
vgchange -an vault_vg || true

# 3. Close LUKS (wipes key from kernel memory)
cryptsetup close vault_crypt || true

# 4. Detach loop
LOOP_DEV=$(losetup -j /var/lib/qubes/pools/vault.img 2>/dev/null | head -1 | cut -d: -f1)
if [ -n "$LOOP_DEV" ]; then
    losetup -d "$LOOP_DEV"
fi

echo "[*] Pool locked and key wiped from memory"
EOF
  1. Confirm creation: When prompted, type YES to proceed (this overwrites any existing vault pool file).

  2. Set your LUKS passphrase: Enter and confirm a strong passphrase when prompted - this will be required every time you unlock the pool.

  3. Open the pool when needed in dom0:
    sudo vault-open
    and enter your LUKS passphrase.

  4. Assign VMs to the vault pool via Qubes Manager: click clone qube and in Advanced select vault in Storage pool. Or create a new qube, and select vault Storage pool in the Advanced Options.


    Now your secret VMs can run.

  5. Close the pool when finished:
    sudo vault-close
    this shuts down all vault VMs, deactivates LVM, wipes the encryption key from kernel memory, and locks the container. Secret VMs will disappear from the app menu and Qube Manager after the pool is closed.

:eyes: :eyes:

  • Opening large pools takes time. If you created a large pool and it contains significant data (tens of GB), opening it with vault-open may take 10-20 seconds - this is normal, as LVM needs to scan and activate the thin pool metadata.

  • Backup the container along with your VMs. You can copy the encrypted container file together with backups of your AppVMs from this pool. The container is located at:
    /var/lib/qubes/pools/vault.img
    Since the container is fully encrypted, you can store it on external media or in cloud storage without additional encryption - the LUKS passphrase protects all data inside.

  • If you need additional disk swap, you can create an ephemeral encrypted swap - there’s a simple guide in the description of this guide.

:bomb: Removing Encrypted Pool

To completely remove the vault pool and all associated data, use this script. This will permanently delete all VMs stored in the pool.
(When prompted, type DELETE to proceed - this permanently destroys all vault VMs and their data)

#!/bin/bash
set -euo pipefail

POOL_NAME="vault"
POOL_DIR="/var/lib/qubes/pools"
POOL_IMG="$POOL_DIR/vault.img"
VG_NAME="vault_vg"
LV_NAME="vault_thin"
MAPPER_NAME="vault_crypt"

echo "========================================"
echo "  Removing encrypted pool"
echo "========================================"
echo

if [ "$EUID" -ne 0 ]; then
    echo "[!] This script must be run as root (dom0)"
    exit 1
fi

echo "[!] WARNING: ALL data in the pool will be destroyed!"
read -r -p "    Type DELETE to confirm: " confirm
if [ "$confirm" != "DELETE" ]; then
    echo "[!] Aborted by user"
    exit 1
fi

for vm in $(qvm-ls --raw-list 2>/dev/null); do
    if qvm-volume list "$vm" 2>/dev/null | grep -q "${POOL_NAME}"; then
        echo "    -> Removing VM: $vm"
        qvm-kill "$vm" 2>/dev/null || true
        sleep 1
        qvm-remove --force "$vm" 2>/dev/null || true
        sleep 1
    fi
done

if lvs "$VG_NAME/$LV_NAME" &>/dev/null; then
    echo "[*] Deactivating thin pool $VG_NAME/$LV_NAME"
    lvchange -an "$VG_NAME/$LV_NAME" || true
fi

if vgs "$VG_NAME" &>/dev/null; then
    echo "[*] Deactivating Volume Group $VG_NAME"
    vgchange -an "$VG_NAME" || true
fi

if dmsetup info "$MAPPER_NAME" &>/dev/null; then
    echo "[*] Closing LUKS container /dev/mapper/$MAPPER_NAME"
    cryptsetup close "$MAPPER_NAME" || true
else
    echo "[*] LUKS container already closed"
fi

if vgs "$VG_NAME" &>/dev/null; then
    echo "[*] Removing Volume Group $VG_NAME"
    vgremove -y "$VG_NAME" || true
else
    echo "[*] Volume Group $VG_NAME not found or already removed"
fi

if [ -f "$POOL_IMG" ]; then
    LOOP_DEV=$(losetup -j "$POOL_IMG" 2>/dev/null | head -1 | cut -d: -f1)
    if [ -n "$LOOP_DEV" ]; then
        echo "[*] Detaching loop device $LOOP_DEV"
        losetup -d "$LOOP_DEV" 2>/dev/null || true
    else
        echo "[*] Loop device already detached"
    fi
fi

if [ -f "$POOL_IMG" ]; then
    echo "[*] Deleting container file $POOL_IMG"
    rm -f "$POOL_IMG"
fi

if [ -d "$POOL_DIR" ] && [ -z "$(ls -A "$POOL_DIR" 2>/dev/null)" ]; then
    echo "[*] Removing empty directory $POOL_DIR"
    rmdir "$POOL_DIR" 2>/dev/null || true
fi

qvm-pool remove $POOL_NAME

echo
echo "========================================"
echo "  Pool removed"
echo "========================================"

:check_mark: License:Unlicense

Also see these my guides:

6 Likes

The threat model is that somebody gains physical access to the machine while it is running and unlocked yes? I’m asking because everything except the /boot partition is already encrypted by the default installation.

Stupid question but what if somebody gains physical access to the machine while vault is unlocked?

PS: Always makes me smile when I see you post a new guide, keep up the good work!

If an attacker gains access to an unlocked device while the vault pool is closed, he will not be able to access the secret VMs - even with a memory dump. If the pool is open, then access to the secret VMs is open as well. That is why this pool is not opened by default: it is unlocked manually only when needed and then closed again.

1 Like

Thanks! I might write another guide soon

3 Likes

If I may suggest one :stuck_out_tongue: :slight_smile:

As you are into physical access, having the attacker finding a machine with “nothing on it” would be a lovely thing to have. As in instead of:

dom0:
$ sudo fdisk -l
Device           Start        End    Sectors  Size Type
/dev/nvme0n1p1    2048    1230847    1228800  600M EFI System
/dev/nvme0n1p2 1230848    3327999    2097152    1G Linux extended boot
/dev/nvme0n1p3 3328000 4000796671 3997468672    1T Linux filesystem

Moving the first two partitions as well as the luks header of the encrypted partition to a USB stick (in one nifty very stable script).

Then it looks like you just wiped the disk of the laptop with random data. You can place a ubuntu install usb stick or sth next to the laptop, and you have a reasonable excuse “yeah well was about to reinstall my box and overwrote the disk with random data cause encryption”.

/boot and luks header then live on the usb stick, and when you want to boot the box, you plug in the USB stick and boot that one.

1 Like

If an attacker gains access to an unlocked device while the vault pool is closed, he will not be able to access the secret VMs - even with a memory dump. If the pool is open, then access to the secret VMs is open as well. That is why this pool is not opened by default: it is unlocked manually only when needed and then closed again.

I want to add sth here. While I’m in love with the technology of it all, your threat model assumes an attacker that “appears” while your box is running. Judging from the guides you write, I assume you do not leave your box running while you are going to the supermarket and alike.

Hence I have some critical feedback: I think the risk is very high that the attacker will force you to enter that vault password, hence I do not think “encryption within encryption” really solves a problem here. I think a similar protection to the guide would be achieved by having a keybind that sysrq shuts down the box, or (better) wipes the luks header and then sysrq shutsdown the box, or by something funny like removing your laptops battery and tying the laptops charger cable around your wrist while using it, so that when you “raise your hands” you disconnect the electricity and the box is off (and so on and so forth / RAM wipe per script / bla).

I’m not the greatest fan of encryption within encryption - I think making the encryption unusable (wipe luks header && implode) as well as hiding the encryption (plausible deniability encryption) make more sense.

What might make the approach better would be hiding the fact that there is encryption, as in like using a USB stick with cryptsetup open --type plain --cipher aes-xts-plain64 --key-size 512 --hash sha512, which does not store a luks header somewhere, where you have that excuse “yeah just wiped it bcs I had some old data on it and wanted to format it again so i’m an old h4x0r and I just like to run shred on things”.
If you use that to store VMs, that might be better than letting the attacker know that “there is a encrypted thingy here, but I’m not going to tell you the password” - I have to add here that if you script decrypting even that USB stick, that script would give away the existence of the encypted thingy.

Long story short, in my personal opinion:

  1. Attacker gains access while you are using the box - panic button: luks header wipe + shutdown
  2. Attacker gains access while you are not using the box - not at home: best if the disk would look like it had just been wiped with fresh ubuntu install stick lying next to it (suggesting you are about to do a fresh install) (my request for your next project)

PS: As the title currently is “locked_with_key Encrypted AppVMs and Templates. Encrypted pool. Secret vault in a LUKS-Pool. Paranoid security” I want to add that this pool is not secret (the presence of the decryption script gives it away). Also dom0 will log things (but your other guide fixes that).

Have you tried this guide? Install Qubes OS with boot partition and a detached LUKS header on USB - #6 by apparatus

1 Like

Ah cool - that, but runnable on an already installed system x) (no reason to move the partition, a bit of offset is ok). Not sure if thats even possible though, but I’m preeeeetty sure it is if I remember cryptsetup correctly.

The point I’m asking is bcs I’d love to have this optionally in kuhbs, but I cant tell endusers “yeah in the installer just open a shell”, but I can tell them “run: kuhbs/scripts/funky.sh" - however that has to work on an installed box.

Imho that is possible.. (to do that after finished normal installation). As far as I know the luks header doens’t care where it is, as long as you tell it the offset of the encrypted data.

The Qubes team should reward you for the coolest guides of 2026!

I use all your guides and enjoy Qubes OS :smiling_face_with_sunglasses:

2 Likes

+1, give him a forum badge for very high value content!

Also @linuxuser1 I think you should change your name from linuxuser1 to linuxprofessional1 :wink: You’re WAY past user :wink:

1 Like

Haha thanks :slightly_smiling_face:

great guide, this inspired me to build a GUI wrapper for exactly this
approach LUKS+LVM-thin encrypted pool, create/open/close/remove from a proper
UI instead of raw shell scripts. part of a small dom0 tool I’ve been
building:

(Encrypted Pool tab, if anyone’s curious)

2 Likes