Skip to content

Known gaps

  1. Set INSTALL_TOKEN before the first deploy. Since the wizard fails closed in production, an unset value means /install returns 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.

  2. On App Platform, EXPORT_STORAGE cannot be local. 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.tf therefore creates a private Spaces bucket and a bucket-scoped access key, and main.tf wires them into EXPORT_STORAGE_BUCKET / _ENDPOINT / _REGION / _KEY / _SECRET.

    All you supply is export_bucket_region — a Spaces datacenter slug like tor1, which is not the App Platform slug (tor) and cannot be derived from it. The bucket is private, 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 = false and fill in the export_storage_* variables.

  3. No canned ACL works on every provider, so EXPORT_STORAGE_ACL exists. 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 except bucket-owner-full-control with a 400 AccessControlListNotSupported, 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.

  4. Set MERCURE_JWT_SECRET if 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’s enable_mercure) and routes /.well-known/mercure on the app’s own domain to it; the module injects MERCURE_URL, MERCURE_PUBLIC_URL and MERCURE_JWT_SECRET itself. 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/mercure image 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 with Last-Event-ID gets the events of the gap. Submissions are also recorded in the outbox_events outbox. 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.

  5. Nothing here has been applied against a live account. terraform validate passes and plan evaluates the full configuration up to the first API call, but no deploy has run. Specifically unobserved:

    • the Mercure component, reasoned from the dunglas/mercure image’s documented interface and the dev compose service;
    • the Spaces bucket and key, whose readwrite grant 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.
  6. Terraform state holds production secrets in cleartext, on whichever machine last ran apply. terraform/versions.tf ships with its backend "s3" block commented out, so state is local. terraform/terraform.tfstate and .tfstate.backup then 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 in terraform/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/.gitignore and 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-state and delete the local copies — as soon as any of these is true: a second person runs apply, CI runs it, or the machine stops being a single trusted one.