Bouée
Operate

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 whatWhat helps
A disk pulled from the machine, a decommissioned SSD, a laptop in a taxiVolume encryption (LUKS, FileVault)
A backup file copied off the host, or sitting on a NASEncrypting the backup itself
A copy of garage-data alone — an rsync, a snapshot shipped offsiteObject-store encryption (SSE-C)
Root on the running machine, or the .env fileNone of this. The volumes are mounted and the keys are in memory
A leaked S3_ACCESS_KEY_ID / DATABASE_URLNone 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.

VolumeMounted atHolds
db-data/var/lib/postgresqlEvery table and index — all of the plaintext listed above, plus the WAL
garage-meta/var/lib/garage/metaGarage's SQLite metadata: bucket names, object keys, sizes, content types, the S3 access key
garage-data/var/lib/garage/dataAttachment 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/docker

Add 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 2

docker.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.raw

There 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: Yes

With 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.gpg

Piping 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 storage

Then, 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 .env next to the thing it protects.
  • Escrow the application's keys as well. A dump restored under a freshly generated .env comes 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 with SECRET_ENCRYPTION_KEY — or, while that is unset, with a key derived from BETTER_AUTH_SECRET — and attachments written under SSE-C open only with S3_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 set SECRET_ENCRYPTION_KEY explicitly 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, returns 200 OK, and does not echo the header back on the response or on a later HeadObject. The object is then readable with an ordinary GetObject and its bytes sit in garage-data as plaintext. Setting it would produce a deployment that looks encrypted in the application code and is not. aws:kms behaves 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. PutObject with x-amz-server-side-encryption-customer-key stores ciphertext: the corresponding block in garage-data is high-entropy, not zstd-decompressible, and contains none of the plaintext. GetObject succeeds 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: CopyObject onto itself with the old key as x-amz-copy-source-server-side-encryption-customer-key and 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. CopyObject with 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 with 400 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:3900 on 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 SSECustomerKeyMD5 itself. Its ssecMiddleware treats 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 GetObject carries 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 same 400 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:

RequestStatusCodeMessage, verbatim
A key against a plaintext object400InvalidRequestBad request: Trying to decrypt a plaintext object
No SSE-C headers, object encrypted400InvalidRequestBad request: Object is encrypted
A different 32-byte key400InvalidRequestBad request: Invalid encryption key, could not decrypt object metadata.: aead::Error
Right key, wrong key-MD5 †400InvalidRequestBad request: Server-side encryption client key MD5 checksum does not match
Right key, key-MD5 omitted †400InvalidRequestBad request: Missing server-side-encryption-customer-key-md5 header
A 16-byte key400InvalidRequestBad request: Invalid server-side-encryption-customer-key header: invalid length
A key for an object that does not exist404NoSuchKeyKey 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 askYou 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.

On this page