# Deployment and operations

> Build, containerize, deploy, configure runtime environments, and observe the running system.

- Repository: egoist/lorca
- GitHub: https://github.com/egoist/lorca
- Human docs: https://grok-wiki.com/public/docs/egoist-lorca-2cf67495e5e6
- Complete Markdown: https://grok-wiki.com/public/docs/egoist-lorca-2cf67495e5e6/llms-full.txt

## Source Files

- `ARCHITECTURE.md`
- `crates/relay/Dockerfile`
- `docs/releasing-mac.md`
- `README.md`
- `docs/releasing-cli.md`

---

---
title: "Deployment and operations"
description: "Build, containerize, deploy, configure runtime environments, and observe the running system."
---

`lorca-relay` is the only always-on network service: build it from `crates/relay/Dockerfile`, run it behind HTTPS, point Devices at the URL with `LORCA_RELAY_URL` or Settings › Advanced › Relay URL. Everything else is a client release (CLI archives, Mac Sparkle feed, iOS TestFlight) or the marketing site Worker.

```mermaid
flowchart LR
  subgraph clients [Client releases]
    CLI["lorca CLI<br/>GitHub cli-v*"]
    MAC["Lorca.app<br/>Sparkle + R2"]
    IOS["iOS<br/>TestFlight"]
    WEB["web/<br/>Cloudflare Worker"]
  end
  subgraph runtime [Self-hosted relay]
    RELAY["lorca-relay<br/>axum :PORT"]
    DB[(SQLite volume<br/>or Postgres)]
    FILES[Local files dir<br/>or S3/R2]
  end
  CLI --> RELAY
  MAC --> RELAY
  IOS --> RELAY
  RELAY --> DB
  RELAY --> FILES
```

## Deployable surfaces

| Surface | Artifact | Typical host | Trigger |
| --- | --- | --- | --- |
| Relay | `lorca-relay` binary / Docker image | Railway, any Docker host | Image build + env |
| CLI | `lorca-cli-*-*.tar.gz` / `.zip` + `.sha256` | GitHub Releases (`cli-vX.Y.Z`) | `.github/workflows/release-cli.yml` |
| macOS app | `.dmg`, Sparkle `.zip`, `appcast.xml` | Cloudflare R2 (`mac-releases.lorca.app`) | `bun run release-mac` |
| iOS | App Store archive | App Store Connect / TestFlight | `bun run release-ios` |
| Website | TanStack Start Worker | Cloudflare (`wrangler deploy`) | `bun run web:deploy` |

Production Mac apps default the CLI to `https://relay.lorca.app` via `LORCA_DEFAULT_RELAY_URL` when unset. A Device with no relay still runs alone on that machine.

## Relay: build and run the container

Build from the **repo root** (Cargo workspace context). About 5–15 minutes cold; dependency layers stay cached until manifests change.

<Steps>
  <Step title="Build the image">
    ```bash
    docker build -f crates/relay/Dockerfile -t lorca-relay .
    ```
  </Step>
  <Step title="Run with a volume (SQLite)">
    ```bash
    docker run -p 8787:8787 \
      -e LORCA_RELAY_SECRET="$(openssl rand -hex 32)" \
      -v lorca-relay:/data \
      lorca-relay
    ```
    Working directory is `/data`. Defaults: `lorca-relay.db` and `lorca-relay.files` there.
  </Step>
  <Step title="Verify (under 30 seconds)">
    ```bash
    curl -s http://127.0.0.1:8787/v1/health
    ```
    Expected: `{"ok":true,"service":"lorca-relay","protocol":…}`. Root `/` returns `Lorca Relay is running...`.
  </Step>
</Steps>

Image behavior:

- Multi-stage: `cargo-chef` → release build of `-p lorca-relay` → `debian:trixie-slim`
- TLS roots are compiled in (`webpki-roots`); no CA bundle in the runtime image
- Listen address: `LORCA_RELAY_BIND`, else `[::]:${PORT:-8787}`
- SIGTERM closes sockets so Devices reconnect after a short backoff

Local binary (no Docker):

```bash
lorca-relay --bind 0.0.0.0:8787 --db lorca-relay.db
```

Put HTTPS and WebSocket upgrades in front (Caddy/nginx). Set `--trust-proxy` / `LORCA_RELAY_TRUST_PROXY=true` so rate limits see real client IPs.

Dev relay from the monorepo: `bun run relay` binds `0.0.0.0:8787` with DB under `temp/` and `--apns-topic app.lorca.dev`.

## Railway deploy

<Steps>
  <Step title="Create the service">
    Root Directory empty. Set Healthcheck Path to `/v1/health`. Watch Paths: `/crates/relay/**`, `/Cargo.toml`, `/Cargo.lock`.
  </Step>
  <Step title="Set required variables">
    | Variable | Value |
    | --- | --- |
    | `RAILWAY_DOCKERFILE_PATH` | `crates/relay/Dockerfile` |
    | `LORCA_RELAY_SECRET` | `openssl rand -hex 32` (stable across restarts) |
    | `LORCA_RELAY_TRUST_PROXY` | `true` |

    Leave `PORT` and `LORCA_RELAY_BIND` unset so Railway’s `$PORT` is used.
  </Step>
  <Step title="Pick storage">
    **SQLite:** volume at `/data`. One deploy at a time; old process stops before the new one starts (~few seconds of reconnect).

    **Postgres + bucket:** no volume; overlapping deploys and multiple replicas.
  </Step>
  <Step title="Attach a domain and point clients">
    First Device: Settings › Advanced › Relay URL, or `LORCA_RELAY_URL=https://<domain>`. Later Devices learn the URL from the pairing code.
  </Step>
</Steps>

### Postgres + object storage

| Variable | Value |
| --- | --- |
| `LORCA_RELAY_DB` | `${{Postgres.DATABASE_URL}}?sslmode=disable` |
| `LORCA_RELAY_S3_BUCKET` | Bucket name |
| `LORCA_RELAY_S3_ENDPOINT` | e.g. `https://<account>.r2.cloudflarestorage.com` |
| `LORCA_RELAY_S3_ACCESS_KEY` / `LORCA_RELAY_S3_SECRET_KEY` | Or `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` |
| `LORCA_RELAY_S3_REGION` | Default `auto` |
| `LORCA_RELAY_S3_PREFIX` | Optional key prefix |

`sslmode=disable` is required for Railway’s private Postgres (self-signed cert vs webpki roots). All replicas must share the same `LORCA_RELAY_SECRET` and the same bucket. Objects use path-style URLs.

## Runtime configuration

Every flag has an env var (`lorca-relay --help`).

### Always set in production

<ParamField body="LORCA_RELAY_SECRET" type="string" required>
Signs bearer tokens. Unset → new secret every boot → every Device token invalid.
</ParamField>

<ParamField body="LORCA_RELAY_TRUST_PROXY" type="bool" default="false">
Read client IP from `X-Forwarded-For`. Required behind Railway/Caddy/nginx.
</ParamField>

### Storage and limits

| Env | Default | Role |
| --- | --- | --- |
| `LORCA_RELAY_DB` | `lorca-relay.db` | SQLite path or `postgres://…` |
| `LORCA_RELAY_FILES_DIR` | beside DB | Local attachment ciphertext (conflicts with S3) |
| `LORCA_RELAY_QUOTA_BYTES` | `5368709120` (5 GiB) | Per-identity ciphertext cap; `0` = unlimited |
| `LORCA_RELAY_CONCURRENT_UPLOADS` | `3` | Large uploads at once (~80 MB each while decoding); `0` = unlimited |
| `LORCA_RELAY_IP_PER_MINUTE` | `60` | Unauth routes (register/auth/pair) |
| `LORCA_RELAY_IDENTITY_PER_SECOND` | `50` | Per-identity; burst 10× |
| `LORCA_RELAY_MIN_PROTOCOL` | `0` | Older clients get `426` |
| `LORCA_RELAY_INACTIVE_DAYS` | `365` | Drop idle identities; `0` keeps all |

Attachment ceiling: **100 MiB** + 40-byte envelope. Non-file blobs: **4 MiB**.

### Push (optional)

| Env | Role |
| --- | --- |
| `LORCA_RELAY_APNS_KEY` | `.p8` text or file path |
| `LORCA_RELAY_APNS_KEY_ID` / `LORCA_RELAY_APNS_TEAM_ID` | Apple key + team |
| `LORCA_RELAY_APNS_TOPIC` | Default `app.lorca` |
| `LORCA_RELAY_FCM_SERVICE_ACCOUNT` | Firebase JSON text or path |

Without these keys the relay still syncs; phones get no pushes. First log line shows `push=apns`, `push=fcm`, or `push=apns+fcm`.

## Observe the running relay

| Check | How | Success |
| --- | --- | --- |
| Liveness | `GET /v1/health` | JSON `ok`, `service`, `protocol` |
| Banner | `GET /` | `Lorca Relay is running...` |
| Boot config | First log line | Names bind, db, files, push |
| Metrics | `GET /metrics` with bearer | Prometheus text when token set |
| Verbose rate limits | `RUST_LOG=info,lorca_relay=debug` | Logs limited requests |

<ParamField body="LORCA_RELAY_METRICS_TOKEN" type="string">
Enables `GET /metrics`. Unset → `404`. Scrape with `Authorization: Bearer <token>`. Multi-replica scrapes hit one instance; per-process counters carry `instance`; DB totals match across replicas.
</ParamField>

Metrics cover identities/machines, blob bytes by kind, open sockets, requests by route/status, rate-limit refusals, pushes, and sweep removals. Ciphertext content is never readable by the relay.

Housekeeping: minute tick + hourly/daily sweeps (stale envelopes, orphan objects, inactive identities).

## Client and site releases

### CLI (`cli-vX.Y.Z`)

1. Bump `[workspace.package]` version in root `Cargo.toml`, `cargo check -p lorca`, commit.
2. `gh workflow run release-cli.yml` or `git tag cli-vX.Y.Z && git push origin cli-vX.Y.Z`.
3. Publish the draft as **latest** so `install-cli.sh` / `install-cli.ps1` hit `releases/latest`.

Archives: macOS aarch64, Linux musl aarch64/x86_64, Windows x86_64 + `.sha256`. Install env: `LORCA_VERSION`, `LORCA_INSTALL_DIR` (default `~/.local/bin`), `LORCA_NO_MODIFY_PATH=1`, `LORCA_DOWNLOAD_URL`.

### macOS app (Sparkle)

One-time: Developer ID + `notarytool` profile `NOTARY`, Sparkle Ed25519 in login keychain, rclone remote `r2` → bucket `lorca-mac-releases`.

```bash
bun run release-mac 0.2.0          # build, notarize, appcast, upload
bun run release-mac --local        # notarized .dmg only, no publish
```

Version is root `package.json` `"version"`. Feed: `https://mac-releases.lorca.app/appcast.xml`. Staging under `dist/mac/`.

### iOS (TestFlight)

```bash
bun run release-ios                # archive + upload
bun run release-ios --local        # archive only
BUILD_NUMBER=42 bun run release-ios
```

Needs Xcode account, `cargo`, `pod`, etc. Marketing version from `mobile/app.config.ts`; default build number is local `YYYYMMDDHHmm`. Bundle id `app.lorca`. Appears in TestFlight after Apple processing (~30 minutes).

### Website

```bash
bun run web:deploy                 # vite build && wrangler deploy
```

Worker name `lorca` in `web/wrangler.jsonc` (`nodejs_compat`, observability on). Install scripts ship with the site; `_headers` serves them as UTF-8 text.

## Version lines to keep straight

| Product | Version source |
| --- | --- |
| CLI / crates | `[workspace.package]` in `Cargo.toml` |
| Mac app | `"version"` in root `package.json` |
| Phone marketing version | `mobile/app.config.ts` |

Prod vs dev installs use different ids (`app.lorca` vs `app.lorca.dev`), homes (`~/.lorca` vs `~/.lorca-dev`), and CLI ports (`4862` vs `4863`).

## Related pages

<CardGroup>
  <Card title="Configuration reference" href="/configuration-reference">
    Env vars, flags, and defaults for relay and clients.
  </Card>
  <Card title="crates/relay reference" href="/ref-crates-relay">
    Binary options, routes, storage backends, error cases.
  </Card>
  <Card title="Installation" href="/installation">
    Install CLI or app and first success signal.
  </Card>
  <Card title="Architecture" href="/architecture">
    Device/relay boundaries and sync data flow.
  </Card>
</CardGroup>

Next: `curl -s https://<your-relay>/v1/health` and confirm `ok` is `true`.
