diff --git a/docs/README.md b/docs/README.md index 0a6e45e..64878c7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,6 +6,7 @@ Completed work — architecture, APIs, and data models as built. - [`reference/api.md`](reference/api.md) — Go search API service - [`reference/app.md`](reference/app.md) — Ionic Vue mobile app +- [`reference/deployment-walkthrough.md`](reference/deployment-walkthrough.md) — Railway deployment guide - [`reference/lexicons.md`](reference/lexicons.md) — Tangled AT Protocol record types - [`reference/resync.md`](reference/resync.md) — Backfill and repo-resync recovery playbook diff --git a/docs/reference/deployment-walkthrough.md b/docs/reference/deployment-walkthrough.md new file mode 100644 index 0000000..aee55ee --- /dev/null +++ b/docs/reference/deployment-walkthrough.md @@ -0,0 +1,150 @@ +# Deployment Walkthrough + +This repo maps cleanly to Railway, but only for the backend pieces. + +- Deploy `packages/api` to Railway as two services: `api` and `indexer`. +- Keep the Ionic + Capacitor app on your machine or in CI for native builds. +- Point the mobile app at the Railway `api` service with + `VITE_TWISTER_API_BASE_URL`. + +## What Railway Should Host + +Railway is a good home for the Go services in this repo: + +- `api`: serves HTTP routes, docs, search, proxies, and readiness checks +- `indexer`: consumes Tap, writes into Turso, and exposes its own health endpoint +Railway is not the place that ships the native iOS or Android app. You still +build, sign, and distribute the Capacitor shells separately. + +## Prerequisites + +Before you start, have these ready: + +- a Railway account and the Railway CLI +- a Turso database URL and auth token +- a Tap URL and Tap auth password +- a seed list for the first backfill run +From this machine: + +```sh +cd /Users/owais/Projects/Twisted +railway login +``` + +## Create The Railway Project + +In the Railway dashboard, create one empty project with two empty services: + +- `api` +- `indexer` +Then link this repo to that project: + +```sh +cd /Users/owais/Projects/Twisted +railway link +``` + +## Configure Service Shape + +Both services should deploy from the same local path: + +- path: `packages/api` +- build source: `packages/api/Dockerfile` +Set the service start commands in Railway: +- `api`: `twister api` +- `indexer`: `twister indexer` +The checked-in Dockerfile already builds the `twister` binary. + +## Set Variables + +Use shared variables for values both services need: + +- `TURSO_DATABASE_URL` +- `TURSO_AUTH_TOKEN` +- `LOG_LEVEL=info` +- `LOG_FORMAT=json` +Set these on `api`: +- `HTTP_BIND_ADDR=0.0.0.0:${{ PORT }}` +- `SEARCH_DEFAULT_LIMIT=20` +- `SEARCH_MAX_LIMIT=100` +- `READ_THROUGH_MODE=missing` +- `READ_THROUGH_COLLECTIONS=sh.tangled.*` +- `READ_THROUGH_MAX_ATTEMPTS=5` +- `ENABLE_ADMIN_ENDPOINTS=false` +- `ADMIN_AUTH_TOKEN=` +Set these on `indexer`: +- `INDEXER_HEALTH_ADDR=0.0.0.0:${{ PORT }}` +- `TAP_URL=` +- `TAP_AUTH_PASSWORD=` +- `INDEXED_COLLECTIONS=sh.tangled.*` +- `ENABLE_INGEST_ENRICHMENT=true` +Optional OAuth variables for a Railway-hosted web client metadata endpoint: +- `OAUTH_CLIENT_ID` +- `OAUTH_REDIRECT_URIS` +The `${{ PORT }}` reference matters. Railway health checks run against the +service port it injects, so the process must listen on that port. + +## Deploy From This Machine + +From the repo root, deploy `packages/api` into each Railway service: + +```sh +cd /Users/owais/Projects/Twisted +railway up packages/api --path-as-root --service api +railway up packages/api --path-as-root --service indexer +``` + +`--path-as-root` is important in this monorepo. It makes `packages/api` the +deployment root instead of archiving the whole repo. + +## Configure Health Checks + +Set the health check path in Railway for each service: + +- `api`: `/readyz` +- `indexer`: `/health` +`/readyz` is the better API check because it verifies database reachability. + +## First Bootstrap + +A fresh environment is not search-ready just because the services booted. + +1. Deploy `api`. +2. Deploy `indexer`. +3. Confirm the `api` domain returns `200` from `/readyz`. +4. Confirm the `indexer` returns `200` from `/health`. +5. Run the initial backfill against the same Turso and Tap environment. +One simple way to run backfill from this machine is to use the same env values +locally and execute: + +```sh +cd /Users/owais/Projects/Twisted/packages/api +go run ./main.go backfill --seeds /path/to/seeds.txt +``` + +Do not call the environment ready until that first backfill has completed. + +## Point The App At Railway + +For local app builds, set the Railway API URL in `apps/twisted/.env`: + +```sh +VITE_TWISTER_API_BASE_URL=https:// +``` + +Then build or run the app as usual: + +```sh +pnpm --dir apps/twisted dev +pnpm --dir apps/twisted build +pnpm --dir apps/twisted exec cap sync +``` + +## Operating Model + +This is the practical split: + +- Railway hosts the always-on backend +- Turso stores indexed data +- this machine, or CI, builds the mobile app and points it at Railway +If you later want a Railway-hosted web frontend, add that as a separate service. diff --git a/packages/api/Dockerfile b/packages/api/Dockerfile index a89810e..e345ad3 100644 --- a/packages/api/Dockerfile +++ b/packages/api/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.24-alpine AS builder +FROM golang:1.25-alpine AS builder WORKDIR /app @@ -21,6 +21,6 @@ RUN apk add --no-cache ca-certificates tzdata COPY --from=builder /app/twister /usr/local/bin/twister -EXPOSE 8080 9090 9091 +EXPOSE 8080 9090 CMD ["twister", "api"] diff --git a/packages/api/internal/config/config.go b/packages/api/internal/config/config.go index 784bf40..f343183 100644 --- a/packages/api/internal/config/config.go +++ b/packages/api/internal/config/config.go @@ -63,8 +63,8 @@ func Load(opts LoadOptions) (*Config, error) { ReadThroughCollections: envOrDefault("READ_THROUGH_COLLECTIONS", os.Getenv("INDEXED_COLLECTIONS")), ReadThroughMaxAttempts: envInt("READ_THROUGH_MAX_ATTEMPTS", 5), SearchDefaultMode: envOrDefault("SEARCH_DEFAULT_MODE", "keyword"), - HTTPBindAddr: envOrDefault("HTTP_BIND_ADDR", ":8080"), - IndexerHealthAddr: envOrDefault("INDEXER_HEALTH_ADDR", ":9090"), + HTTPBindAddr: envBindAddr("HTTP_BIND_ADDR", "PORT", 8080), + IndexerHealthAddr: envBindAddr("INDEXER_HEALTH_ADDR", "PORT", 9090), LogLevel: envOrDefault("LOG_LEVEL", "info"), LogFormat: envOrDefault("LOG_FORMAT", "json"), AdminAuthToken: os.Getenv("ADMIN_AUTH_TOKEN"), @@ -155,6 +155,16 @@ func envOrDefault(key, def string) string { return def } +func envBindAddr(key, portKey string, defaultPort int) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + if port := strings.TrimSpace(os.Getenv(portKey)); port != "" { + return ":" + port + } + return ":" + strconv.Itoa(defaultPort) +} + func envInt(key string, def int) int { v := os.Getenv(key) if v == "" { diff --git a/packages/api/internal/config/config_test.go b/packages/api/internal/config/config_test.go index f07774f..7d54845 100644 --- a/packages/api/internal/config/config_test.go +++ b/packages/api/internal/config/config_test.go @@ -80,3 +80,41 @@ func TestLoadReadThroughDefaults(t *testing.T) { t.Fatalf("ReadThroughMaxAttempts: got %d", cfg.ReadThroughMaxAttempts) } } + +func TestLoadUsesRailwayPortForBindAddresses(t *testing.T) { + t.Setenv("TURSO_DATABASE_URL", "file:test.db") + t.Setenv("TURSO_AUTH_TOKEN", "") + t.Setenv("HTTP_BIND_ADDR", "") + t.Setenv("INDEXER_HEALTH_ADDR", "") + t.Setenv("PORT", "4567") + + cfg, err := Load(LoadOptions{}) + if err != nil { + t.Fatalf("load config: %v", err) + } + if cfg.HTTPBindAddr != ":4567" { + t.Fatalf("HTTPBindAddr: got %q, want %q", cfg.HTTPBindAddr, ":4567") + } + if cfg.IndexerHealthAddr != ":4567" { + t.Fatalf("IndexerHealthAddr: got %q, want %q", cfg.IndexerHealthAddr, ":4567") + } +} + +func TestLoadPrefersExplicitBindAddressesOverRailwayPort(t *testing.T) { + t.Setenv("TURSO_DATABASE_URL", "file:test.db") + t.Setenv("TURSO_AUTH_TOKEN", "") + t.Setenv("HTTP_BIND_ADDR", "0.0.0.0:8081") + t.Setenv("INDEXER_HEALTH_ADDR", "0.0.0.0:9091") + t.Setenv("PORT", "4567") + + cfg, err := Load(LoadOptions{}) + if err != nil { + t.Fatalf("load config: %v", err) + } + if cfg.HTTPBindAddr != "0.0.0.0:8081" { + t.Fatalf("HTTPBindAddr: got %q", cfg.HTTPBindAddr) + } + if cfg.IndexerHealthAddr != "0.0.0.0:9091" { + t.Fatalf("IndexerHealthAddr: got %q", cfg.IndexerHealthAddr) + } +}