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:
- Visit
https://YOUR-DOMAIN/hv/dashboard/— the first atproto login becomes the super-user. - 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
- client id url:
- Copy the generated
hvc_…key andhvs_…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
.internalnetwork is IPv6-only, so HappyView must bind[::](set viaHOST=[::]inapi.fly.toml), not the default0.0.0.0(IPv4-only) — otherwise the web app's proxy getsconnection refused/ 502. Note[::], not::(HappyView doesformat!("{host}:{port}"), and:::3000won't parse). - Private api:
api.fly.tomlpublishes no ports, so the app is only reachable over.internal. Iffly deployobjects to a[[services]]without ports, drop the block —<app>.internalresolves 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 DPoPhtumatches what HappyView reconstructs after it strips/hv. Don't "simplify" the SPA to call/hv/xrpcdirectly. - 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_lexiconDNS 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_KEYlive only on the api app.