Thesis

Deploy Wisp to Fly.io

3 min read
Guides

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_BASE signs/encrypts cookies and session tokens.
  • PHX_HOST is critical: it builds magic-link URLs, derives the WebAuthn rp_id / origin, and sets the mailer default sender (no-reply@{PHX_HOST}). It overrides the getwisp.net placeholder in fly.toml [env]. You can edit fly.toml directly instead of using a secret.
  • RESEND_API_KEY is optional — without it, Wisp falls back to Wisp.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), then litestream replicate -exec '/app/bin/server' to stream the WAL and supervise the server as pid 1.
  • Migrations run automatically via the Ecto.Migrator supervisor in lib/wisp/application.ex, gated on the RELEASE_NAME env var that Fly sets for releases. There is no release_command in fly.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.db on the mounted volume (DATABASE_PATH in fly.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, and temp_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.