Encryption at rest
What Bouée encrypts, and what to encrypt around it.
Bouée is self-hosted: Postgres and Garage keep their bytes on a disk the operator owns, and encryption at rest is something the operator configures around them, not a switch inside the app. This page describes what the application already encrypts, what it deliberately does not, and what to do to the volumes, the backups and the object store so that a disk leaving the building does not become a data breach.
Read the threat model first, because every control below answers a different question:
| Who has what | What helps |
|---|---|
| A disk pulled from the machine, a decommissioned SSD, a laptop in a taxi | Volume encryption (LUKS, FileVault) |
| A backup file copied off the host, or sitting on a NAS | Encrypting the backup itself |
A copy of garage-data alone — an rsync, a snapshot shipped offsite | Object-store encryption (SSE-C) |
Root on the running machine, or the .env file | None of this. The volumes are mounted and the keys are in memory |
A leaked S3_ACCESS_KEY_ID / DATABASE_URL | None of this — see Credentials |
The last two rows are the honest limit. Encryption at rest protects data at rest; a running Bouée has every one of these volumes open.
What the application encrypts, and what it does not
src/lib/crypto/secrets.ts seals a short list of stored credentials with AES-256-GCM under
SECRET_ENCRYPTION_KEY (falling back to a key derived from BETTER_AUTH_SECRET): the Resend OAuth
client secret and tokens, the inbound webhook signing secret, and each brand's company sign-in
client secret. Those are ciphertext in the database, so a dump, a stray backup or a read-only SQL
grant does not hand them over. .env.example describes the key and its rotation.
Nothing else in the database is encrypted by the application. In particular these are stored in plain text and are readable by anyone who can read the database:
- ticket subjects and message bodies, including anything a customer pasted into one;
- requester names and email addresses, and the per-ticket reply tokens;
- attachment filenames, sizes and content types;
- knowledge base drafts, agent accounts and the audit trail.
This is a decision, not an oversight. Column-level encryption of message bodies would end agent search over them — the product would lose its primary function to defend against an attacker who already holds the database, which the controls on this page address better. Treat the database contents as sensitive-but-plaintext and put the protection around the media.
The three volumes
compose.yaml declares three named volumes. With name: bouee at the top of the file, Docker calls
them bouee_db-data, bouee_garage-meta and bouee_garage-data, and on a Linux host they live
under /var/lib/docker/volumes/<name>/_data.
| Volume | Mounted at | Holds |
|---|---|---|
db-data | /var/lib/postgresql | Every table and index — all of the plaintext listed above, plus the WAL |
garage-meta | /var/lib/garage/meta | Garage's SQLite metadata: bucket names, object keys, sizes, content types, the S3 access key |
garage-data | /var/lib/garage/data | Attachment bytes, as zstd-compressed blocks |
Two details matter when you decide how far to go.
garage-data blocks are compressed, which is not encrypted. A .zst block from that directory
decompresses to the attachment, with no credential of any kind. If you take one thing from this
page: copying garage-data today is equivalent to copying the attachments.
garage-meta holds object keys in plain text, and objectKeyFor() in src/lib/storage.ts builds
those keys as tickets/<ticketId>/<messageId>/<random>-<filename> — the customer's original
filename is in the key. Object-store encryption (below) encrypts object bodies, not object names,
so passport-scan.pdf remains legible in garage-meta even under SSE-C. Only volume encryption
covers the names.
Volume encryption on Linux
Encrypt the block device that /var/lib/docker sits on, so all three volumes are covered at once by
one thing you can point an auditor at. Doing it per-volume is more moving parts for no more
protection.
On a fresh host, before the first docker compose up:
# 1. A dedicated device or partition for Docker's storage.
cryptsetup luksFormat --type luks2 /dev/sdb1
cryptsetup open /dev/sdb1 docker-data
mkfs.ext4 /dev/mapper/docker-data
# 2. Mount it where Docker keeps everything.
systemctl stop docker
mkdir -p /var/lib/docker
mount /dev/mapper/docker-data /var/lib/dockerAdd the mapping to /etc/crypttab and the filesystem to /etc/fstab so it comes back on boot:
# /etc/crypttab
docker-data UUID=<luks partition uuid> none luks
# /etc/fstab
/dev/mapper/docker-data /var/lib/docker ext4 defaults 0 2docker.service already orders itself after local-fs.target, so a volume mounted through
/etc/fstab is in place before the daemon starts. Verify after a reboot with
lsblk -o NAME,FSTYPE,MOUNTPOINT — /var/lib/docker must sit on a crypto_LUKS parent — and
cryptsetup status docker-data.
On an existing host the data has to move: systemctl stop docker, copy
/var/lib/docker aside with rsync -aHAX --numeric-ids, create and mount the encrypted filesystem,
copy it back, start Docker. Copying with anything that loses hard links or ownership will break the
image store. Take a backup first and confirm the stack comes up before deleting the old copy.
How it unlocks at boot, and why that is the whole question
A LUKS volume is only as good as the thing that opens it.
- Passphrase at the console is the strongest and the least convenient: the machine does not come back from a reboot without a human. Reasonable for one appliance, painful for anything else.
- TPM-bound (
systemd-cryptenroll --tpm2-device=auto, or Clevis) unlocks automatically on that machine and refuses on any other. This is the usual answer: it defeats the stolen-disk threat, which is the one volume encryption is for, at no operational cost. - A keyfile on the unencrypted root filesystem defeats a stolen disk from the array but not a stolen machine — whoever takes the box takes the key. If you do this, say so plainly in your ISMS rather than letting it read as full-disk encryption.
If the host is a cloud VM whose provider already encrypts the underlying volumes (EBS, Hetzner, DigitalOcean), that is a legitimate control and worth citing, but it is the provider's key. It protects against the provider's discarded hardware, not against the provider, and not against anyone who can attach the volume to another instance in your account.
macOS and OrbStack
OrbStack does not put containers on the macOS filesystem. Every Docker volume lives inside its Linux VM, whose disk is a single file:
~/Library/Group Containers/HUAQ24HBR6.dev.orbstack/data/data.img.rawThere is no LUKS to configure and nothing to change in compose.yaml. The control is FileVault,
which encrypts the APFS Data volume that file sits on:
fdesetup status # must print: FileVault is On.
sudo fdesetup enable # if it is not — record the recovery key somewhere durable
diskutil apfs list | grep -A1 'Volume disk.*Data' # FileVault: YesWith FileVault on, powering the Mac off makes data.img.raw — and therefore all three volumes —
unreadable without the login password or recovery key. With the Mac logged in, the file is plainly
readable by any process running as that user. That is the same trade as LUKS with auto-unlock, with
a much wider set of processes on the machine.
Be honest about what this arrangement is: a Mac laptop running the production stack under OrbStack is a development convenience, and FileVault makes it defensible against loss or theft. It is not a production hosting posture, and an auditor asking about access control, patching and physical security will get worse answers about a laptop than about a Linux host. Plan to run production on Linux and use this section for developer machines that hold real data.
Backups
An encrypted volume does nothing for a backup that leaves it. pg_dump reads through Postgres
and writes plaintext SQL wherever you point it; the moment that file lands on another disk, an
object store or a colleague's laptop, it is outside every control on this page. This is the single
most common way an otherwise-encrypted deployment leaks.
Encrypt the dump itself, in the same pipeline that produces it, so an unencrypted copy never exists:
# age — one recipient, the private key kept off this host
docker compose exec -T db pg_dump -U bouee -Fc bouee \
| age -r age1<recipient public key> > bouee-$(date +%F).dump.age
# or gpg, if that is what your key management already uses
docker compose exec -T db pg_dump -U bouee -Fc bouee \
| gpg --encrypt --recipient backups@example.com > bouee-$(date +%F).dump.gpgPiping straight from pg_dump into the encryptor matters: writing the dump to /tmp and encrypting
it afterwards leaves a plaintext copy, and shred on a modern SSD or a copy-on-write filesystem does
not reliably remove it.
The object store has no dump command — you back it up by copying garage-data and garage-meta,
or by aws s3 sync-ing the bucket. Either way the copy is plaintext (compressed blocks, or objects
fetched through the API) unless you encrypt the archive:
docker compose stop storage # quiesce for a consistent metadata copy
tar -C /var/lib/docker/volumes -cf - bouee_garage-data bouee_garage-meta \
| age -r age1<recipient public key> > garage-$(date +%F).tar.age
docker compose start storageThen, the parts people skip:
- Escrow the backup key. A backup you cannot decrypt is not a backup. The age or GPG private key
belongs somewhere that survives this host — a password manager, a sealed envelope, a second
machine — and not in
.envnext to the thing it protects. - Escrow the application's keys as well. A dump restored under a freshly generated
.envcomes up, and then cannot read what it restored: the Resend grant, the inbound webhook signing secret and every company sign-in client secret are sealed withSECRET_ENCRYPTION_KEY— or, while that is unset, with a key derived fromBETTER_AUTH_SECRET— and attachments written under SSE-C open only withS3_SSE_CUSTOMER_KEY. Inbound mail then fails signature checks with no visible cause until each credential is issued again. Keep those values with the backup key, and setSECRET_ENCRYPTION_KEYexplicitly so that rotating the session secret stops being able to take stored credentials with it. - Rehearse a restore, end to end, from the encrypted artifact, and write down the date. This is also the only honest evidence that your backups work, and an auditor will ask for it.
- Retention. Old encrypted dumps on a shared NAS are still customer data. Give them an expiry.
The object store
Garage's own S3 server-side encryption support was measured against dxflrs/garage:v2.4.1 — the
image this stack pins — using the @aws-sdk/client-s3 already in node_modules. The results decide
what is worth configuring:
- SSE-S3 (
x-amz-server-side-encryption: AES256) is not implemented, and fails open. Garage accepts the header, returns200 OK, and does not echo the header back on the response or on a laterHeadObject. The object is then readable with an ordinaryGetObjectand its bytes sit ingarage-dataas plaintext. Setting it would produce a deployment that looks encrypted in the application code and is not.aws:kmsbehaves the same way. Do not use these, and do not claim them in a control description. - SSE-C (client-supplied key) is fully implemented by Garage, and is real.
PutObjectwithx-amz-server-side-encryption-customer-keystores ciphertext: the corresponding block ingarage-datais high-entropy, not zstd-decompressible, and contains none of the plaintext.GetObjectsucceeds with the right key, byte for byte, and fails closed otherwise. Garage holds no copy of the key. Confirmed twice, on v2.4.1 and again on v2.3.0 — the second time with compression disabled and an incompressible object, because with compression on a control object written in the clear also failed a plaintext grep and the negative proved nothing. Compose pins v2.4.1.
So: can someone who copies the Garage data directory read an attachment? Today, yes — decompress
the block. With volume encryption and the machine off, no. With SSE-C and without the key, no, though
they still read every filename out of garage-meta.
If you adopt SSE-C
The code is in place (see What the code does); all it needs is an
S3_SSE_CUSTOMER_KEY of exactly 32 random bytes — openssl rand -base64 32. Then live with these,
all of which were confirmed against v2.4.1:
- Lose the key and you lose every attachment. There is no recovery, no escrow at Garage, no support ticket. It is a data-destroying secret and must be backed up like one — and separately from the object store it opens, or the backup shares the loss.
- An object is either encrypted or it is not, and the request must match. Reading a pre-existing
plaintext object with a key fails at the store with
400 "Trying to decrypt a plaintext object".getObject()catches exactly that and retries the read without the key, so turning the key on in a running installation does not break the attachments uploaded before it — but those stay in the clear until something rewrites them, and the installation is then honestly "encrypted from <date>", not "encrypted". A fresh install is the only moment this is free, which is why it is worth deciding before a reinstall. - Existing objects can be migrated server-side with
CopyObject(plaintext source, SSE-C destination) — no download through the app — but every object must be rewritten, and reads fail in between. - Rotation rewrites every object too:
CopyObjectonto itself with the old key asx-amz-copy-source-server-side-encryption-customer-keyand the new one as the destination key. Verified working, including that the old key stops working afterwards. Budget for it as a migration, not a config change, and expect attachment reads to fail for anything mid-flight. - You can back out.
CopyObjectwith a source key and no destination key writes the object back as plaintext. - Garage requires and validates
x-amz-server-side-encryption-customer-key-md5. Omitting it and sending a mismatched one both fail with400 InvalidRequest(an earlier note here said otherwise; it was wrong, and the measured table above is what stands). The AWS SDK derives the header from the key on every request, so this is a guard the application gets without asking for it. - The key travels in a request header on every put and get. Inside Compose that is
http://storage:3900on the private network, which is acceptable; do not expose Garage's S3 port beyond loopback, and use TLS if it ever crosses a host boundary.
What the code does
src/lib/storage.ts is the whole object-store surface: putObject(), getObject() and
getObjectBuffer(), with no presigned URLs, no multipart upload and no CopyObject anywhere in
src/. SSE-C is therefore one environment variable and one helper, and the key never leaves the
server process.
This is implemented. S3_SSE_CUSTOMER_KEY in src/lib/env.ts takes exactly 32 bytes written as
base64 or hex, and rejects anything else the first time the environment is read — which is early, but
not at process start: instrumentation.register() catches that throw so a bad value does not stop the
server, it fails every request that reads the environment. sseParams(objectKey) in
src/lib/storage.ts returns the encryption parameters for one object, or an empty object when no key
is configured, and is spread into the PutObjectCommand and into the first of the two
GetObjectCommands — the second is the deliberate unencrypted retry below. Unset the variable and
every request is byte-for-byte what it was before.
Three details worth knowing, each of which cost a bug in the first sketch of this:
- The AWS SDK derives
SSECustomerKeyMD5itself. ItsssecMiddlewaretreats a string that is valid base64 for 32 bytes as the key, base64-decodes it, and computes the MD5 over the raw bytes. Any other string it treats as UTF-8 text and base64-encodes, so a 64-character hex key handed over untouched would be sent as 64 key bytes and refused.sseParams()therefore normalizes whatever the environment holds to base64 of the raw 32 bytes, once, and lets the SDK do the rest. sseParams()takes the object, not a global. A read has to present the key its write used. One key serves the whole installation today, but when a brand brings its own, this is the only function that has to learn how to choose, and every call site already passes what it needs.- A read retries unencrypted when the store says the object itself is plaintext. An installation
that turns encryption on later keeps serving the attachments written before it: the first
GetObjectcarries the key, and only the message "Trying to decrypt a plaintext object" triggers the retry. A wrong key, a missing key, a 403, a 404 and a 500 are not retried, and nothing is retried when no key was presented, so a real failure stays a failure. New writes are still encrypted. Matching the message rather than the error code is not a shortcut — see the taxonomy below, where every one of those cases is the same400 InvalidRequest. - A key that is set but unreadable fails the request. Not "no encryption": an operator who set a key asked for one, and writing plaintext under a configuration that says otherwise is the one outcome worth ruling out explicitly.
ensureBucket() is untouched — HeadBucket and CreateBucket do not carry encryption headers.
src/lib/__tests__/storage-encryption.test.ts holds the wire-level assertions: what reaches the
store with and without a key, that hex and base64 spellings produce the same bytes, and which
failures do and do not fall back.
What a refused read looks like on the wire
Measured against a throwaway Garage v2.4.1 node — the version Compose pins — with SigV4 requests
written by hand, so these are literal response bodies rather than a reading of the documentation. The
same probe was then run against v2.3.0 and the two result sets are byte-identical, so this surface did
not change between those releases. Garage answers every SSE-C complaint with 400 and the S3
error code InvalidRequest. Only the free-text message differs:
| Request | Status | Code | Message, verbatim |
|---|---|---|---|
| A key against a plaintext object | 400 | InvalidRequest | Bad request: Trying to decrypt a plaintext object |
| No SSE-C headers, object encrypted | 400 | InvalidRequest | Bad request: Object is encrypted |
| A different 32-byte key | 400 | InvalidRequest | Bad request: Invalid encryption key, could not decrypt object metadata.: aead::Error |
| Right key, wrong key-MD5 † | 400 | InvalidRequest | Bad request: Server-side encryption client key MD5 checksum does not match |
| Right key, key-MD5 omitted † | 400 | InvalidRequest | Bad request: Missing server-side-encryption-customer-key-md5 header |
| A 16-byte key | 400 | InvalidRequest | Bad request: Invalid server-side-encryption-customer-key header: invalid length |
| A key for an object that does not exist | 404 | NoSuchKey | Key not found |
† Reachable only at raw HTTP. ssecMiddleware recomputes SSECustomerKeyMD5 from the key on every
request, overwriting whatever was passed, which was confirmed by logging the outgoing headers of a
request that deliberately set a wrong one: it went out correct and succeeded. The application cannot
produce these two errors.
Three consequences. The key-MD5 is required — omit it and the read fails — and Garage does check
that it matches, so a corrupted key is caught rather than silently used. The SDK derives it, so
nothing in src/ has to. getObject()'s fallback matches the message, not the code: the code alone
cannot tell the one recoverable case from a wrong key. And a HEAD returns the status with an empty
body, so on HEAD these cannot be told apart at all — anything that starts issuing HeadObject
against encrypted attachments has to carry the key rather than probe for it.
The one thing SSE-C does not hide
Garage compresses before it encrypts. In a first run of the experiment above, with Garage's default compression on, a 4,167-byte highly compressible object became an 86-byte encrypted block, while an incompressible object of the same size became a block 56 bytes larger than its plaintext (the AEAD overhead). Stored ciphertext length therefore leaks how compressible the plaintext was.
For attachments this is a weak signal and not worth acting on — but say so plainly rather than claim
length-hiding, and note that docker/garage.toml leaves compression at its default. Setting
compression_level = "none" there removes the leak at the cost of disk. garage-meta still holds
every object key in plain text regardless, which is the larger disclosure and the reason volume
encryption stays the primary control.
Recommendation
Volume encryption is the control to put in place, and it is enough for the threat that motivates this work — a disk or a backup leaving the building. Do that first and unconditionally.
SSE-C is genuine defence in depth for one specific case: a copy of the object store alone, taken
while the machine is running, by someone who does not also have .env. On a single host where the
key sits in .env beside the database password, that is a narrow gap. The reason to decide now is
timing, not threat: enabling it at install costs nothing, and enabling it later costs a rewrite of
every attachment.
If you use Cloudflare R2 or Amazon S3 instead
Both encrypt all objects at rest by default with provider-managed keys — S3 applies SSE-S3 (AES-256)
to every new object, R2 encrypts everything it stores — and unlike Garage they mean it. Point
S3_ENDPOINT, S3_BUCKET, S3_REGION (auto for R2) and the credentials at the bucket and the
garage-data / garage-meta volumes disappear from your scope along with everything above about
them. db-data does not: the database still needs an encrypted volume, and the backups still need
encrypting.
What that default does and does not buy: it covers the provider's discarded hardware and satisfies
the "objects are encrypted at rest" line in a questionnaire. It does not hide anything from the
provider, who holds the key, and it does nothing at all against a leaked S3_ACCESS_KEY_ID — those
requests are authorized and decrypt transparently. For a key the provider does not hold, S3 offers
SSE-KMS with a customer-managed key, which is the better version of what SSE-C does here and keeps
audit logging of key use. R2 also accepts SSE-C, with the same key-loss consequence as Garage. Note
that these provider defaults are documented behaviour, not something measured here; the Garage
findings above are measured.
Credentials are a separate problem
None of this protects .env. It holds POSTGRES_PASSWORD, S3_SECRET_ACCESS_KEY,
BETTER_AUTH_SECRET, SECRET_ENCRYPTION_KEY, PORTAL_LINK_SECRET and — if you adopt it —
S3_SSE_CUSTOMER_KEY, in plain text, readable by the user that runs Compose. Keep it chmod 600,
keep it out of git, and remember that anyone who can read it can read everything the volumes hold,
whatever those volumes are encrypted with.
What an auditor will ask for
Claim only what you can show. These are producible:
| They ask | You produce |
|---|---|
| Is data at rest encrypted? | cryptsetup status docker-data / lsblk -o NAME,FSTYPE,MOUNTPOINT, or fdesetup status, showing the volume holding all three Docker volumes |
| What algorithm, what key length? | cryptsetup luksDump <device> — cipher, key size, PBKDF; AES-256-GCM for the application-level secrets, from src/lib/crypto/secrets.ts |
| How does it unlock, and who can? | Your honest answer from How it unlocks at boot: console passphrase, TPM binding, or a keyfile — plus who holds the recovery key |
| Are backups encrypted? | The backup script showing pg_dump piped into age/gpg, a listing of the stored artifacts, and where the private key is escrowed |
| Can you restore? | The date and outcome of your last restore rehearsal from an encrypted artifact |
| Is customer content encrypted in the database? | No — say so, and say why: field-level encryption of subjects and bodies would remove agent search. Point to the volume, backup and access controls as the compensating controls |
| Are attachments encrypted? | Volume encryption, plus SSE-C if you adopted it. If you did not, do not imply otherwise |
| Key rotation? | For SECRET_ENCRYPTION_KEY, the multi-key rotation in .env.example. For LUKS, cryptsetup luksChangeKey. For SSE-C, the object-rewrite procedure above — and its cost |
Two things not to claim, because the measurements above contradict them: that Garage performs
server-side encryption by default, and that setting ServerSideEncryption: "AES256" encrypts
anything on Garage. Both are false on v2.4.1, and the second is false silently.