Deployment: Fly.io, Neon and Cloudflare R2¶
One machine running the API, a managed Postgres on Neon, and the video in Cloudflare R2 behind a public bucket domain. The phone does the editing, so the server stays small on purpose: it draws turns, hands out upload URLs and tells people when something premiered.
What this shape means before you touch anything¶
Exactly one machine. Not two. Two things in this app count in memory and would go quietly wrong if it ever scaled out:
- The turn scheduler runs inside the process. Two machines means two schedulers, and a turn drawn twice.
- Rate limiting counts per address in memory. Two machines means each counts roughly half the requests, so every limit is silently double what it says.
fly.toml pins this (min_machines_running = 1, auto_stop_machines = false)
and the comment there says the same thing. Making the app scale out is real
work -- the schedule has to move into the database with a lock, and the limiter
into something shared -- not a config change.
Auto-stop stays off. A stopped machine runs no scheduler, so nobody would learn their turn had started until somebody happened to open the app.
The origin is reachable. This is the difference from running behind a
tunnel, and it changes who the rate limiter believes. Fly's proxy sets
Fly-Client-IP and overwrites it, so it cannot be forged; X-Forwarded-For is
appended to and its first entry is whatever the caller sent. CLIENT_IP_HEADER
names the one to read, and reading nothing at all is safe -- everyone then
shares one budget. Do not point it at X-Forwarded-For.
Server-side rendering is the fallback, and it is expensive here. Two things
send an edit to it: a phone with no native module at all, or an engine that says
it cannot carry this material. The Android engine now asks the codec list
(EngineCapabilities.kt, #161) and reports itself unavailable on a phone with
no AVC encoder for the output size or no AAC encoder -- that phone is sent here
too. When it is available it is available for everything, so on Android the
material never decides it; the iOS module reports no stills, no fades and no
loudness, so that second case becomes real the moment iOS compiles.
FFmpeg on 1080p on a shared CPU takes minutes and slows the API while it does.
It is on because the alternative is those vlogs failing outright, and it should
stay rare: if it stops being rare, find out why the device engine is being
skipped rather than buying a bigger machine.
Once, on your side¶
You need three accounts: Fly.io with a card on it (this costs a few dollars a month, not nothing -- about $3.20 for the one 512 MB machine, see limits.md; Fly does not charge invoices under $5, which is why a short first month may bill nothing), Neon for the database, and Cloudflare for R2. Only the Fly one is paid.
The database¶
Neon, not Fly's own Managed Postgres. The smallest Fly plan is $38 a month
against a free Neon project, and at this size the difference buys reliability
this does not need yet. It cost one change: DATABASE_URL. See
decisions.md.
At https://console.neon.tech, create a project on the Free plan in
Frankfurt (eu-central-1), which is the region nearest the Fly machine in
Amsterdam.
Take the direct connection string, not the pooled one. psycopg 3 prepares statements on its own and PgBouncer in transaction mode does not support that; the failure arrives later and looks unrelated. See gotchas.
It will start postgres://, which SQLAlchemy does not accept -- the app rewrites
that itself (app/core/config.py), so paste it exactly as given.
What the free plan gives you also shapes the deployment: the compute suspends
after five idle minutes, which is why SCHEDULER_INTERVAL_SECONDS is 900 in
fly.toml rather than the 60 used in development. The arithmetic is in
limits.md.
The bucket¶
In the Cloudflare dashboard, R2 → Create bucket, named vlogroulette-media.
Then Manage R2 API tokens → Create API token, with Object Read & Write
scoped to that bucket. Keep three things: the access key id, the secret, and the
account id, which is the <account-id> in the endpoint
https://<account-id>.r2.cloudflarestorage.com.
Then turn on a public URL for the bucket: Settings → Public access. Either
the r2.dev domain or a custom one works. This is what CDN_BASE_URL points
at, and it is what makes a premiere cheap -- the video is served from
Cloudflare's edge and never touches the API machine. Without it, playback URLs
are signed and every viewer streams through R2 directly, which still works and
still has no egress fee, but loses the caching.
The app¶
flyctl apps create vlogroulette-api
flyctl secrets set \
SECRET_KEY="$(python3 -c 'import secrets; print(secrets.token_urlsafe(48))')" \
DATABASE_URL="postgres://..." \
S3_ENDPOINT_URL="https://<account-id>.r2.cloudflarestorage.com" \
S3_BUCKET="vlogroulette-media" \
S3_ACCESS_KEY_ID="..." \
S3_SECRET_ACCESS_KEY="..." \
CDN_BASE_URL="https://<your-public-bucket-domain>" \
PUBLIC_BASE_URL="https://vlogroulette-api.mintos.space" \
CLIENT_IP_HEADER="fly-client-ip" \
SERVER_SIDE_RENDER_ENABLED="true"
flyctl deploy --ha=false
--ha=false is not optional. Fly creates two machines for a new app by
default, and nothing in fly.toml prevents it -- min_machines_running is a
floor, not a ceiling. Two machines means two schedulers, which means a turn
drawn twice. Check it afterwards:
SECRET_KEY signs every session token. Changing it later logs everyone out.
The app refuses to start on three things outside development
(check_production_settings in app/main.py):
- a weak
SECRET_KEY-- not only the repository default, but any value inWEAK_SECRETS(every placeholder this repo has ever shipped,.env.example's included) or shorter than 32 characters; DEBUGon, which is also its default --fly.tomlsets it tofalse;STORAGE_BACKEND=local, whose/mediaroute has no authentication.
The check is skipped only when ENVIRONMENT is exactly development or
test. Anything else, a typo included, gets the strict treatment.
PUBLIC_BASE_URL in the command above does nothing on this deployment: only
the local storage backend reads it, to build /media URLs. It is harmless and
can be left out.
Migrations¶
fly.toml runs alembic upgrade head as the release_command, once per
deploy in a machine of its own, and sets RUN_MIGRATIONS=false so the
container's entrypoint does not try again. The entrypoint
(backend/docker-entrypoint.sh) migrates on boot when RUN_MIGRATIONS is
unset or true, which is what docker-compose relies on. With SQLite --
development and tests -- neither runs: the app creates the tables itself at
startup.
Error reporting¶
Without it nothing is reported anywhere, and with ENVIRONMENT=test nothing is
reported even when it is set. SENTRY_TRACES_SAMPLE_RATE stays at 0.0: see
limits.md. RELEASE is stamped on every
event so a stack trace can be tied to a deployment; whether it falls back to a
value Fly provides on its own when unset is being settled in #171 -- check
app/core/config.py before relying on it.
Settings worth knowing about¶
The rest have defaults that suit this deployment, and are documented one by one
in backend/.env.example. The ones somebody may actually need to touch:
| Setting | Default | What it does |
|---|---|---|
CORS_ALLOWED_ORIGINS |
[] |
Origins allowed credentialed cross-origin requests. Empty mounts no CORS at all, which is right for native apps; with DEBUG on, empty allows * without credentials. A literal "*" is refused. |
UPLOAD_URL_TTL_SECONDS |
3600 | How long a presigned upload URL lasts (1 h). |
PLAYBACK_URL_TTL_SECONDS |
86400 | How long a signed playback URL lasts (24 h). Irrelevant while CDN_BASE_URL is set. |
FFMPEG_TIMEOUT_SECONDS |
600 | Ceiling for one ffmpeg/ffprobe process in the server-side render. |
STAGING_DIR |
.staging |
Where raw takes wait for a server-side render, on the machine's own disk. |
RESET_CODE_TTL_MINUTES |
15 | Lifetime of a password-reset code. |
REMINDER_HOURS_BEFORE |
24 | How long before a deadline the vlogger is reminded. |
EXPO_PUSH_URL |
Expo's send endpoint | Only worth changing to point at a test double. |
backend/.env.example also carries DEFAULT_CADENCE_DAYS,
DEFAULT_MAX_TOTAL_SECONDS and DEFAULT_CLIP_MAX_SECONDS; nothing reads them -- the per-circle defaults live
in the request schema -- and #170 is deciding whether they go. See
limits.md.
The domains¶
All three services answer on mintos.space, and nothing was moved to do it —
Fly still runs the container, R2 still holds the video, Pages still serves the
site.
| Name | Points at | Proxied? |
|---|---|---|
vlogroulette-api.mintos.space |
Fly, A + AAAA | No |
vlogroulette-media.mintos.space |
R2 bucket | Yes |
vlogroulette-docs.mintos.space |
Pages project | Yes |
The API record must stay DNS-only. Fly issues and terminates its own certificate for that hostname; putting Cloudflare's proxy in front would need an edge certificate covering the name, and would break it. R2 and Pages are the opposite: they are only served through the proxy.
Why hyphens and not api.vlogroulette.mintos.space. Cloudflare's free
Universal SSL covers the apex and one level of subdomain on a full-setup zone.
A second level needs Advanced Certificate Manager, around ten dollars a month,
to achieve the same thing with a dot instead of a hyphen.
Email, for password resets¶
Without this a forgotten password is a lost account: there is no other way back in. It stays off until configured, and while off a reset still works in development — the code goes to the server log, which is where you would look.
Currently Resend over SMTP, sending as no-reply@mintos.space.
flyctl secrets set \
EMAIL_BACKEND=smtp \
EMAIL_FROM="VlogRoulette <no-reply@mintos.space>" \
SMTP_HOST=smtp.resend.com SMTP_PORT=587 \
SMTP_USERNAME=resend \
SMTP_PASSWORD="<the Resend API key>"
The username is literally resend; the password is the API key. Because it
speaks SMTP rather than a provider's API, moving to a different service later
is four settings and no code.
Setting up a sending domain¶
Resend needs DNS records before it will send as your domain. Use its Auto configure button rather than copying them by hand — the DKIM value is a long public key, and one wrong character means mail silently stops leaving.
The records land on a send. subdomain, deliberately, and this is worth
understanding before touching anything: mintos.space already carried MX
records and an SPF from Namecheap's default forwarding, left behind when the
domain moved to Cloudflare. Had Resend put its SPF on the apex it would have
collided with that one, and two SPF records break both. Always check for
existing mail configuration on a domain before adding to it.
A consequence worth knowing: those inherited MX records point at a registrar
service that no longer serves this domain, so replies to no-reply@ go
nowhere. Normal for a no-reply address, and fixable with Cloudflare Email
Routing if it ever matters.
DMARC is offered by Resend and not configured here. v=DMARC1; p=none; is
monitoring only and harmless, but it applies to the whole domain rather than
this project, so it is the domain owner's call.
Checking it, in the order things break¶
storage should read s3, environment production and scheduler true;
server_render says whether the fallback render is on, and is what the app
reads to decide whether to offer it. scheduler is the SCHEDULER_ENABLED
setting echoed back, not a sign the scheduler is alive -- a crashed scheduler
still reads true here. Then, from a checkout pointed at the same settings:
Walks one full user journey over real HTTP -- register, login, create a
circle, draw a turn, record and render on the "device", upload, premiere,
have the audience actually download the bytes, and round-trip a push
notification -- against whatever is actually running, not a test double. Run
it before pointing traffic at a new deploy and again right after; see
scripts/smoke_test.py for what it does and does not verify (PUSH_BACKEND
below decides whether the push step is a real send or a wired-but-silent
no-op).
This is the one that matters. It presigns an upload, PUTs it with a plain HTTP request the way the phone does, downloads it through the playback URL, compares the bytes and deletes it. Everything that goes wrong with R2 goes wrong in the gap between signing and uploading, and this is the only thing that crosses it.
backend/.venv/bin/python scripts/push_check.py --api https://vlogroulette-api.mintos.space --email ...
That last one also runs by itself: .github/workflows/push-check.yml, daily at
06:00 UTC (and by hand from the Actions tab), with the repository secrets
PUSH_CHECK_API_URL (optional; production by default), PUSH_CHECK_EMAIL and
PUSH_CHECK_PASSWORD. It sends a real notification to whichever phone has
that account signed in, every morning -- that is the point, and also why the
account should be one whose owner expects it. A green run means Expo accepted
the push, not that anybody saw it. See push-setup.md.
Pointing the app at it¶
mobile/eas.json carries EXPO_PUBLIC_API_URL per build profile, and preview
and production already point at https://vlogroulette-api.mintos.space. Both that
and the Sentry DSN beside it are baked in at build time, not read at runtime, so
changing either means a rebuild. The development profile carries neither: the
dev client takes the API URL from the Metro command line instead.
Backups¶
Two things cannot be rebuilt, and they fail differently. The vlogs: nothing in the app ever deletes one, so R2 holds a group's entire history and holds the only copy of it. The database: Neon keeps a recovery window, which covers a mistake and does not cover the project or the account going away.
It dumps the database and mirrors the bucket, skipping objects it already has,
so the second run onwards is quick. It only ever adds -- a local file whose
object has vanished is kept and counted, which is the point of a copy. The dump
is taken with a pg_dump of the server's major version, through Docker,
because a dump from a newer one produces a file the server cannot read back.
It reads the same backend/.env as the app, and refuses to run unless
STORAGE_BACKEND=s3 -- with local storage there is no bucket to copy. Two more
things land in the destination:
- The Android signing key, if
ANDROID_KEYSTORE_PATHpoints at it, copied to<out>/keyswith permissions0600. Lose that file and no installed copy of the app can ever be updated again. Its passwords are deliberately not copied -- they stay inbackend/.env, so one lost disk is not the whole set. manifest.json, one entry appended per successful run: when, which dump, and how many objects and bytes. The history of what the backups held.
--verify-only checks what is already there without copying: every object
present, and the newest dump readable by pg_restore --list, which a truncated
file is not.
On a schedule¶
A systemd user timer, not a cron entry, and the difference is the whole
point on a laptop: cron silently skips a run that falls while the machine is
off, which is most nights, and that is how somebody ends up believing in
backups they do not have. Persistent=true records when the job last ran and
catches up on the next boot.
Daily at 22:00 with up to fifteen minutes of jitter. To change it, edit the
timer and systemctl --user daemon-reload.
systemctl --user list-timers vlogroulette-backup.timer # when it next runs
journalctl --user -u vlogroulette-backup -n 50 # what it did
systemctl --user start vlogroulette-backup.service # run it now
loginctl enable-linger $USER # run with the session closed
A failed run reports to Sentry, tagged environment: backup. Nobody reads
the output of something on a timer, and a backup that has been failing quietly
for three months is worse than no backup at all -- you believe you are covered.
This path was tested by pointing the script at a bucket that does not exist and
watching the issue arrive.
Why this is on your machine rather than in the cloud¶
Everything else here is rented, and a backup is the one thing that should not
be. Its value comes from being independent of what it is copying: a copy that
production's own credentials can reach is not protection against those
credentials being misused, or against a bug with a delete in it. Nothing in
production knows this copy exists, and most of the time the machine holding it
is switched off.
The gap that leaves is real and worth naming: a laptop that stays off for a fortnight backs up nothing for a fortnight, and a laptop that dies takes the copy with it. The cheap answer to that, if it ever matters, is a scheduled job that copies only the database somewhere else -- kilobytes, unlike the video.
Restoring¶
Restoring the database, into a scratch copy first rather than over anything:
psql "$DATABASE_URL" -c 'create database restore_check'
pg_restore --no-owner --no-privileges -d "<url of restore_check>" the.dump
This has actually been run, on 2026-08-21, and came back with every table, the right Alembic revision and the rows that were in production. That is the only reason this section could originally say restoring works -- a human doing it once by hand, and never repeating it.
That is now automated: scripts/restore_drill.py takes a real pg_dump (or
checks an existing one with --dump), restores it into a throwaway Postgres
container, and confirms row counts match table-by-table, the Alembic revision
is current, foreign keys resolve, and -- given --media-dir -- sampled vlogs
from the backup are present, non-empty and valid MP4s. It runs on a schedule
in .github/workflows/backup-restore-drill.yml, and can be run by hand:
docker compose up -d --build
backend/.venv/bin/python scripts/smoke_test.py
backend/.venv/bin/python scripts/restore_drill.py
Media goes back with any S3 client -- the mirror is laid out under media/
with the object keys as paths, so uploading that tree to the bucket restores it
unchanged.
Sweeping up what never became a vlog¶
backend/.venv/bin/python scripts/sweep_orphans.py --dry-run # report only
backend/.venv/bin/python scripts/sweep_orphans.py # remove
Two things are left behind when a turn ends some way other than the one that
cleans up after itself (#79): objects in the bucket with no Vlog row --
an upload nothing ever confirmed -- and staging directories of raw takes
for turns that are no longer open. The script removes both and never touches a
key a vlog points at. Anything younger than ORPHAN_SWEEP_GRACE_MINUTES
(default 60) is left alone whatever the database says, because an object can
land in storage a moment before its row commits.
Nothing runs it. There is no timer, no workflow and no scheduler job: it
happens when somebody types it. Always --dry-run first.
It reads backend/.env, so from a checkout pointed at production it sweeps the
real bucket -- but the staging directory it sweeps is the one on the machine
running it. scripts/ is not in the container image, so the Fly machine's own
STAGING_DIR is out of its reach; that disk is not a volume, so a deploy or a
restart empties it anyway.
The documentation site¶
The Markdown in docs/ is the source; the site is generated from it and never
edited directly.
.venv-docs/bin/mkdocs serve # local, with live reload
.venv-docs/bin/mkdocs build --strict # what CI runs
--strict fails on a broken internal link, which is the failure mode that
quietly makes documentation untrustworthy.
Published to https://vlogroulette-docs.mintos.space by .github/workflows/docs.yml
on every push to main that touches docs/ or mkdocs.yml. Pull requests build
the site to check it and publish nothing.
The Cloudflare token behind it is an account token (cfat_ prefix) with a
single permission, Cloudflare Pages → Edit. It cannot reach R2, DNS or billing.
Note that account tokens verify at /accounts/{id}/tokens/verify, not the
/user/tokens/verify endpoint most examples show.
What to watch afterwards¶
- Errors go to Sentry when
SENTRY_DSNis set, and nowhere when it is not.flyctl logsstill has everything the machine printed, but only until it is replaced. - The backup runs on your laptop, and only while it is on. The systemd timer
catches up a run missed overnight, but a machine switched off for a fortnight
backs up nothing for a fortnight.
systemctl --user list-timerssays when it last managed one. - Storage only grows. No vlog is ever deleted, by design -- worth knowing before it is a surprise on a bill. The one exception is leftovers that never became a vlog, below, and only when somebody runs the sweep.
- The machine count.
flyctl statusshould always show one. If something ever scales it, re-read the top of this page.