Skip to content

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.

export PATH="$HOME/.fly/bin:$PATH"
flyctl auth login

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:

flyctl status --app vlogroulette-api    # exactly one machine under Machines

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

curl https://vlogroulette-api.mintos.space/health

storage should read s3 and scheduler should be true. Then, from a checkout pointed at the same settings:

backend/.venv/bin/python scripts/r2_check.py

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.

backend/.venv/bin/python scripts/backup.py --out ~/vlogroulette-backups

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

sh deploy/systemd/install.sh

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_DSN is set, and nowhere when it is not. flyctl logs still 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-timers says 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 status should always show one. If something ever scales it, re-read the top of this page.