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 its 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. When several volumes share one key, destroying that key
crypto-shreds all of them at once.
Multiple keys & rotation
The daemon can hold a keyring of more than one key, so different volumes can be wrapped under different keys — and any key can be rotated without re-encrypting a single byte of data.
Keys are loaded from, in addition to the default key:
# additional keys off-disk (preferred):
CRUCIBLE_VOLUME_KEY_archive="$(cat archive.key.b64)" crucible daemon …
# or as files under a directory (id = filename without .key):
crucible daemon … --volume-key-dir /etc/crucible/volume-keys # 2026q3.key → id "2026q3"--volume-default-key <id> chooses which key wraps new volumes (default
default); volume create --encrypt uses it, and a create can name another key.
volume ls shows each volume's KEY:
NAME SIZE ENCRYPTED KEY ATTACHED HOST AGE
pgdata 10G yes default - host1 5m
appdata 5G yes 2026q3 - host1 1mRotate a key — no data is re-encrypted
Rotation re-wraps a volume's per-volume key under a different keyring key. The
key that actually encrypts the data (the LUKS volume key) never changes, so there
is no cryptsetup run, no data movement, and no downtime — it is safe on a
live, attached volume:
crucible volume rewrap pgdata --to-key 2026q3 # one volume
crucible volume rewrap --all --from-key default --to-key 2026q3 # every volume on a key(Re-encrypting the underlying data — needed only if a data key is compromised —
is a separate, expensive cryptsetup reencrypt procedure, not part of rewrap.)
Add or retire a key without a restart
Drop a new key into the env/dir, then reload the keyring in place:
crucible volume keys reloadTo retire an old key: rewrap --all --from-key <old> --to-key <new>, remove the
old key's file/env, then keys reload. Reload refuses to drop a key that any
volume still uses (and a volume whose key is missing fails to open with a clear
error), so you can't strand data. Rewrap and reload are gated by the default-deny
volume_key scoped-token op.
Audit
Every key operation — volume_key_created, volume_key_rotated (with the from/to
key ids), and volume_shredded — is emitted as a structured log record (and over
OTLP when configured). The records carry volume names and key ids only, never
key material.
What it protects (and what it doesn't)
| Threat | Protected? |
|---|---|
| Stolen / seized / RMA'd / decommissioned disk | ✅ ciphertext at rest |
| Crypto-shred a volume (or destroy a shared 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-volume 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.