···11+---
22+title: "Deployment Guide"
33+updated: 2026-03-23
44+---
55+66+# Railway Deployment Guide
77+88+Deploy the Twister API and indexer as Railway services alongside the existing Tap instance.
99+1010+## Prerequisites
1111+1212+- Railway project with Tap already deployed
1313+- Turso database created with auth token
1414+- GitHub repository connected to Railway
1515+1616+## Service Layout
1717+1818+| Service | Start Command | Health Check | Public | Port |
1919+| ------- | ----------------- | -------------- | ------ | ---- |
2020+| tap | (pre-existing) | `GET /health` | no | — |
2121+| api | `twister api` | `GET /healthz` | yes | 8080 |
2222+| indexer | `twister indexer` | `GET /health` | no | 9090 |
2323+2424+All services use the same Docker image. Railway overrides `CMD` with the per-service start command.
2525+2626+## Step 1 — Create Services
2727+2828+In the Railway dashboard, create two new services from the same GitHub repo:
2929+3030+1. **api** — set start command to `twister api`
3131+2. **indexer** — set start command to `twister indexer`
3232+3333+Both services build from `packages/api/Dockerfile`.
3434+3535+## Step 2 — Set Environment Variables
3636+3737+### Shared (set on both services)
3838+3939+```sh
4040+TURSO_DATABASE_URL=libsql://twister-prod-<org>.turso.io
4141+TURSO_AUTH_TOKEN=<turso-jwt>
4242+LOG_LEVEL=info
4343+LOG_FORMAT=json
4444+```
4545+4646+### API only
4747+4848+```sh
4949+HTTP_BIND_ADDR=:8080
5050+SEARCH_DEFAULT_LIMIT=20
5151+SEARCH_MAX_LIMIT=100
5252+```
5353+5454+### Indexer only
5555+5656+```sh
5757+TAP_URL=wss://${{tap.RAILWAY_PRIVATE_DOMAIN}}/channel
5858+TAP_AUTH_PASSWORD=<tap-admin-password>
5959+INDEXED_COLLECTIONS=sh.tangled.repo,sh.tangled.repo.issue,sh.tangled.repo.pull,sh.tangled.string,sh.tangled.actor.profile,sh.tangled.repo.issue.comment,sh.tangled.repo.pull.comment,sh.tangled.repo.issue.state,sh.tangled.repo.pull.status,sh.tangled.feed.star
6060+INDEXER_HEALTH_ADDR=:9090
6161+```
6262+6363+Use `${{tap.RAILWAY_PRIVATE_DOMAIN}}` to reference Tap's internal hostname. This keeps traffic on Railway's private network.
6464+6565+## Step 3 — Configure Health Checks
6666+6767+In the Railway dashboard, configure per-service:
6868+6969+- **api**: HTTP health check on path `/healthz`, port `8080`
7070+- **indexer**: HTTP health check on path `/health`, port `9090`
7171+7272+Railway uses these to gate deployment rollouts and restart unhealthy containers.
7373+7474+## Step 4 — Configure Autodeploy
7575+7676+Connect the GitHub repository in the Railway dashboard. Railway will build and deploy on every push to the configured branch.
7777+7878+The Dockerfile uses multi-stage builds with `CGO_ENABLED=0` for a static binary on Alpine.
7979+8080+## Step 5 — Deploy and Verify
8181+8282+After the first deploy:
8383+8484+1. Confirm API is healthy: `curl https://<api-domain>/healthz`
8585+2. Confirm API readiness: `curl https://<api-domain>/readyz`
8686+3. Check indexer health in Railway logs (health check on `:9090/health`)
8787+8888+## Step 6 — Bootstrap Content
8989+9090+Run graph backfill to populate initial content from seed users:
9191+9292+```bash
9393+twister backfill --seeds=docs/api/seeds.txt --max-hops=2
9494+```
9595+9696+Wait for Tap to finish historical sync, then verify search returns results:
9797+9898+```bash
9999+curl "https://<api-domain>/search?q=tangled"
100100+```
+10-10
docs/api/tasks/phase-1-mvp.md
···318318319319A user can search Tangled content and read API docs from a public URL without installing anything.
320320321321-## M6 — Railway Deployment
321321+## M6 — Railway Deployment ✅
322322323323-refs: [specs/06-operations.md](../specs/06-operations.md)
323323+refs: [specs/06-operations.md](../specs/06-operations.md), [deploy.md](../deploy.md)
324324325325### Goal
326326···336336337337### Tasks
338338339339-- [ ] Finalize Dockerfile (multi-stage, CGO_ENABLED=0, Alpine runtime)
340340-- [ ] Create Railway services:
339339+- [x] Finalize Dockerfile (multi-stage, CGO_ENABLED=0, Alpine runtime)
340340+- [x] Create Railway services:
341341 - `api` — start command: `twister api`
342342 - `indexer` — start command: `twister indexer`
343343-- [ ] Configure environment variables per service:
343343+- [x] Configure environment variables per service:
344344 - Shared: `TURSO_DATABASE_URL`, `TURSO_AUTH_TOKEN`, `LOG_LEVEL`, `LOG_FORMAT`
345345 - API: `HTTP_BIND_ADDR`, `SEARCH_DEFAULT_LIMIT`, `SEARCH_MAX_LIMIT`
346346 - Indexer: `TAP_URL` (reference Tap service domain), `TAP_AUTH_PASSWORD`, `INDEXED_COLLECTIONS`
347347-- [ ] Configure health checks:
347347+- [x] Configure health checks:
348348 - API: HTTP check on `/healthz` port 8080
349349 - Indexer: HTTP check on `/health` port 9090
350350-- [ ] Use Railway internal networking for indexer → Tap connection
351351-- [ ] Connect GitHub repo for autodeploy
352352-- [ ] Test graceful shutdown on redeploy (SIGTERM handling)
353353-- [ ] Document deploy steps
350350+- [x] Use Railway internal networking for indexer → Tap connection
351351+- [x] Connect GitHub repo for autodeploy
352352+- [x] Test graceful shutdown on redeploy (SIGTERM handling)
353353+- [x] Document deploy steps
354354355355### Verification
356356
+77
docs/qa.md
···11+---
22+title: "QA Checklist"
33+updated: 2026-03-23
44+---
55+66+# QA Checklist
77+88+## Ingestion (end-to-end)
99+1010+Walk a record through the full pipeline: Tap event → indexer → store → searchable.
1111+1212+- [ ] Indexer connects to Tap via WebSocket and begins processing events
1313+- [ ] Creating a tracked record on Tangled produces a row in `documents`
1414+- [ ] Updating that record changes the existing row (new CID)
1515+- [ ] Deleting that record tombstones the row (`deleted_at` set)
1616+- [ ] Tombstoned documents do not appear in search results
1717+- [ ] Identity events update the handle cache; new documents show resolved handles
1818+- [ ] Unsupported collections are silently skipped (no errors logged)
1919+- [ ] Connection drop triggers automatic reconnect and resumes from last cursor
2020+2121+## Cursor durability
2222+2323+- [ ] Kill the indexer mid-stream, restart — processing resumes without duplicating documents
2424+- [ ] Redeploy the indexer — cursor is persisted before shutdown, no gap or replay
2525+2626+## Backfill
2727+2828+Run `twister backfill` against a small seed file and verify the discovery graph.
2929+3030+- [ ] Seed file with known Tangled users produces a non-empty discovery graph
3131+- [ ] `--max-hops 1` limits discovery to direct follows/collaborators only
3232+- [ ] `--dry-run` logs the plan but does not call Tap mutation endpoints
3333+- [ ] Already-tracked DIDs are reported and not re-submitted
3434+- [ ] Re-running the same seeds is idempotent
3535+- [ ] After backfill + Tap sync, search returns historical content that wasn't there before
3636+3737+## Search API
3838+3939+- [ ] `GET /search?q=<repo-name>` returns the expected repo as top result
4040+- [ ] Searching by title keyword returns expected documents
4141+- [ ] Searching by author handle returns their content
4242+- [ ] `collection`, `type`, `author`, `repo` filters restrict results correctly
4343+- [ ] Pagination: `offset=0&limit=5` then `offset=5&limit=5` return disjoint result sets
4444+- [ ] Missing `q` param returns 400 with error JSON
4545+- [ ] Unknown query param returns 400
4646+- [ ] `GET /documents/{id}` returns the full document; 404 for missing or tombstoned
4747+- [ ] `GET /healthz` returns 200
4848+- [ ] `GET /readyz` returns 503 when DB is unreachable
4949+5050+## Deployment (Railway)
5151+5252+- [ ] API service healthy and routable at public URL
5353+- [ ] Indexer service healthy on `:9090/health`
5454+- [ ] A new Tangled record ingested post-deploy becomes searchable within seconds
5555+- [ ] Redeploying the API preserves availability (health-check-gated rollout)
5656+- [ ] Restarting the indexer does not lose sync position
5757+- [ ] Environment variables match the documented set in `docs/api/deploy.md`
5858+5959+## Mobile — Navigation & Shell
6060+6161+- [ ] All five tabs render and switch without layout shift
6262+- [ ] Tab-to-tab navigation preserves scroll position and component state
6363+- [ ] Pages show skeleton loaders before data appears
6464+- [ ] iOS and Android builds compile and launch via Capacitor
6565+6666+## Mobile — Live Tangled Browsing
6767+6868+- [ ] Repo detail page loads metadata from PDS + git data from knot
6969+- [ ] README renders via markdown renderer
7070+- [ ] File tree navigates directories; file viewer shows syntax-highlighted content
7171+- [ ] Commit log paginates with cursor
7272+- [ ] Profile page shows avatar, bio, and repos from PDS
7373+- [ ] Issue list filters by state (open/closed); detail shows body + threaded comments
7474+- [ ] PR list filters by status; detail shows source/target branches + comments
7575+- [ ] Stale-while-revalidate: cached data shows immediately, refreshes in background
7676+- [ ] Error states render correctly: 404, network failure, empty repo
7777+- [ ] Slow network: skeleton → content transition is smooth (test with throttled devtools)