Wisp deploys to Fly.io as a single stateful machine: an OTP release running on Debian, with SQLite on a mounted volume and Litestream streaming the WAL to object storage. Here is the real, copy-pasteable sequence.
1. Create the app
# New app:
fly launch --no-deploy
# Existing app (adopt the committed fly.toml verbatim):
fly launch --copy-config --no-deploy
Both preserve fly.toml without deploying.
2. Create the persistent volume
The volume name wisp_data is hardcoded in fly.toml [mounts] and the Litestream
config, and the region must match primary_region (sjc by default):
fly volumes create wisp_data --region sjc --size 1
Size is in GB; 1 GB is plenty to start and is expandable later. Verify with
fly volumes list.
3. Set the runtime secrets
fly secrets set SECRET_KEY_BASE="$(mix phx.gen.secret)"
fly secrets set PHX_HOST="blog.example.com"
fly secrets set RESEND_API_KEY="re_..."
SECRET_KEY_BASEsigns/encrypts cookies and session tokens.PHX_HOSTis critical: it builds magic-link URLs, derives the WebAuthnrp_id/ origin, and sets the mailer default sender (no-reply@{PHX_HOST}). It overrides thegetwisp.netplaceholder infly.toml[env]. You can editfly.tomldirectly instead of using a secret.RESEND_API_KEYis optional — without it, Wisp falls back toWisp.Mailer.Local. Provide a valid key for production mail.
4. Object storage for Litestream
The default Wisp config targets Fly Tigris (endpoint https://fly.storage.tigris.dev
is referenced in etc/litestream.yml). Pre-create it; Fly auto-injects
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY as secrets, which Litestream reads:
fly storage create
fly secrets set LITESTREAM_REPLICA_URL="s3://getwisp-store/wisp"
To use AWS S3 or Cloudflare R2 instead, set the credentials explicitly:
fly secrets set LITESTREAM_REPLICA_URL="s3://my-bucket/wisp"
fly secrets set LITESTREAM_ACCESS_KEY_ID="AKIA..."
fly secrets set LITESTREAM_SECRET_ACCESS_KEY="wJalrXUt..."
# Cloudflare R2 also needs an endpoint:
# fly secrets set LITESTREAM_ENDPOINT="https://<account>.r2.cloudflarestorage.com"
Litestream credentials are runtime secrets, never baked into the image. If they are missing, Litestream still starts but cannot replicate — leaving the DB unprotected.
5. Custom domain + TLS
Point the domain's DNS at the Fly app (use the CNAME from fly domains), then:
fly certs add blog.example.com
Fly auto-provisions a Let's Encrypt certificate. The order relative to fly deploy
doesn't matter; check fly certs list to confirm status.
6. Deploy
fly deploy
Fly runs the multi-stage Docker build in their CI (the local mix release is for testing;
the image is authoritative).
What happens at boot
- Builder stage: Elixir 1.20 + OTP 28 on Debian bookworm compiles Phoenix, assets (Tailwind + esbuild), and the SQLite NIFs, producing the OTP release.
- Runtime stage: Debian bookworm-slim + Litestream. The entrypoint runs
litestream restore -if-db-not-exists -if-replica-exists /data/wisp_prod.db(idempotent — restores only if the volume is empty and a snapshot exists), thenlitestream replicate -exec '/app/bin/server'to stream the WAL and supervise the server as pid 1. - Migrations run automatically via the
Ecto.Migratorsupervisor inlib/wisp/application.ex, gated on theRELEASE_NAMEenv var that Fly sets for releases. There is norelease_commandinfly.toml— that is intentional, the boot-time migrator is the single reliable path. Boot order: Repo → Migrator → Endpoint.
Key configuration facts
- Database path:
/data/wisp_prod.dbon the mounted volume (DATABASE_PATHinfly.toml[env], required — boot raises if missing). - Pool size 5 (
POOL_SIZE): SQLite is single-writer, so a small pool prevents lock contention. Increasing it causes deadlocks, not throughput. - PORT 8080 must match
[http_service]internal_port(the health check targets it). - SQLite is tuned with
journal_mode: :wal,synchronous: :normal,busy_timeout: 5_000, a 64 MB cache, andtemp_store: :memory. min_machines_running: 1,auto_stop_machines: false— the stateful WAL stream cannot pause.force_https: true.
The full operator checklist also lives in fly.toml itself.