# Operating Intent How to deploy, configure and look after a production Intent. For what the app does, see [concept.md](concept.md); for how it's built, [plan.md](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: 1. restores the database from B2 if the volume has none (a fresh volume), 2. runs migrations and creates the cache table, 3. 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 `. 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://.fly.storage.tigris.dev`. | | `SENTRY_DSN` | no | Error reporting. | ## First deploy 1. Replace the `CHANGEME` values in `fly.toml` (app name, region, host). 2. `fly launch --no-deploy`, then `fly volumes create intent_data --size 1`. 3. `fly storage create` for the Tigris bucket. 4. Set the secrets above. 5. `fly deploy`. Check `fly logs` for "running without backups" (Litestream not configured) or a restore message. 6. `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. ```sh fly ssh console cd /app python manage.py createsuperuser python manage.py add_admin_totp ``` 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:///accounts/oidc//login/callback/`, then add a Social application with provider "OpenID Connect", the same `provider_id`, the client ID and secret, and under Settings `{"server_url": "https://"}`. No deploy is needed. ## Email Sign-in codes and email invitations need working email. Any provider [Anymail](https://anymail.dev) supports works without code changes. For example, Postmark: ```sh fly secrets set \ EMAIL_BACKEND=anymail.backends.postmark.EmailBackend \ ANYMAIL_POSTMARK_SERVER_TOKEN=... \ DEFAULT_FROM_EMAIL="Intent " ``` Then send a test and sign in with an emailed code: ```sh 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 `) are a second, independent copy. Run the restore drill before launch and after any change to `bin/start` or `litestream.yml`: 1. Note a recent change in the app, e.g. a comment you just posted. 2. Create a new volume and a new machine with it, from the same image: `fly volumes create intent_data --size 1 --region ` then `fly machine clone --attach-volume :/data`. 3. In `fly logs` for the new machine, confirm a Litestream restore happens before "Applying migrations" (or "No migrations to apply"). 4. `fly ssh console --machine ` 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)"`. 5. 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 ```sh 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.