- Rate limits in the shared cache for comments, issues, uploads and invites; a limited HTMX reply keeps the draft. - Content-Security-Policy (self only, no inline script or style, bucket origin for images and frames) and Permissions-Policy on every page. htmx configured not to inject styles or eval. - Moderation: deactivating a user ends their sessions and keeps their content; deleted accounts show as "ghost"; reserved usernames; the admin won't delete an org's last owner. - Admin requires a TOTP code (django-otp), with add_admin_totp to enrol. - Email provider chosen by environment through django-anymail. - Playwright browser tests: axe WCAG 2.1 AA on every page in light and dark, no CSP violations, keyboard walkthrough, theme toggle. Explicit Canvas/CanvasText on :root fixes dark-mode contrast detection. - bin/start creates the data dir if missing; docs/operations.md runbook. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6.4 KiB
Operating Intent
How to deploy, configure and look after a production Intent. For what the app does, see concept.md; for how it's built, plan.md.
Shape
One Fly.io machine with a volume at /data holding the SQLite database. Litestream streams
the database to a Backblaze B2 bucket continuously. Uploaded files go to a private Tigris
bucket. bin/start runs as root only long enough to take ownership of the volume, then as
the app user it:
- restores the database from B2 if the volume has none (a fresh volume),
- runs migrations and creates the cache table,
- starts gunicorn under
litestream replicate.
Secrets and settings
Set with fly secrets set NAME=value. Non-secret settings live in fly.toml.
| Name | Required | Purpose |
|---|---|---|
DJANGO_SECRET_KEY |
yes | Signs sessions and tokens. Long and random. |
DJANGO_ALLOWED_HOSTS |
yes (fly.toml) |
Comma-separated hostnames. Also sets the trusted CSRF origins. |
EMAIL_BACKEND |
yes | Production refuses to start with the console backend. See Email. |
ANYMAIL_* |
with Anymail | The provider's settings, e.g. ANYMAIL_POSTMARK_SERVER_TOKEN. |
DEFAULT_FROM_EMAIL |
yes | e.g. Intent <intent@example.org>. Must be a sender the provider allows. |
LITESTREAM_BUCKET, LITESTREAM_ENDPOINT, LITESTREAM_ACCESS_KEY_ID, LITESTREAM_SECRET_ACCESS_KEY |
yes | B2 backup bucket and an application key limited to it. |
BUCKET_NAME, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_ENDPOINT_URL_S3, AWS_REGION |
yes | Tigris uploads. fly storage create sets these. |
CSP_BUCKET_ORIGIN |
no | Only if signed URLs aren't https://<bucket>.fly.storage.tigris.dev. |
SENTRY_DSN |
no | Error reporting. |
First deploy
- Replace the
CHANGEMEvalues infly.toml(app name, region, host). fly launch --no-deploy, thenfly volumes create intent_data --size 1.fly storage createfor the Tigris bucket.- Set the secrets above.
fly deploy. Checkfly logsfor "running without backups" (Litestream not configured) or a restore message.fly ssh console, then create the first administrator (next section).
Administrators
Administrators are separate from regular users: they sign in only at /admin/, with a
password and a code from an authenticator app, and can't join organizations.
fly ssh console
cd /app
python manage.py createsuperuser
python manage.py add_admin_totp <username>
The second command prints an otpauth:// link. Add it to an authenticator app (most can scan
it if you paste it into any QR generator you trust, or enter the secret by hand). If someone
loses their device, run it again with --replace.
Only superusers can make other accounts staff or give out permissions.
In local development the admin uses a password only. To try the real flow, run with
ADMIN_REQUIRE_OTP=1.
Organizations and sign-in providers
- Organizations are created in the admin (Organizations → Add), naming the first owner. They invite everyone else.
- GitHub and Discord sign-in: in the admin, Social applications → Add, with the provider's client ID and secret.
- A Forgejo instance: register an OAuth2 application on the instance with the redirect URL
https://<host>/accounts/oidc/<provider_id>/login/callback/, then add a Social application with provider "OpenID Connect", the sameprovider_id, the client ID and secret, and under Settings{"server_url": "https://<forgejo-host>"}. No deploy is needed.
Sign-in codes and email invitations need working email. Any provider Anymail supports works without code changes. For example, Postmark:
fly secrets set \
EMAIL_BACKEND=anymail.backends.postmark.EmailBackend \
ANYMAIL_POSTMARK_SERVER_TOKEN=... \
DEFAULT_FROM_EMAIL="Intent <intent@example.org>"
Then send a test and sign in with an emailed code:
fly ssh console -C "python /app/manage.py sendtestemail you@example.org"
Moderation
- Delete a comment: the author, or an owner of the project, from the comment itself. It leaves a "Comment deleted" marker.
- Close an issue: any project collaborator or owner.
- Block a person: admin → Users → select → "Deactivate". They're signed out everywhere and can't sign in; what they wrote stays under their name. "Reactivate" undoes it.
- Delete an account: admin → the user → Delete. What they wrote stays, shown as written by "ghost". The admin won't delete an organization's last owner; make someone else an owner first.
Limits
Per person: 30 comments or new threads per 10 minutes, 20 new issues per hour, 30 file uploads
per hour, 30 invitations per hour (and 3 per address per organization). allauth limits
sign-in attempts and code requests itself. Limits live in the database cache and reset when
their window ends. Change them in intent/core/throttle.py.
Backups and the restore drill
Litestream keeps B2 within about a second of the database. Fly volume snapshots (daily by
default; check with fly volumes snapshots list <volume-id>) are a second, independent copy.
Run the restore drill before launch and after any change to bin/start or litestream.yml:
- Note a recent change in the app, e.g. a comment you just posted.
- Create a new volume and a new machine with it, from the same image:
fly volumes create intent_data --size 1 --region <region>thenfly machine clone <machine-id> --attach-volume <new-volume-id>:/data. - In
fly logsfor the new machine, confirm a Litestream restore happens before "Applying migrations" (or "No migrations to apply"). fly ssh console --machine <new-machine-id>and check the change from step 1 is there:python manage.py shell -c "from intent.comments.models import Comment; print(Comment.objects.latest('created_at').body)".- Destroy the clone and its volume. Two machines must never write to the same B2 path for long.
To recover for real, stop the machine, destroy its volume, create a new one, and start the
machine: bin/start restores from B2 on an empty volume.
Tests
uv run playwright install chromium # once per machine
uv run pytest # everything, including real-browser tests
uv run pytest -m "not browser" # fast run without the browser
The browser tests run axe (WCAG 2.1 A and AA) on every page in light and dark mode, check there are no CSP violations or script errors, and walk the main flow by keyboard.