a love letter to tangled (android, iOS, and a search API)
19
fork

Configure Feed

Select the types of activity you want to include in your feed.

build: add deployment walkthrough documentation and update config for Railway integration

+203 -4
+1
docs/README.md
··· 6 6 7 7 - [`reference/api.md`](reference/api.md) — Go search API service 8 8 - [`reference/app.md`](reference/app.md) — Ionic Vue mobile app 9 + - [`reference/deployment-walkthrough.md`](reference/deployment-walkthrough.md) — Railway deployment guide 9 10 - [`reference/lexicons.md`](reference/lexicons.md) — Tangled AT Protocol record types 10 11 - [`reference/resync.md`](reference/resync.md) — Backfill and repo-resync recovery playbook 11 12
+150
docs/reference/deployment-walkthrough.md
··· 1 + # Deployment Walkthrough 2 + 3 + This repo maps cleanly to Railway, but only for the backend pieces. 4 + 5 + - Deploy `packages/api` to Railway as two services: `api` and `indexer`. 6 + - Keep the Ionic + Capacitor app on your machine or in CI for native builds. 7 + - Point the mobile app at the Railway `api` service with 8 + `VITE_TWISTER_API_BASE_URL`. 9 + 10 + ## What Railway Should Host 11 + 12 + Railway is a good home for the Go services in this repo: 13 + 14 + - `api`: serves HTTP routes, docs, search, proxies, and readiness checks 15 + - `indexer`: consumes Tap, writes into Turso, and exposes its own health endpoint 16 + Railway is not the place that ships the native iOS or Android app. You still 17 + build, sign, and distribute the Capacitor shells separately. 18 + 19 + ## Prerequisites 20 + 21 + Before you start, have these ready: 22 + 23 + - a Railway account and the Railway CLI 24 + - a Turso database URL and auth token 25 + - a Tap URL and Tap auth password 26 + - a seed list for the first backfill run 27 + From this machine: 28 + 29 + ```sh 30 + cd /Users/owais/Projects/Twisted 31 + railway login 32 + ``` 33 + 34 + ## Create The Railway Project 35 + 36 + In the Railway dashboard, create one empty project with two empty services: 37 + 38 + - `api` 39 + - `indexer` 40 + Then link this repo to that project: 41 + 42 + ```sh 43 + cd /Users/owais/Projects/Twisted 44 + railway link 45 + ``` 46 + 47 + ## Configure Service Shape 48 + 49 + Both services should deploy from the same local path: 50 + 51 + - path: `packages/api` 52 + - build source: `packages/api/Dockerfile` 53 + Set the service start commands in Railway: 54 + - `api`: `twister api` 55 + - `indexer`: `twister indexer` 56 + The checked-in Dockerfile already builds the `twister` binary. 57 + 58 + ## Set Variables 59 + 60 + Use shared variables for values both services need: 61 + 62 + - `TURSO_DATABASE_URL` 63 + - `TURSO_AUTH_TOKEN` 64 + - `LOG_LEVEL=info` 65 + - `LOG_FORMAT=json` 66 + Set these on `api`: 67 + - `HTTP_BIND_ADDR=0.0.0.0:${{ PORT }}` 68 + - `SEARCH_DEFAULT_LIMIT=20` 69 + - `SEARCH_MAX_LIMIT=100` 70 + - `READ_THROUGH_MODE=missing` 71 + - `READ_THROUGH_COLLECTIONS=sh.tangled.*` 72 + - `READ_THROUGH_MAX_ATTEMPTS=5` 73 + - `ENABLE_ADMIN_ENDPOINTS=false` 74 + - `ADMIN_AUTH_TOKEN=<set this if admin routes are enabled>` 75 + Set these on `indexer`: 76 + - `INDEXER_HEALTH_ADDR=0.0.0.0:${{ PORT }}` 77 + - `TAP_URL=<your Tap URL>` 78 + - `TAP_AUTH_PASSWORD=<your Tap password>` 79 + - `INDEXED_COLLECTIONS=sh.tangled.*` 80 + - `ENABLE_INGEST_ENRICHMENT=true` 81 + Optional OAuth variables for a Railway-hosted web client metadata endpoint: 82 + - `OAUTH_CLIENT_ID` 83 + - `OAUTH_REDIRECT_URIS` 84 + The `${{ PORT }}` reference matters. Railway health checks run against the 85 + service port it injects, so the process must listen on that port. 86 + 87 + ## Deploy From This Machine 88 + 89 + From the repo root, deploy `packages/api` into each Railway service: 90 + 91 + ```sh 92 + cd /Users/owais/Projects/Twisted 93 + railway up packages/api --path-as-root --service api 94 + railway up packages/api --path-as-root --service indexer 95 + ``` 96 + 97 + `--path-as-root` is important in this monorepo. It makes `packages/api` the 98 + deployment root instead of archiving the whole repo. 99 + 100 + ## Configure Health Checks 101 + 102 + Set the health check path in Railway for each service: 103 + 104 + - `api`: `/readyz` 105 + - `indexer`: `/health` 106 + `/readyz` is the better API check because it verifies database reachability. 107 + 108 + ## First Bootstrap 109 + 110 + A fresh environment is not search-ready just because the services booted. 111 + 112 + 1. Deploy `api`. 113 + 2. Deploy `indexer`. 114 + 3. Confirm the `api` domain returns `200` from `/readyz`. 115 + 4. Confirm the `indexer` returns `200` from `/health`. 116 + 5. Run the initial backfill against the same Turso and Tap environment. 117 + One simple way to run backfill from this machine is to use the same env values 118 + locally and execute: 119 + 120 + ```sh 121 + cd /Users/owais/Projects/Twisted/packages/api 122 + go run ./main.go backfill --seeds /path/to/seeds.txt 123 + ``` 124 + 125 + Do not call the environment ready until that first backfill has completed. 126 + 127 + ## Point The App At Railway 128 + 129 + For local app builds, set the Railway API URL in `apps/twisted/.env`: 130 + 131 + ```sh 132 + VITE_TWISTER_API_BASE_URL=https://<your-api-domain> 133 + ``` 134 + 135 + Then build or run the app as usual: 136 + 137 + ```sh 138 + pnpm --dir apps/twisted dev 139 + pnpm --dir apps/twisted build 140 + pnpm --dir apps/twisted exec cap sync 141 + ``` 142 + 143 + ## Operating Model 144 + 145 + This is the practical split: 146 + 147 + - Railway hosts the always-on backend 148 + - Turso stores indexed data 149 + - this machine, or CI, builds the mobile app and points it at Railway 150 + If you later want a Railway-hosted web frontend, add that as a separate service.
+2 -2
packages/api/Dockerfile
··· 1 - FROM golang:1.24-alpine AS builder 1 + FROM golang:1.25-alpine AS builder 2 2 3 3 WORKDIR /app 4 4 ··· 21 21 22 22 COPY --from=builder /app/twister /usr/local/bin/twister 23 23 24 - EXPOSE 8080 9090 9091 24 + EXPOSE 8080 9090 25 25 26 26 CMD ["twister", "api"]
+12 -2
packages/api/internal/config/config.go
··· 63 63 ReadThroughCollections: envOrDefault("READ_THROUGH_COLLECTIONS", os.Getenv("INDEXED_COLLECTIONS")), 64 64 ReadThroughMaxAttempts: envInt("READ_THROUGH_MAX_ATTEMPTS", 5), 65 65 SearchDefaultMode: envOrDefault("SEARCH_DEFAULT_MODE", "keyword"), 66 - HTTPBindAddr: envOrDefault("HTTP_BIND_ADDR", ":8080"), 67 - IndexerHealthAddr: envOrDefault("INDEXER_HEALTH_ADDR", ":9090"), 66 + HTTPBindAddr: envBindAddr("HTTP_BIND_ADDR", "PORT", 8080), 67 + IndexerHealthAddr: envBindAddr("INDEXER_HEALTH_ADDR", "PORT", 9090), 68 68 LogLevel: envOrDefault("LOG_LEVEL", "info"), 69 69 LogFormat: envOrDefault("LOG_FORMAT", "json"), 70 70 AdminAuthToken: os.Getenv("ADMIN_AUTH_TOKEN"), ··· 153 153 return v 154 154 } 155 155 return def 156 + } 157 + 158 + func envBindAddr(key, portKey string, defaultPort int) string { 159 + if v := strings.TrimSpace(os.Getenv(key)); v != "" { 160 + return v 161 + } 162 + if port := strings.TrimSpace(os.Getenv(portKey)); port != "" { 163 + return ":" + port 164 + } 165 + return ":" + strconv.Itoa(defaultPort) 156 166 } 157 167 158 168 func envInt(key string, def int) int {
+38
packages/api/internal/config/config_test.go
··· 80 80 t.Fatalf("ReadThroughMaxAttempts: got %d", cfg.ReadThroughMaxAttempts) 81 81 } 82 82 } 83 + 84 + func TestLoadUsesRailwayPortForBindAddresses(t *testing.T) { 85 + t.Setenv("TURSO_DATABASE_URL", "file:test.db") 86 + t.Setenv("TURSO_AUTH_TOKEN", "") 87 + t.Setenv("HTTP_BIND_ADDR", "") 88 + t.Setenv("INDEXER_HEALTH_ADDR", "") 89 + t.Setenv("PORT", "4567") 90 + 91 + cfg, err := Load(LoadOptions{}) 92 + if err != nil { 93 + t.Fatalf("load config: %v", err) 94 + } 95 + if cfg.HTTPBindAddr != ":4567" { 96 + t.Fatalf("HTTPBindAddr: got %q, want %q", cfg.HTTPBindAddr, ":4567") 97 + } 98 + if cfg.IndexerHealthAddr != ":4567" { 99 + t.Fatalf("IndexerHealthAddr: got %q, want %q", cfg.IndexerHealthAddr, ":4567") 100 + } 101 + } 102 + 103 + func TestLoadPrefersExplicitBindAddressesOverRailwayPort(t *testing.T) { 104 + t.Setenv("TURSO_DATABASE_URL", "file:test.db") 105 + t.Setenv("TURSO_AUTH_TOKEN", "") 106 + t.Setenv("HTTP_BIND_ADDR", "0.0.0.0:8081") 107 + t.Setenv("INDEXER_HEALTH_ADDR", "0.0.0.0:9091") 108 + t.Setenv("PORT", "4567") 109 + 110 + cfg, err := Load(LoadOptions{}) 111 + if err != nil { 112 + t.Fatalf("load config: %v", err) 113 + } 114 + if cfg.HTTPBindAddr != "0.0.0.0:8081" { 115 + t.Fatalf("HTTPBindAddr: got %q", cfg.HTTPBindAddr) 116 + } 117 + if cfg.IndexerHealthAddr != "0.0.0.0:9091" { 118 + t.Fatalf("IndexerHealthAddr: got %q", cfg.IndexerHealthAddr) 119 + } 120 + }