Known gaps
-
Set
INSTALL_TOKENbefore the first deploy. Since the wizard fails closed in production, an unset value means/installreturns 404 and the browser has no route to the first administrator. Recoverable from a shell — see Recovering an instance — but the wizard is the pleasant path. -
On App Platform,
EXPORT_STORAGEcannot belocal. The web and worker containers have separate ephemeral filesystems, so the application default would have the worker write an archive the web container cannot see, and every download would 404.terraform/spaces.tftherefore creates a private Spaces bucket and a bucket-scoped access key, andmain.tfwires them intoEXPORT_STORAGE_BUCKET/_ENDPOINT/_REGION/_KEY/_SECRET.All you supply is
export_bucket_region— a Spaces datacenter slug liketor1, which is not the App Platform slug (tor) and cannot be derived from it. The bucket isprivate, is never destroyed while it holds objects, and has a 30-day lifecycle rule as a backstop against archives that outlive their database row. Download links expire after 48 hours and the app deletes the archive then, so that rule can never reach a live one.Bringing your own bucket instead: set
create_export_bucket = falseand fill in theexport_storage_*variables. -
No canned ACL works on every provider, so
EXPORT_STORAGE_ACLexists. The Flysystem S3 adapter always sends a canned ACL and offers no way to send none. Buckets created since 2023 on AWS default to “Bucket owner enforced”, which rejects everything exceptbucket-owner-full-controlwith a 400AccessControlListNotSupported, while MinIO and DigitalOcean Spaces accept only the app’s default,private. Get this wrong and every export upload fails inside the worker, where nobody is watching. -
Set
MERCURE_JWT_SECRETif you want live updates. The hub carries them for the board, the inbox count, worker runs and site review. On App Platform, setting it runs a Mercure hub as a second service (module v1.6.0’senable_mercure) and routes/.well-known/mercureon the app’s own domain to it; the module injectsMERCURE_URL,MERCURE_PUBLIC_URLandMERCURE_JWT_SECRETitself. Leaving it empty keeps push off — review submissions still save, but never reach the bridge CLI, and the publish failure is only logged, so it degrades silently rather than erroring.The
dunglas/mercureimage sets no transport, so the hub keeps its history in a Bolt file,/data/caddy/mercure.db, with no size limit. Neither compose file mounts/data, so the history lasts until the container is recreated, and the file grows until then. While the hub runs, a subscriber that reconnects withLast-Event-IDgets the events of the gap. Submissions are also recorded in theoutbox_eventsoutbox. When the hub is down, the scheduled drain publishes them later. The outbox does not restore a history that a recreated container lost. A browser page has no replay. When it loses a message, a board card stays stale until the page reconnects to the hub or reloads. -
Nothing here has been applied against a live account.
terraform validatepasses andplanevaluates the full configuration up to the first API call, but no deploy has run. Specifically unobserved:- the Mercure component, reasoned from the
dunglas/mercureimage’s documented interface and the dev compose service; - the Spaces bucket and key, whose
readwritegrant is taken from DigitalOcean’s documentation rather than from a completed upload-download-delete cycle; - the S3 export path as a whole — it has never touched a real bucket;
- the single-host Compose stack, validated as configuration but never started;
- the production image itself, whose base is pinned by digest in
docker/prod/Dockerfile; no image built from that pin has been deployed.
- the Mercure component, reasoned from the
-
Terraform state holds production secrets in cleartext, on whichever machine last ran
apply.terraform/versions.tfships with itsbackend "s3"block commented out, so state is local.terraform/terraform.tfstateand.tfstate.backupthen contain everything the app spec marks"type":"SECRET", plus the managed-database URI and the Spaces keys — plaintext whatever the spec calls them. Provider credentials are not in state but sit beside it interraform/terraform.tfvars: same machine, same blast radius, different file.This is deliberate for a single operator on a single machine, where a remote backend would add a bucket, a lock table and another credential against a risk already bounded by that machine’s own security. It is not a repository exposure — all of it is covered by
terraform/.gitignoreand none has ever been committed. The exposure is anything that copies a home directory: system backups, cloud sync, a support bundle, a stolen laptop.Switch to the encrypted remote backend the file already documents — then
terraform init -migrate-stateand delete the local copies — as soon as any of these is true: a second person runsapply, CI runs it, or the machine stops being a single trusted one.