Volume encryption
A persistent volume holds a stateful workload's real data — a Postgres cluster, a Redis dump, a user's files. That data lives in a backing file on the host disk. Volume encryption makes each volume its own LUKS2 container with its own key, so the backing file is ciphertext at rest and a tenant's data can be crypto-shredded by destroying that one key.
This is the standard encryption-at-rest model (the same one AWS EBS gives you): it protects a stolen, seized, RMA'd, or decommissioned disk, and it gives you a key you control and can revoke. See What it protects for the honest boundary — it is not protection against a compromised host.
Enable it
Encryption is opt-in: the daemon needs a master key. Point it at a key file (generated on first use) or supply the key in the environment:
crucible daemon … --volume-dir /var/lib/crucible/volumes \
--volume-encrypt-key-file /var/lib/crucible/volume.key
# or, key from a KMS / systemd credential (takes precedence over the file):
CRUCIBLE_VOLUME_KEY="$(cat key.b64)" crucible daemon … --volume-dir …Add --volume-encrypt to make every new volume encrypted by default; without
it, encryption is available but a volume is only encrypted when you ask
(--encrypt). With no key configured, encryption is off and --encrypt /
volume shred are rejected — there is no silent fallback.
⚠️ Back up the master key. It is the only thing that can open your encrypted volumes — losing it means losing all of their data. The key lives outside the volume store (a key file,
CRUCIBLE_VOLUME_KEY, or a KMS), so a stolen disk alone is inert. Keep it somewhere safe and separate, and treat it like the database it unlocks.
The volume key is independent of the secrets master key — point both flags at the same file if you want one key, or keep them separate.
Create an encrypted volume
# Explicitly encrypted, regardless of the daemon default:
crucible volume create pgdata --encrypt --size 10G
# Explicitly plaintext, even when --volume-encrypt is the daemon default:
crucible volume create cache --no-encrypt
# `volume ls` shows which volumes are encrypted:
crucible volume ls
# NAME SIZE ENCRYPTED ATTACHED HOST AGE
# pgdata 10G yes - host1 2m
# cache 2G no - host1 1mAttach it to an app or sandbox exactly like any volume — the encryption is transparent to the guest:
crucible app create db --image postgres:16-alpine \
--volume pgdata:/var/lib/postgresql/data \
--env POSTGRES_PASSWORD=… --env PGDATA=/var/lib/postgresql/data/pgdataHow it works
Each encrypted volume is a LUKS2 container (aes-xts-plain64, a 512-bit key —
AES-256-XTS, the disk-encryption standard) over its backing file. A fresh random
per-volume key unlocks it; that key is sealed under the daemon master key
(AES-256-GCM, bound to the volume name) and stored in the volume record — never
in the clear on disk. The record's wrapped key is the only copy.
When the volume attaches, the daemon opens the LUKS container to a decrypted
device (/dev/mapper/crucible-vol-<name>) and — under the jailer — stages that
device node into the VM's chroot (the same mechanism the jailer uses for
/dev/kvm), never the ciphertext file. The guest sees an ordinary block device
and mounts plaintext; the bytes on disk are ciphertext. Encryption and decryption
happen in the kernel's device-mapper layer, so it is transparent — including
to the snapshot/wake memory pager, which pays no per-page cost.
Because the guest key is a high-entropy random value, the LUKS keyslot uses a fast KDF: opening a volume (on attach, and on every scale-to-zero wake) stays sub-second.
Encrypted at rest, including while asleep
A scale-to-zero app snapshots and stops its VM when idle. For an encrypted volume, the daemon closes the decrypted device on sleep (the single-writer claim is kept) and re-opens it on wake — so a slept database's data is ciphertext at rest for the entire sleep, not left decrypted on the host.
Backups
A backup of an encrypted volume is the ciphertext container, and
the backup record carries the volume's wrapped key — so it is inert without the
master key, and a restore re-wraps that key under the new volume's name and
brings the data back:
crucible app sleep db # quiesce (or detach) first
crucible volume backup pgdata
crucible volume restore --from <backup-id> --to pgdata-copy # also encryptedvolume clone of an encrypted volume is not yet supported (an independent
clone needs a fresh key — a full re-encrypt); restore a backup instead.
Crypto-shred
Deleting an encrypted volume's key makes its data permanently unrecoverable without touching the ciphertext blocks — instant, and provable:
crucible volume shred pgdata # destroys the keyslots + deletes the wrapped keyshred is refused while the volume is attached to a live sandbox, and on a
plaintext volume (use volume rm). It is gated by the default-deny delete
scoped-token op. For a managed control plane, a per-tenant key means deleting a
tenant crypto-shreds all their volumes at once.
What it protects (and what it doesn't)
| Threat | Protected? |
|---|---|
| Stolen / seized / RMA'd / decommissioned disk | ✅ ciphertext at rest |
| Crypto-shred a volume (or a whole tenant's key) | ✅ data unrecoverable, immediately |
| One guest reading another's volume | ✅ single-writer + separate keys + isolation |
| A compromised host root | ❌ it holds the live device-mapper (the AWS-EBS model) |
| The guest itself | ❌ it legitimately mounts plaintext |
Closing the host-root gap needs confidential computing (encrypted guest RAM), which Firecracker does not support and which is fundamentally incompatible with the lazy-paging that powers sub-second scale-to-zero wake. So the honest claim is "encryption at rest with per-tenant keys and crypto-shred," never "we can't read your data."
Slept apps and the memory snapshot
Volume encryption covers a workload's data on disk. There is one more surface to understand for a slept scale-to-zero app, and it's worth being precise about because it's where crucible differs from a stateless serverless-database design.
When crucible sleeps an app it snapshots the whole VM, including its RAM, to a
memory file under --work-base, so it can wake warm in ~170 ms — buffers hot,
no reconnect, no cold query. That snapshot can contain cached rows (a database's
shared_buffers), so the memory file is at-rest data too. A stateless design
instead discards RAM on suspend and reconnects to storage on wake — no memory
file, but a cold wake that re-warms its buffers on the first queries.
crucible trades that one extra surface for a much faster wake.
So there are two honest ways to keep a slept database encrypted at rest, and you choose per the tradeoff you want:
- Warm (default): keep snapshot-wake, and put
--work-baseon an encrypted filesystem (LUKS / dm-crypt). Because device-mapper encryption is transparent — the kernel decrypts pages as the snapshot pager faults them in — this adds no per-page wake cost, and every byte the daemon writes (the memory file, the writable rootfs, the volume containers) is ciphertext at rest. This is the recommended setup. - Cold: a stop/start sleep that never writes RAM to disk — zero memory-at-rest surface, at the cost of a cold wake.
ℹ️ The daemon does not encrypt the memory file itself: doing so would force the wake path to decrypt every faulted page, taxing exactly the sub-second wake that scale-to-zero depends on. An encrypted
--work-basegets the same result for free — the kernel does the crypto below the pager.
Startup advisory
When volume encryption is enabled but the daemon can positively see that
--work-base sits on unencrypted storage, it logs a warning at startup:
volume encryption is on but --work-base is on unencrypted storage: a slept app's
memory snapshot (cached rows, buffers) is written to disk in the clear. Put
--work-base on a dm-crypt/LUKS filesystem for full encryption at rest.It stays silent when --work-base is on an encrypted or ephemeral (tmpfs)
filesystem, or when the backing storage can't be classified — so a warning means
there is really something to fix.