Per-PVC Encryption with Longhorn and CSI Secret Templates
This article documents how to configure a Longhorn StorageClass that encrypts every PVC with its own per-volume key, derived through CSI’s secret-template parameters, and how to provision the matching secrets so the keys are scoped to the application namespace.
1. What encryption Longhorn actually does
Longhorn 1.4+ supports LUKS encryption at the block device layer. When a PVC’s StorageClass declares encrypted: "true", Longhorn calls cryptsetup luksFormat on the underlying replica devices using a passphrase pulled from a Kubernetes Secret. The PVC is then exposed to the consuming Pod as an unencrypted filesystem — the kernel handles the encryption transparently through the device-mapper layer.
The data on disk, in Longhorn replica state, and in any Longhorn-driven backup is ciphertext. The kernel’s dm-crypt is the only piece that ever holds the plaintext-deriving passphrase.
The natural shape is one passphrase per cluster — simple, but a key compromise leaks every PVC. A more interesting shape is one passphrase per PVC. The CSI standard supports it through template parameters in the StorageClass, and Longhorn honours them.
2. The encrypted StorageClass
| |
Field reference:
provisioner: driver.longhorn.io— the CSI driver name registered by Longhorn.numberOfReplicas: "3"— three replicas across nodes. Encrypted volumes carry the same replication semantics as plain ones; each replica is encrypted independently with the same passphrase.staleReplicaTimeout: "2880"— minutes (48 hours) before a disconnected replica is considered stale and garbage-collected. Larger values protect against transient node outages at the cost of slower rebuild on permanent loss.encrypted: "true"— the toggle. Without this, the secret references are ignored.fsType: "xfs"— XFS performs better than ext4 on the trim-aware dm-crypt path withmountOptions: discard. The combination causes the kernel to TRIM the encrypted layer when the application unlinks files, returning space to the underlying Longhorn replica.csi.storage.k8s.io/*-secret-name: ${pvc.name}-longhorn— the CSI template.${pvc.name}and${pvc.namespace}are expanded at PVC provisioning time, so each PVC reads a uniquely-named Secret in its own namespace. The three role-specific keys (provisioner,node-publish,node-stage) all point to the same Secret here; CSI permits independent secrets per phase, but for LUKS there is one passphrase shared across them.mountOptions: - discard— propagates TRIM through dm-crypt to the underlying Longhorn replica. Critical for thin-provisioned storage backing the replicas.
3. The per-PVC Secret
For a PVC named gitea-data in namespace gitea, the Secret is gitea-data-longhorn in gitea:
| |
Field reference:
CRYPTO_KEY_VALUE— the passphrase. Longhorn passes this tocryptsetup luksFormat --key-file -. Generate withopenssl rand -base64 32or a Vault-issued one-time value.CRYPTO_KEY_PROVIDER: "secret"— Longhorn reads the value from this Secret directly. The alternativekmsprovider is reserved for future KMS integrations and not implemented.CRYPTO_KEY_CIPHER: "aes-xts-plain64"— the LUKS cipher mode. XTS is the modern default for full-disk encryption; CBC is legacy and worse on every axis.CRYPTO_KEY_HASH: "sha256"— passphrase hashing algorithm. Used during PBKDF, not for the data itself.CRYPTO_KEY_SIZE: "256"— key size in bits. XTS uses two keys, so 256 means a 512-bit XTS keyset under the hood.CRYPTO_PBKDF: "argon2id"— the password-based key derivation function.argon2idis the modern default;pbkdf2is also accepted but slower to brute-force evaluation per unit of legitimate work, hence weaker.
The Secret must exist before the PVC is created. The provisioner will not create one; it will fail with failed to get secret gitea-data-longhorn in the Longhorn manager logs.
4. Bootstrapping the Secret
A plaintext passphrase in Git is obviously not acceptable. The shape of the answer is to materialise the Secret at apply-time rather than commit-time — SOPS, External Secrets, or a Composition step that emits the Secret alongside the PVC all work. Which one fits depends on the rest of the stack; pick whichever already handles the other namespace-scoped Secrets in the cluster.
5. Provisioning a PVC against the StorageClass
| |
Longhorn provisions three replicas, runs luksFormat on each using the passphrase from gitea-data-longhorn, and mounts the resulting dm-crypt device into the consuming Pod. The Pod sees an XFS filesystem on a 10 GiB volume — the encryption is invisible above the kernel block layer.
Volume expansion works through the encryption: allowVolumeExpansion: true on the StorageClass plus kubectl patch pvc to a larger size resizes the underlying Longhorn replicas, then cryptsetup resize on the dm-crypt mapping, then XFS online grow. Longhorn orchestrates all three transparently.
6. Verifying the result
Volume is encrypted:
| |
The presence of a replica directory whose volume.meta records EncryptionEnabled: true confirms encryption is on at the Longhorn layer.
dm-crypt device is in place on the consuming node:
| |
type: LUKS2, cipher: aes-xts-plain64, and a device pointing at a Longhorn /dev/longhorn/... path confirm the encryption layer is active and on top of the replica.
7. Practical notes
- Secret rotation does not re-encrypt the volume. LUKS binds the data-encryption key on volume create; rotating the passphrase post-hoc requires
cryptsetup luksChangeKeyagainst every replica, which Longhorn does not automate. Treat the passphrase as immutable per-volume. mountOptions: discardis not the default. Without it, deleted file space remains allocated at the dm-crypt and Longhorn layers. Storage utilisation balloons until the PVC is detached and reattached.- Per-PVC keys vs. cluster key. Per-PVC isolates blast radius but multiplies the number of secrets to track. For homelabs or air-gapped clusters with a strong workstation-key story, a cluster-wide passphrase is acceptable. For multi-tenant production, per-PVC is the right default.