README.md

Fly.io deployment #

Two Fly apps behind one public origin — the topology the local deploy/ stack mirrors:

            ┌─────────────────────────────┐
 browser ─▶ │ timelog-web  (public)       │   Caddy: serves the SPA at /,
   https    │  Caddy + built SPA          │   proxies /hv + /xrpc, injects the
            └──────────────┬──────────────┘   HappyView client key/secret
                           │ .internal (private 6PN)
            ┌──────────────▼──────────────┐
            │ timelog-api  (private)      │   HappyView under BASE_PATH=/hv,
            │  HappyView + SQLite volume  │   not publicly reachable
            └─────────────────────────────┘

Only timelog-web is public and owns the domain. timelog-api is reachable only as timelog-api.internal:3000 from the web app. The SPA needs no build-time config — it derives its origin, OAuth client_id, and redirect URI from window.location at runtime, so the web image is domain-agnostic.

Fill these in first #

Placeholder Where Example
app names web.fly.toml, api.fly.toml (app =) + HV_UPSTREAM timelog-web, timelog-api
region both fly.toml (primary_region) iad
domain api.fly.toml → PUBLIC_URL https://time.pardisio.xyz

The domain is the only value that must match reality; everything else is a name you pick. fly deploy validates each fly.toml.

Prerequisites #

fly auth login
fly apps create timelog-web
fly apps create timelog-api
# 1 GB SQLite volume. Pick a region with capacity (ord worked; iad was full at
# deploy time). Whatever you pick must match `primary_region` in both fly.toml.
fly volumes create happyview_data -a timelog-api -r ord -s 1

1. Deploy the api (HappyView) first #

The web app depends on it. HappyView builds from its own repo (../happyview). Use Fly's remote builder so the image is amd64 regardless of your machine's arch (a local arm64 image won't run on Fly).

# secrets — generated once, stored by Fly (never in git)
fly secrets set -a timelog-api \
  SESSION_SECRET="$(openssl rand -base64 48)" \
  TOKEN_ENCRYPTION_KEY="$(openssl rand -base64 32)"

# build + deploy from the happyview checkout using our config
cp deploy/fly/api.fly.toml ../happyview/fly.toml
( cd ../happyview && fly deploy --remote-only --ha=false )

2. Deploy the web app #

# `.` = build context (repo root, so the Dockerfile's COPYs see package.json/src)
fly deploy . -c deploy/fly/web.fly.toml --remote-only --ha=false

It comes up serving the SPA and proxying to the api — but user sign-in won't work until step 4 (the client secret). The HappyView dashboard does work now, because it authenticates with atproto OAuth directly, not our API client.

3. Domain + TLS #

Point your domain at the web app and let Fly issue the cert:

fly certs add YOUR-DOMAIN -a timelog-web
fly ips list -a timelog-web        # add the shown A / AAAA records at your DNS host
fly certs show YOUR-DOMAIN -a timelog-web    # wait until it reports issued

Confirm https://YOUR-DOMAIN/health → 200 and the SPA loads.

4. Bootstrap the HappyView API client (the chicken-and-egg) #

The web app injects a confidential HappyView API client (key + secret) that only exists once HappyView is running. Create it, then hand it to the web app:

  1. Visit https://YOUR-DOMAIN/hv/dashboard/ — the first atproto login becomes the super-user.
  2. Create an API client of type confidential with:
    • client id url: https://YOUR-DOMAIN/
    • redirect uri: https://YOUR-DOMAIN/oauth/callback
    • allowed origin: https://YOUR-DOMAIN
  3. Copy the generated hvc_… key and hvs_… secret, then:
fly secrets set -a timelog-web \
  HAPPYVIEW_CLIENT_KEY=hvc_xxx HAPPYVIEW_CLIENT_SECRET=hvs_xxx

Setting secrets restarts the web app. Now Caddy injects them on every proxied request (see deploy/Caddyfile), and sign-in at https://YOUR-DOMAIN/ works: handle → approve on your PDS → space provisioned → records sync.

Redeploys #

  • SPA / Caddyfile change: fly deploy . -c deploy/fly/web.fly.toml --remote-only
  • HappyView upgrade: ( cd ../happyview && fly deploy --remote-only --ha=false )
  • Secrets persist across deploys; the SQLite volume persists across api deploys.

Notes #

  • Private api over IPv6: Fly's .internal network is IPv6-only, so HappyView must bind [::] (set via HOST=[::] in api.fly.toml), not the default 0.0.0.0 (IPv4-only) — otherwise the web app's proxy gets connection refused / 502. Note [::], not :: (HappyView does format!("{host}:{port}"), and :::3000 won't parse).
  • Private api: api.fly.toml publishes no ports, so the app is only reachable over .internal. If fly deploy objects to a [[services]] without ports, drop the block — <app>.internal resolves regardless; you'd just lose the HTTP check.
  • Same origin, DPoP: keep the Caddy proxy path-intact. HappyView runs under BASE_PATH=/hv; the SPA calls XRPC at the root /xrpc/* (Caddy rewrites to /hv/xrpc/*) so the DPoP htu matches what HappyView reconstructs after it strips /hv. Don't "simplify" the SPA to call /hv/xrpc directly.
  • NSID lexicons (xyz.pardisio.time.*): records are stored as raw JSON in the space with no server-side lexicon validation, so the app runs without publishing the lexicons. For full interop later, upload them to HappyView or serve a _lexicon DNS TXT record for the authority domain.
  • Secrets never ship in the bundle: the client key/secret live only as Fly secrets on the web app and are injected by Caddy. SESSION_SECRET / TOKEN_ENCRYPTION_KEY live only on the api app.