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 answers yes to everything, so in practice only the first happens today — but the iOS module reports no stills, no fades and no loudness, so the second 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), 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. Leaving it at the repository default is
one of the two things the app refuses to start on in production; the other is
DEBUG. Changing it later logs everyone out.
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 and scheduler should be true. Then, from a
checkout pointed at the same settings:
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 ...
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.
--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 can say restoring works.
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.
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 machine count.
flyctl statusshould always show one. If something ever scales it, re-read the top of this page.