···88|----------|----------|---------|-------------|
99| `DATABASE_URL` | yes | --- | Database connection string. SQLite (`sqlite://path/to/db?mode=rwc`) or Postgres (`postgres://user:pass@host/db`) |
1010| `DATABASE_BACKEND` | no | auto-detected | Force `sqlite` or `postgres`. Auto-detected from `DATABASE_URL` scheme if not set |
1111-| `PUBLIC_URL` | yes | --- | Public-facing URL for HappyView (used for OAuth callbacks, e.g. `https://happyview.example.com`) |
1111+| `PUBLIC_URL` | yes | --- | Public-facing URL for HappyView (used for OAuth callbacks, e.g. `https://happyview.example.com`). **For local development, use `http://127.0.0.1:3000` — not `localhost`** (see note below) |
1212| `SESSION_SECRET` | no | dev default | Secret key for signing session cookies (at least 64 characters). **Must be set in production** |
1313| `HOST` | no | `0.0.0.0` | Bind host |
1414| `PORT` | no | `3000` | Bind port |
···2626| `TOS_URI` | no | --- | URL to terms of service. Overridden by database setting if set via admin API |
2727| `POLICY_URI` | no | --- | URL to privacy policy. Overridden by database setting if set via admin API |
28282929+:::warning[Use 127.0.0.1, not localhost]
3030+ATProto OAuth loopback clients are registered with `127.0.0.1`. If you set `PUBLIC_URL` to `http://localhost:3000`, OAuth sign-in will fail because the redirect URI won't match the loopback client ID. Always use `http://127.0.0.1:3000` for local development.
3131+:::
3232+2933## Example `.env`
30343135```sh
3236# SQLite (default — zero setup required)
3337DATABASE_URL=sqlite://data/happyview.db?mode=rwc
3434-PUBLIC_URL=http://localhost:3000
3838+PUBLIC_URL=http://127.0.0.1:3000
3539SESSION_SECRET=change-me-in-production
36403741# Or use Postgres instead:
···1414cp .env.example .env
1515```
16161717-Edit `.env` and set at least `PUBLIC_URL` (e.g. `http://localhost:3000`) and `SESSION_SECRET` (at least 64 characters). The defaults work for everything else. See [Configuration](../configuration.md) for the full list of environment variables.
1717+Edit `.env` and set at least `PUBLIC_URL` (e.g. `http://127.0.0.1:3000`) and `SESSION_SECRET` (at least 64 characters). The defaults work for everything else. See [Configuration](../configuration.md) for the full list of environment variables.
1818+1919+:::warning[Use 127.0.0.1, not localhost]
2020+ATProto OAuth loopback clients are registered with `127.0.0.1`. If you set `PUBLIC_URL` to `http://localhost:3000`, OAuth sign-in will fail because the redirect URI won't match the loopback client ID. Always use `http://127.0.0.1:3000` for local development.
2121+:::
18221923## 2. Start the stack
2024···31353236HappyView runs migrations automatically on startup. The first build will take a few minutes while Rust compiles.
33373434-The `happyview` container serves its own bundled dashboard at `http://localhost:3000`, but that copy is baked in at container build time and only updates when you rebuild the image. For day-to-day development, use the dev dashboard at `http://localhost:3001` — it hot-reloads on changes to the `web/` source.
3838+The `happyview` container serves its own bundled dashboard at `http://127.0.0.1:3000`, but that copy is baked in at container build time and only updates when you rebuild the image. For day-to-day development, use the dev dashboard at `http://127.0.0.1:3001` — it hot-reloads on changes to the `web/` source.
35393640:::tip
3741SQLite is the default and requires no extra services. To use Postgres instead, uncomment the `postgres` service in `docker-compose.yml` and update `DATABASE_URL` in `.env`. See the [database setup guide](../../guides/database/database-setup.md).
···2020```sh
2121# SQLite (default — no setup needed, file created automatically)
2222DATABASE_URL=sqlite://data/happyview.db?mode=rwc
2323-PUBLIC_URL=http://localhost:3000
2323+PUBLIC_URL=http://127.0.0.1:3000
2424SESSION_SECRET=change-me-in-production
2525```
2626+2727+:::warning[Use 127.0.0.1, not localhost]
2828+ATProto OAuth loopback clients are registered with `127.0.0.1`. If you set `PUBLIC_URL` to `http://localhost:3000`, OAuth sign-in will fail because the redirect URI won't match the loopback client ID. Always use `http://127.0.0.1:3000` for local development.
2929+:::
26302731Or if you prefer Postgres:
2832
+1-1
packages/docs/docs/guides/admin/api-keys.md
···2727Pass the key as a Bearer token in the `Authorization` header:
28282929```sh
3030-curl http://localhost:3000/admin/lexicons \
3030+curl http://127.0.0.1:3000/admin/lexicons \
3131 -H "Authorization: Bearer hv_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"
3232```
3333
+4-4
packages/docs/docs/guides/admin/event-logs.md
···106106107107```sh
108108# Get all errors
109109-curl "http://localhost:3000/admin/events?severity=error" -H "$AUTH"
109109+curl "http://127.0.0.1:3000/admin/events?severity=error" -H "$AUTH"
110110111111# Get script errors for a specific lexicon
112112-curl "http://localhost:3000/admin/events?event_type=script.error&subject=com.example.feed.like" -H "$AUTH"
112112+curl "http://127.0.0.1:3000/admin/events?event_type=script.error&subject=com.example.feed.like" -H "$AUTH"
113113114114# Get all lexicon-related events
115115-curl "http://localhost:3000/admin/events?category=lexicon" -H "$AUTH"
115115+curl "http://127.0.0.1:3000/admin/events?category=lexicon" -H "$AUTH"
116116117117# Paginate through results
118118-curl "http://localhost:3000/admin/events?limit=20&cursor=2026-03-01T11:59:00Z" -H "$AUTH"
118118+curl "http://127.0.0.1:3000/admin/events?limit=20&cursor=2026-03-01T11:59:00Z" -H "$AUTH"
119119```
120120121121See the [Admin API reference](../../reference/admin/events.md#list-event-logs) for full parameter documentation.
+1-1
packages/docs/docs/guides/features/api-clients.md
···5050### From the API
51515252```sh
5353-curl -X POST http://localhost:3000/admin/api-clients \
5353+curl -X POST http://127.0.0.1:3000/admin/api-clients \
5454 -H "Authorization: Bearer $TOKEN" \
5555 -H "Content-Type: application/json" \
5656 -d '{
+3-3
packages/docs/docs/guides/features/labelers.md
···2222You can also add a labeler via the API:
23232424```sh
2525-curl -X POST http://localhost:3000/admin/labelers \
2525+curl -X POST http://127.0.0.1:3000/admin/labelers \
2626 -H "$AUTH" \
2727 -H "Content-Type: application/json" \
2828 -d '{ "did": "did:plc:ar7c4by46qjdydhdevvrndac" }'
···3333You can pause a labeler subscription to temporarily stop consuming labels without losing your cursor position. Click the pause icon next to the labeler in the table, or use the API:
34343535```sh
3636-curl -X PATCH http://localhost:3000/admin/labelers/did:plc:ar7c4by46qjdydhdevvrndac \
3636+curl -X PATCH http://127.0.0.1:3000/admin/labelers/did:plc:ar7c4by46qjdydhdevvrndac \
3737 -H "$AUTH" \
3838 -H "Content-Type: application/json" \
3939 -d '{ "status": "paused" }'
···5151Or via the API:
52525353```sh
5454-curl -X DELETE http://localhost:3000/admin/labelers/did:plc:ar7c4by46qjdydhdevvrndac \
5454+curl -X DELETE http://127.0.0.1:3000/admin/labelers/did:plc:ar7c4by46qjdydhdevvrndac \
5555 -H "$AUTH"
5656```
5757
···1616Requires `plugins:read`. Returns every loaded plugin with its source, required secrets, configuration status, and any pending updates from the official registry cache.
17171818```sh
1919-curl http://localhost:3000/admin/plugins -H "$AUTH"
1919+curl http://127.0.0.1:3000/admin/plugins -H "$AUTH"
2020```
21212222**Response**: `200 OK`
···6262Requires `plugins:create`. Fetches and parses a manifest without installing the plugin, so the dashboard can show what it would register.
63636464```sh
6565-curl -X POST http://localhost:3000/admin/plugins/preview \
6565+curl -X POST http://127.0.0.1:3000/admin/plugins/preview \
6666 -H "$AUTH" \
6767 -H "Content-Type: application/json" \
6868 -d '{ "url": "https://example.com/plugins/steam/manifest.json" }'
···9797Requires `plugins:create`. Fetches the manifest, downloads the WASM, registers the plugin, and persists it.
98989999```sh
100100-curl -X POST http://localhost:3000/admin/plugins \
100100+curl -X POST http://127.0.0.1:3000/admin/plugins \
101101 -H "$AUTH" \
102102 -H "Content-Type: application/json" \
103103 -d '{
···205205Requires `plugins:create`. Encrypts the provided secret values with `TOKEN_ENCRYPTION_KEY` (AES-256-GCM) and upserts them into `plugin_configs`.
206206207207```sh
208208-curl -X PUT http://localhost:3000/admin/plugins/steam/secrets \
208208+curl -X PUT http://127.0.0.1:3000/admin/plugins/steam/secrets \
209209 -H "$AUTH" \
210210 -H "Content-Type: application/json" \
211211 -d '{
···1616Requires `users:create` permission. You cannot grant permissions you don't have yourself (escalation guard).
17171818```sh
1919-curl -X POST http://localhost:3000/admin/users \
1919+curl -X POST http://127.0.0.1:3000/admin/users \
2020 -H "$AUTH" \
2121 -H "Content-Type: application/json" \
2222 -d '{
···5353Requires `users:read` permission.
54545555```sh
5656-curl http://localhost:3000/admin/users -H "$AUTH"
5656+curl http://127.0.0.1:3000/admin/users -H "$AUTH"
5757```
58585959**Response**: `200 OK`
···8080Requires `users:read` permission.
81818282```sh
8383-curl http://localhost:3000/admin/users/550e8400-e29b-41d4-a716-446655440000 -H "$AUTH"
8383+curl http://127.0.0.1:3000/admin/users/550e8400-e29b-41d4-a716-446655440000 -H "$AUTH"
8484```
85858686**Response**: `200 OK` with the same shape as a single item from the list response.
···9494Requires `users:update` permission. You cannot grant permissions you don't have yourself, and you cannot modify the super user's permissions.
95959696```sh
9797-curl -X PATCH http://localhost:3000/admin/users/550e8400-e29b-41d4-a716-446655440000/permissions \
9797+curl -X PATCH http://127.0.0.1:3000/admin/users/550e8400-e29b-41d4-a716-446655440000/permissions \
9898 -H "$AUTH" \
9999 -H "Content-Type: application/json" \
100100 -d '{
···119119Only the current super user can call this endpoint. Transfers super user status to another existing user.
120120121121```sh
122122-curl -X POST http://localhost:3000/admin/users/transfer-super \
122122+curl -X POST http://127.0.0.1:3000/admin/users/transfer-super \
123123 -H "$AUTH" \
124124 -H "Content-Type: application/json" \
125125 -d '{ "target_user_id": "550e8400-e29b-41d4-a716-446655440000" }'
···140140Requires `users:delete` permission. You cannot delete the super user or yourself.
141141142142```sh
143143-curl -X DELETE http://localhost:3000/admin/users/550e8400-e29b-41d4-a716-446655440000 \
143143+curl -X DELETE http://127.0.0.1:3000/admin/users/550e8400-e29b-41d4-a716-446655440000 \
144144 -H "$AUTH"
145145```
146146
+7-7
packages/docs/docs/reference/xrpc-api.md
···2222```
23232424```sh
2525-curl http://localhost:3000/health
2525+curl http://127.0.0.1:3000/health
2626```
27272828**Response**: `200 OK` with body `ok`
···3636Returns the authenticated user's profile, resolved from their PDS via PLC directory lookup.
37373838```sh
3939-curl http://localhost:3000/xrpc/app.bsky.actor.getProfile \
3939+curl http://127.0.0.1:3000/xrpc/app.bsky.actor.getProfile \
4040 -H "X-Client-Key: $CLIENT_KEY" \
4141 -H "Authorization: Bearer $TOKEN"
4242```
···6262Proxies a blob upload to the authenticated user's PDS. Maximum size: 50MB.
63636464```sh
6565-curl -X POST http://localhost:3000/xrpc/com.atproto.repo.uploadBlob \
6565+curl -X POST http://127.0.0.1:3000/xrpc/com.atproto.repo.uploadBlob \
6666 -H "X-Client-Key: $CLIENT_KEY" \
6767 -H "Authorization: Bearer $TOKEN" \
6868 -H "Content-Type: image/png" \
···8282```
83838484```sh
8585-curl "http://localhost:3000/xrpc/xyz.statusphere.listStatuses?uri=at%3A%2F%2Fdid%3Aplc%3Aabc%2Fxyz.statusphere.status%2Fabc123" \
8585+curl "http://127.0.0.1:3000/xrpc/xyz.statusphere.listStatuses?uri=at%3A%2F%2Fdid%3Aplc%3Aabc%2Fxyz.statusphere.status%2Fabc123" \
8686 -H "X-Client-Key: $CLIENT_KEY"
8787```
8888···114114| `did` | string | --- | Filter records by DID |
115115116116```sh
117117-curl "http://localhost:3000/xrpc/xyz.statusphere.listStatuses?limit=10&did=did:plc:abc" \
117117+curl "http://127.0.0.1:3000/xrpc/xyz.statusphere.listStatuses?limit=10&did=did:plc:abc" \
118118 -H "X-Client-Key: $CLIENT_KEY"
119119```
120120···148148When the body does **not** contain a `uri` field, a new record is created.
149149150150```sh
151151-curl -X POST http://localhost:3000/xrpc/xyz.statusphere.setStatus \
151151+curl -X POST http://127.0.0.1:3000/xrpc/xyz.statusphere.setStatus \
152152 -H "X-Client-Key: $CLIENT_KEY" \
153153 -H "Authorization: Bearer $TOKEN" \
154154 -H "Content-Type: application/json" \
···162162When the body **contains** a `uri` field, the existing record is updated.
163163164164```sh
165165-curl -X POST http://localhost:3000/xrpc/xyz.statusphere.setStatus \
165165+curl -X POST http://127.0.0.1:3000/xrpc/xyz.statusphere.setStatus \
166166 -H "X-Client-Key: $CLIENT_KEY" \
167167 -H "Authorization: Bearer $TOKEN" \
168168 -H "Content-Type: application/json" \
+5-5
packages/docs/docs/tutorials/statusphere.md
···3535You can also add lexicons via the [admin API](../reference/admin/lexicons.md). This is useful for automation or CI/CD workflows:
36363737```sh
3838-curl -X POST http://localhost:3000/admin/lexicons \
3838+curl -X POST http://127.0.0.1:3000/admin/lexicons \
3939 -H "Authorization: Bearer $TOKEN" \
4040 -H "Content-Type: application/json" \
4141 -d '{
···8484This creates a `GET /xrpc/xyz.statusphere.listStatuses` endpoint. Without a Lua script, it uses HappyView's built-in default behavior: listing records with `limit`, `cursor`, and `did` parameters, or fetching a single record by `uri`. Try it:
85858686```sh
8787-curl "http://localhost:3000/xrpc/xyz.statusphere.listStatuses?limit=5" \
8787+curl "http://127.0.0.1:3000/xrpc/xyz.statusphere.listStatuses?limit=5" \
8888 -H "X-Client-Key: $CLIENT_KEY"
8989```
9090···140140The endpoint now uses your custom logic. Filter by a specific user:
141141142142```sh
143143-curl "http://localhost:3000/xrpc/xyz.statusphere.listStatuses?did=did:plc:abc&limit=1" \
143143+curl "http://127.0.0.1:3000/xrpc/xyz.statusphere.listStatuses?did=did:plc:abc&limit=1" \
144144 -H "X-Client-Key: $CLIENT_KEY"
145145```
146146147147Fetch a single record by URI:
148148149149```sh
150150-curl "http://localhost:3000/xrpc/xyz.statusphere.listStatuses?uri=at://did:plc:abc/xyz.statusphere.status/3abc123" \
150150+curl "http://127.0.0.1:3000/xrpc/xyz.statusphere.listStatuses?uri=at://did:plc:abc/xyz.statusphere.status/3abc123" \
151151 -H "X-Client-Key: $CLIENT_KEY"
152152```
153153···181181Set a status. This requires DPoP authentication — the [JavaScript SDK](../sdk/overview.md) handles this for you, but you can test with curl if you have a token:
182182183183```sh
184184-curl -X POST http://localhost:3000/xrpc/xyz.statusphere.setStatus \
184184+curl -X POST http://127.0.0.1:3000/xrpc/xyz.statusphere.setStatus \
185185 -H "X-Client-Key: $CLIENT_KEY" \
186186 -H "Authorization: DPoP $TOKEN" \
187187 -H "DPoP: $DPOP_PROOF" \