Blog/Tutorials
Tutorials

Self-Hosting the OnlyFans API on Fly.io: A Complete Deployment Guide

Deploy the open-source OnlyFans and Fansly CRM API on Fly.io with a persistent volume and a machine that never sleeps. Two apps, three secrets, and the autostop default that silently kills polling.

J
Jordan H. · Co-Founder & CEO, The Only API·Sep 19, 2026·11 min read·Updated Sep 24, 2026
Self-Hosting the OnlyFans API on Fly.io: A Complete Deployment Guide

Fly.io is close to the shortest path from a Dockerfile to a running container with a persistent disk, which is exactly the shape this application needs: one always-on process, one volume, Python and Node in the same image. It runs there well.

It will also, if you take the defaults, appear to deploy perfectly and then quietly do nothing. fly launch writes a config that puts your machine to sleep when traffic stops, and a sleeping machine runs no scheduler. Polling stops. Webhook retries stop. Automations stop. Nothing logs an error, because nothing is running to log one.

This guide walks the whole deployment end to end — two Fly apps, one volume, three secrets, a custom domain, your first connected account, backups, upgrades, and the failure modes worth recognising before they cost you a night. Every command matches the fly.toml and fly.web.toml committed in the repository. Fly.io is a trademark of its owner; this guide is descriptive and not affiliated with or endorsed by Fly.io.

What you are actually deploying

Two processes, and they are not interchangeable.

The backend is a Flask API plus a background event engine: an APScheduler instance that polls connected accounts, a webhook delivery queue with its own retry schedule, an automation rule evaluator, and a Server-Sent Events hub that pushes live updates to open dashboards. It owns a SQLite database and one saved session file per connected OnlyFans or Fansly account.

The dashboard is a Next.js App Router app, and it is stateless. Every call it makes to the backend happens server-side, with the API key attached from the signed session JWT. The browser never talks to Flask directly.

The backend image ships Python and Node 20 in the same container. header_generator.py shells out to node onlyfans-sign-generator.js for every signed OnlyFans request, and with no node on PATH every signed call fails — which rules out any Python-only base image or buildpack.

Fly is one app per config file, so this is two Fly apps: onlyfans-api built from fly.toml, and onlyfans-api-web built from fly.web.toml.

The constraint that shapes every other decision

One machine, always running. This is not a preference you can tune later, and there are three independent reasons for it.

The first is in the code. gunicorn.conf.py raises a RuntimeError at startup if GUNICORN_WORKERS is anything but 1, because four things hold authoritative state inside the process: the APScheduler instance, the refresh_state progress store, the sse_hub subscriber registry, and a memory-backed rate limiter. Two processes means every poll and every webhook retry fires twice against the same accounts, doubling your captcha spend, while live events reach only the browsers connected to whichever process emitted them. Concurrency comes from threads instead — 32 in one worker, which suits a workload that is almost entirely socket wait.

The second is Fly's own storage model. A volume attaches to exactly one Machine. A second Machine gets its own empty volume, and therefore its own empty database — two divergent copies of your CRM with no reconciliation. That is the failure mode that destroys data quietly.

The third is the one this guide exists for. When fly launch generates a fly.toml, it writes auto_stop_machines = "stop", auto_start_machines = true and min_machines_running = 0 (Fly docs, checked September 2026). A stopped Machine runs no scheduler. It will start again when an HTTP request arrives, so the dashboard looks fine the moment you open it — and every poll that should have happened while you were asleep simply did not. Worse for this stack specifically, Fly's private .internal DNS only returns records for started Machines, so a stopped backend is not reachable from the dashboard app over the private network at all.

The committed fly.toml sets the opposite, and it must stay that way:

toml
[http_service]
  internal_port = 5000
  force_https = true

  auto_stop_machines = false
  auto_start_machines = false
  min_machines_running = 1

Fly now spells these as strings — "off", "stop" and "suspend" — with "off" documented as equivalent to false. Either form works; if you regenerate the file, write auto_stop_machines = "off" and leave auto_start_machines = false. Fly's documentation is explicit: with "off", the proxy will never stop Machines.

What it costs

Fly bills compute per second, and prices vary by region. These are the Amsterdam rates displayed on Fly's pricing page, checked September 2026.

ItemSpecPrice
Backend Machineshared-cpu-1x, 1 GB$5.92/mo
Dashboard Machineshared-cpu-1x, 512 MB$3.32/mo
Volumeper GB provisioned$0.15/GB/mo
Outbound bandwidthNorth America / Europe$0.02/GB
Supportcommunityfree

A 10 GB volume adds $1.50, so a realistic starting bill is around $11/month before bandwidth. There is no ongoing free tier — Fly offers a free trial capped at two hours of Machine runtime or seven days, whichever comes first. Paid support starts at $29/month (Fly's plans page, checked September 2026); you do not need it.

Volumes are encrypted by default, and Fly takes daily block-level snapshots with five days of retention (configurable from 1 to 60). Useful, but not a backup strategy on its own.

The server is not the expensive part, though. One dedicated residential or mobile proxy per connected account is required here exactly as on the hosted cloud, and proxies are usually the largest recurring line item. Captcha credits are second.

Before you start

You need four things the software cannot provide.

  • A 2captcha API key. OnlyFans gates login behind a Cloudflare Turnstile challenge and the login flow pays for a solve every time. config.py treats TWOCAPTCHA_API_KEY as mandatory and raises at import without it, so the API will not bind a port. CapSolver and anti-captcha are functional competitors, but the client speaks the 2captcha API.
  • One proxy per account you plan to connect. Residential or mobile. A datacentre IP shared across accounts is the fastest way to get all of them flagged.
  • Three secrets. SECRET_KEY and ENCRYPTION_KEY (32+ characters each, any random string — ENCRYPTION_KEY is SHA-256'd into a Fernet key) and NEXTAUTH_SECRET for the dashboard.
  • Somewhere off Fly to store `ENCRYPTION_KEY`. fly secrets is write-only. You cannot read a secret back out, and that string is the only thing that decrypts the stored account passwords in your database. There is no reset flow and no escrow.

Install flyctl, then sign in. The binary is fly.

bash
fly auth login

Step one — create the apps and the volume

Create the backend app, the dashboard app, and exactly one volume in the region you want.

bash
fly apps create onlyfans-api
fly apps create onlyfans-api-web

fly volumes create onlyapi_data --size 10 --region fra --app onlyfans-api

fra is Frankfurt. Pick a region close to you, not close to OnlyFans — platform traffic egresses through each account's proxy anyway, so the round trip that matters is browser to dashboard. Fly currently lists eighteen regions.

Create one volume. Not one per Machine, not one per region.

Step two — set the secrets

bash
fly secrets set --app onlyfans-api \
    SECRET_KEY="$(openssl rand -base64 48 | tr -d '\n=')" \
    ENCRYPTION_KEY="$(openssl rand -base64 48 | tr -d '\n=')" \
    TWOCAPTCHA_API_KEY="your-2captcha-key"

fly secrets set --app onlyfans-api-web \
    NEXTAUTH_SECRET="$(openssl rand -base64 48 | tr -d '\n=')"

Before you press enter on the first command, print ENCRYPTION_KEY and put it in your password manager. This is the only moment it exists in a readable form.

Step three — the settings in fly.toml that carry weight

The committed config pins several values that look like defaults and are not.

  • GUNICORN_WORKERS = "1" is stated explicitly in [env] so nobody raises it from the Fly dashboard. The app refuses to boot on any other value, but a clear error beats a confusing one.
  • Every writable path resolves inside the mount: DATABASE_PATH, SCHEDULER_JOBSTORE_PATH, EXPORTS_DIR, OAUTH_KEYS_DIR and OF_RATE_STATE_DIR all point at /data.
  • [[mounts]] sets source = "onlyapi_data" and destination = "/data". The source must match the volume name you created.
  • The health check points at /health, deliberately not /ready. /ready returns 503 whenever the signer or scheduler is degraded, and pointing a restart check at it would kill the Machine at exactly the moment you want to read its logs.

The mount destination deserves its own paragraph, because getting it wrong is silent. The backend container runs with `/data` as its working directory, and that is load-bearing: multi_tenant_auth.py builds session paths as the relative string saved_sessions/<crm_id>/<of_user_id>.json with no environment override, so it resolves against the process CWD. Mount the volume elsewhere and every connected account's session lands on the container filesystem, which is discarded on the next deploy. You will see no error — only every account asking to log in again after a restart.

Step four — deploy the backend

bash
fly deploy ./onlyfans-api --config oss-packaging/fly.toml \
           --dockerfile oss-packaging/Dockerfile

The trailing path sets the Docker build context. Fly otherwise uses the directory holding the config file, which is not where the backend source lives in the staging layout; in the extracted open-source repo, where the backend sits at the repository root, this collapses to a plain fly deploy.

Builds run on a Fly remote builder by default, so your laptop's RAM is irrelevant to the build. Check it came up:

bash
fly status --app onlyfans-api
fly logs --app onlyfans-api

You want one Machine, state started, and a log line reporting one worker and 32 threads with an in-process scheduler.

Step five — deploy the dashboard

bash
fly deploy ./xcelerate-company-page --config oss-packaging/fly.web.toml \
           --dockerfile oss-packaging/Dockerfile.web \
           --build-arg NEXT_PUBLIC_SITE_URL=https://onlyfans-api-web.fly.dev \
           --build-arg NEXT_PUBLIC_APP_URL=https://onlyfans-api-web.fly.dev \
           --build-arg NEXT_PUBLIC_API_URL=https://onlyfans-api.fly.dev

The --build-arg flags are not decoration. Every NEXT_PUBLIC_* value is inlined into the browser bundle by next build, so setting them as Fly secrets or [env] afterwards changes nothing for client-side code. If the deployed dashboard shows the wrong API URL, you rebuild — you do not edit config.

The dashboard reaches the backend over Fly's private network:

toml
BACKEND_URL = "http://onlyfans-api.internal:5000"
NEXTAUTH_URL = "https://onlyfans-api-web.fly.dev"

That means the backend never has to be publicly reachable for the dashboard to work. Two caveats: Fly's private network is IPv6-only, and .internal records only exist for started Machines — a second reason autostop must stay off. NEXTAUTH_URL must be this app's own public URL or sign-in callbacks break.

Step six — your own domain

*.fly.dev certificates are automatic. For your own hostname:

bash
fly certs add crm.example.com --app onlyfans-api-web

Fly prints the DNS records it needs. Create them, wait for propagation, then update NEXTAUTH_URL and redeploy the dashboard with the NEXT_PUBLIC_* build args pointing at the new hostname. Both have to change together — NEXTAUTH_URL is read at request time, the public values are baked in at build time.

First run and your first account

Open the dashboard and create your account. The first sign-up creates a user, provisions a CRM panel for it, and issues that panel's API key — that panel is your tenant, and everything you connect afterwards is isolated to it.

Then connect an OnlyFans or Fansly account from the Accounts page, and give it its proxy at the same time. The proxy is stored with the account and sent as X-Proxy on every platform call it makes. Expect the login itself to take 20 to 30 seconds: it runs a Cloudflare init and a paid Turnstile solve before it gets anywhere near a session. If the account has 2FA, the flow pauses for the code.

Polling is off until you turn it on, and is enabled automatically at a five-minute interval when an account connects on a paid tier. Sixty seconds is the floor.

Backups

Fly's daily volume snapshots cover the disk. They do not cover the thing that makes the disk useful.

`ENCRYPTION_KEY` first. A snapshot of the volume without the key restores a database of ciphertext you cannot read. Every stored account password and bot token is Fernet-encrypted under a key derived from that string, and there is no way back from the ciphertext. Store it outside Fly. Treat rotating it as a data migration, not a config change.

Then the volume. List and restore snapshots with flyctl:

bash
fly volumes snapshots list <volume-id> --app onlyfans-api
fly volumes create onlyapi_data_restored --snapshot-id <id> \
    --region fra --app onlyfans-api

For an off-platform copy, pull the database and session directory down over SSH:

bash
fly ssh sftp get /data/crm_data.db ./crm_data.db --app onlyfans-api

Do that on a schedule you actually keep. A managed platform holding your only copy partly undoes the reason you are self-hosting.

Upgrades

Pull, then redeploy the app that changed. Schema changes are additive and applied on startup, and the volume is untouched.

bash
git pull
fly deploy ./onlyfans-api --config oss-packaging/fly.toml \
           --dockerfile oss-packaging/Dockerfile

Snapshot the volume first anyway.

Troubleshooting

Everything looks fine but nothing updates overnight. The Machine is stopping. Run fly machine list --app onlyfans-api and look at the state — if you find it stopped when no browser is open, autostop is on. This is the single most common way a Fly deployment of this stack appears to work and does not. Fix it in fly.toml and redeploy; do not fix it by opening the dashboard.

Accounts ask to log in again after every deploy. The volume is mounted somewhere other than /data, or the Machine has no volume attached. Session files are written relative to the working directory, so they land on the ephemeral container filesystem and vanish. Check [[mounts]] against the volume name, and confirm with fly ssh console that /data/saved_sessions has content.

The API container exits immediately. A missing or too-short SECRET_KEY (32+), ENCRYPTION_KEY (32+) or TWOCAPTCHA_API_KEY (10+). config.py raises at import, before Flask binds a port, and the logs name which one.

The web build dies with a bare `exit code 137`. That is the kernel's out-of-memory killer, not an application error. On Fly this is rarer than on a small VPS because builds run on a remote builder, but if you have forced a local build on a 4 GB machine, that is the cause — add swap or let Fly build it.

Every signed call starts returning 4xx across every account at once. This is not an account problem and re-logging in will not fix it. The request signer embeds a deobfuscated copy of OnlyFans' own signing module; when OnlyFans ships a new web bundle, that copy goes stale and every signed request is rejected together. /ready will still report the signer as present, because the probe only checks that Node can execute the file. Pull the updated repository and redeploy. While you wait, touch the pause file to stop background jobs hammering upstream with requests that cannot succeed:

bash
fly ssh console --app onlyfans-api -C "touch /data/.signed-jobs-paused"

Live counters never move, but data is correct on reload. Something is buffering the SSE stream. Fly's proxy buffers requests, not responses, so here it usually means a CDN or corporate proxy on the viewer's side. Worth saying plainly: Fly does not document an idle timeout for long-lived HTTP responses. The server writes a keep-alive comment every 15 seconds, which is what stops an idle intermediary reaping the connection.

Where Fly fits

Fly is a good fit if you want a container platform rather than a server — no OS patching, no Docker install, remote builds, a private network between the two apps, and volume snapshots you did not configure. You pay for that twice over: a third party holds your disk, which partly undoes the privacy argument for self-hosting, and managed platforms have acceptable-use policies, several of which are unfriendly to adult-industry tooling and can terminate an account without much warning. Keep off-platform backups.

If you would rather own the box, the Hetzner guide covers a plain VPS and is meaningfully cheaper. If you want the same VPS shape with more familiar tooling and better-documented recovery, the DigitalOcean guide is the middle ground.

And if none of this appeals — if you would rather not own the upgrades, the proxy pool and the pager — the hosted cloud runs the same API and the same dashboard with our operations attached. Both are legitimate choices. The code being public is what makes the choice yours.

Sources · last verified Sep 24, 2026

Machine and volume prices are region-dependent; the figures quoted are Fly's Amsterdam rates as rechecked on 24 September 2026. Fly does not publish a documented idle timeout for long-lived HTTP responses, so this guide deliberately quotes no number for it — the claims circulating in community threads are not in the docs.