Object store — pointing Obscura at your own S3
Obscura ships with a MinIO container and uses it by default. Every document byte the system
holds lives there: uploads, every version, rendered official copies, marked download copies and
the blobs a backup mirrors. Nothing else stores document content.
A deployment can use its own object store instead — AWS S3, an existing MinIO or Ceph cluster,
a NAS gateway, anything with an S3-compatible API. This is an env-file change, not a code
change: five settings, no rebuild.
Read Moving an existing archive before repointing a deployment
that already holds documents. Blobs do not move themselves, and the failure mode looks
exactly like a healthy system.
The five settings
| Setting | Default | Notes |
|---|---|---|
S3_ENDPOINT |
minio:9000 |
Host:port or URL. The bundled MinIO on the container network. |
S3_BUCKET |
obscura |
Must exist already — Obscura does not create it. |
S3_ACCESS_KEY |
obscura |
|
S3_SECRET_KEY |
MINIO_ROOT_PASSWORD, else a dev default |
Prefer S3_SECRET_KEY_FILE where you have a secret manager. |
S3_USE_SSL |
false |
Must be true for any endpoint outside your own network — see below. |
Put them in the env file the deployment already uses (deploy/prod.env, deploy/.env, or
whatever OBSCURA_ENV_FILE names). Do not edit deploy/docker-compose.yml: the updater
performs a git checkout, so an edit there is silently reverted on the next update.
# deploy/prod.env
S3_ENDPOINT=s3.ap-southeast-3.amazonaws.com
S3_BUCKET=arsip-perusahaan
S3_ACCESS_KEY=AKIA...
S3_SECRET_KEY=...
S3_USE_SSL=true
S3_SECRET_KEY falls back to MINIO_ROOT_PASSWORD when unset, so deployments that only ever
set the MinIO password keep working untouched.
Both services take these
obscura and obscura-backup read the same five keys from the same env file, so setting them
once moves both. 🔴 If you ever split them, the backup sidecar mirrors a store the application
does not use, and nothing reports the drift until someone attempts a restore. obscura doctor
compares the two and fails if they disagree.
TLS is not optional off-network
S3_USE_SSL=false is the default because the bundled MinIO is reached over the container
network, where plaintext is fine. Against a remote endpoint the same default sends every
document, in full, in clear text — and at-rest encryption does not help, because blobs are
decrypted before they are served.
Preflight refuses to start a deployment whose endpoint is outside its own network with SSL off:
SECURITY S3_USE_SSL
endpoint s3.ap-southeast-3.amazonaws.com is outside this deployment's
network and SSL is off — set S3_USE_SSL=true
"Inside the network" means loopback, an RFC1918/link-local address, a container or service name
with no dots, or a .local / .internal / .svc suffix. Anything else is treated as remote.
At-rest encryption is unchanged
Blobs are age-encrypted before they are written, so the store never sees plaintext and
moving to a different store re-keys nothing. BLOB_ENCRYPTION_KEY_FILE remains exactly as
critical as before: lose it and the objects are unreadable wherever they live. Do not rotate it
as part of a store move — that is a separate, riskier operation
(scripts/rotate-blob-key.sh).
Moving an existing archive
Objects are content-addressed. A document version stores a content_hash, and the object
key is that hash (<hash>, or <tenant>/<hash> on Obscura Cloud). There is no path, no
filename and no per-document folder.
🔴 This is why repointing at an empty bucket is dangerous. Nothing errors at boot. The
application starts, the archive lists in full, folders and metadata are intact, search works —
and every single download returns 404, because each row resolves to a key that is not there.
It looks like a healthy system that has lost its documents, and it will look that way to your
users before it looks that way to you.
The order below is the one Obscura Cloud already uses for tenant conversion
(obscura-convert, see MIGRATION_TO_CLOUD.md): copy, verify,
repoint, and only then prune. Deleting from the old store is a separate, deliberate step that
happens after everything is proven — never part of the move.
1. Create the destination bucket
Obscura does not create buckets. Create it, and give the credentials read/write on it. No
public access — the application signs every read; the bucket itself never needs to be readable
by anyone else.
2. Copy, do not move
mc alias set old http://minio:9000 "$OLD_KEY" "$OLD_SECRET"
mc alias set new https://s3.example.com "$NEW_KEY" "$NEW_SECRET"
mc mirror --preserve old/obscura new/arsip-perusahaan
Run it as many times as you like — mirror is incremental, so a first pass over a large
archive can run for hours while the system stays live, and a second pass afterwards picks up
whatever was written in the meantime.
3. Verify before you switch
Compare counts, and confirm the second mirror pass has nothing left to do:
mc ls --recursive old/obscura | wc -l
mc ls --recursive new/arsip-perusahaan | wc -l
mc mirror --dry-run old/obscura new/arsip-perusahaan # expect no output
The destination may legitimately hold more objects than the database has distinct
content_hash values — sealed and marked copies are stored alongside originals. Fewer is the
alarm.
4. Stop, repoint, start
./deploy/obscura stop # or: docker compose stop obscura obscura-backup
# edit the env file: S3_ENDPOINT / S3_BUCKET / S3_ACCESS_KEY / S3_SECRET_KEY / S3_USE_SSL
./deploy/obscura update --yes
Writing to the old store while mirroring means the last writes are missed, which is why the
application is stopped for the final pass rather than repointed underneath itself.
5. Check before you trust it
./deploy/obscura doctor
Object store
✓ app uses s3.example.com bucket arsip-perusahaan (ssl=true)
✓ obscura-backup agrees
✓ bucket holds 1050 object(s) for 474 expected blob(s)
A red line here is the empty-bucket case, caught before your users find it:
✗ the archive expects 474 blob(s) but bucket 'arsip-perusahaan' at s3.example.com
is EMPTY or unreachable
Then download a handful of real documents — a recent upload, an old one, one with several
versions, and one that is sealed or signed. The doctor counts objects; only a download proves
the bytes are the right ones.
6. Prune the old store — later, and separately
Keep the old bucket until you are satisfied: a week of normal use, and at least one successful
backup and restore drill against the new store. Only then delete it. The old bucket is the
cheapest rollback that exists, and object storage is cheap next to an archive.
Air-gapped notes
mcis baked into theobscura-backupimage; there is no download at run time.- Nothing about a store move needs internet access if the destination is on your own network.
- A remote endpoint means TLS, which means the container must trust its CA. Mount your CA
bundle if it is not a public one.
Troubleshooting
| Symptom | Cause |
|---|---|
| Everything lists, every download 404s | Repointed at a bucket that does not hold the objects. Go back to step 2 — the data is still in the old store. |
Preflight refuses to start on S3_USE_SSL |
A remote endpoint with TLS off. Set S3_USE_SSL=true. |
| Doctor: "obscura and obscura-backup point at DIFFERENT object stores" | The env file was applied to only one. They share the same keys — set them once. |
| Backups succeed but a restore finds nothing | The backup sidecar was still pointed at the old store. Same fix. |
SignatureDoesNotMatch |
Wrong secret, or a region-specific endpoint host. Use the regional endpoint, not the global one. |