Blog/Tutorials
Tutorials

Self-Hosting an Open-Source OnlyFans and Fansly CRM API on Render

Deploy the open-source OnlyFans and Fansly CRM API to Render from the committed blueprint: persistent disk, one instance, the build-time env var trap, custom domains, backups and the acceptable-use risk.

J
Jordan H. · Co-Founder & CEO, The Only API·Sep 19, 2026·11 min read·Updated Sep 24, 2026
Self-Hosting an Open-Source OnlyFans and Fansly CRM API on Render

Render is the managed platform this project ships a template for. The committed render.yaml blueprint in oss-packaging/ already encodes the two constraints that break most deployments: a persistent disk, and exactly one instance. That makes it the least effort of the managed options — and it is still the tier where you hand a third party your disk, which deserves a straight paragraph before the walkthrough.

The trade you are making

The point of self-hosting is that your creators' DMs, your fans' spend history and your subscriber book sit on a disk you control, under a retention policy you wrote. Render is a real improvement over a closed SaaS panel — no telemetry, no licence check, you hold the encryption key, you can read every line of the code. But Render holds the disk. They can restore it, lose it, or switch the service off. That is a weaker position than a VPS, and anyone telling you otherwise is selling something.

The second consideration is policy. Render's Acceptable Use Policy prohibits hosting or distributing content that is unlawful, abusive, defamatory, hateful or otherwise objectionable, and reserves the right to warn, take down content, or suspend or terminate an account on a suspected violation. That "otherwise objectionable" clause is the one to think about: it is broad, the judgement is Render's, and adult-industry tooling is exactly the grey-area workload a broad clause plus a single report catches. One caveat on my own verification: Render's AUP page is rendered client-side and I could not read its full text programmatically, so that summary comes from Render's own search-indexed description rather than a clause-by-clause quote. Read it at render.com/acceptable-use before you commit a production panel to it.

This is a risk to manage, not a reason to panic. Keep backups off Render, keep ENCRYPTION_KEY somewhere Render cannot reach, and understand that a VPS from a provider that knowingly sells to adult businesses carries far less exposure — the Hetzner walkthrough covers that route on a €5.49/month box (excluding VAT and IPv4, checked September 2026).

Non-negotiables

Checked against the code, not assumed.

RequirementWhyOn Render
Exactly one instancegunicorn.conf.py raises if GUNICORN_WORKERS is not 1numInstances: 1; a disk forces it
Persistent diskSQLite holds app data and the scheduler jobstoredisk block, paid instance only
Working directory is the persistence rootSession paths are relative to the process CWDMount at /data
Python and Node in one imageThe request signer is a Node subprocessThe shipped Dockerfile does both
Hours-long SSE connectionsOne event stream per open dashboard tab100-minute response limit
20-30 second loginsA login runs a paid Turnstile solveWell inside the limit
One proxy per connected accountEach account is bound to its own egress IPYou supply these

On the single-instance rule: four things hold authoritative state inside the process — the APScheduler instance, the refresh_state progress store, the sse_hub subscriber registry, and Flask-Limiter backed by memory://. Two instances means every poll and every webhook retry fires twice against the same account, live events reach half your open tabs, and every rate limit is twice as loose as configured. Render enforces this for you: you cannot scale a service to multiple instances if it has a disk attached.

The free tier cannot run this

Worth stating flatly, because it is the first thing people try. Render spins down a Free web service after 15 minutes without inbound traffic, and a cold start takes about a minute. An idle service runs no scheduler, so polling, webhook retries and automations stop whenever nobody is watching. Free services also cannot have a persistent disk, so the database, the jobstore and every saved session are wiped on every deploy and restart. Free hours are capped at 750 per workspace per month on top of that. The free tier is not a smaller version of this deployment; it cannot work.

Prerequisites and cost

  • A Render account with a payment method — you need a paid instance type for the disk.
  • Your fork of the open-source repository on GitHub, with oss-packaging/render.yaml in it.
  • A 2captcha API key with credit. config.py reads TWOCAPTCHA_API_KEY at import and raises without it.
  • One dedicated residential or mobile proxy per account you will connect.
  • A domain, if you want your own hostname.

Persistent disk storage is $0.25 per GB per month, prorated by the second. Render's official pricing page also listed service compute at $7/month for 512 MB and $25/month for 2 GB with 1 CPU when rechecked on 24 September 2026. Treat every figure as dated and confirm it at render.com/pricing before budgeting.

From the workload rather than the price list: Starter's 512 MB is tight for the API and marginal for next start on the dashboard, so budget for Standard on at least the web service. With a 10 GB disk, a realistic figure is $35 to $55 a month before proxies — against roughly €5.49 for a Hetzner CX23 running both containers. You are paying for managed TLS, a deploy pipeline and not owning the incident.

Step 1 — Deploy the blueprint

Push your fork to GitHub. In the Render dashboard choose New → Blueprint and point it at the repository; Render reads render.yaml and proposes two services. For the API service it sets:

yaml
- type: web
  name: onlyfans-api
  runtime: docker
  dockerfilePath: ./oss-packaging/Dockerfile
  dockerContext: ./onlyfans-api
  plan: starter
  region: frankfurt
  numInstances: 1
  healthCheckPath: /health
  disk:
    name: onlyapi-data
    mountPath: /data
    sizeGB: 10

Two details worth understanding rather than copying blindly. `dockerContext` points at the backend source while `dockerfilePath` points at the packaging directory, because this repository stages the packaging files separately from the source trees; in the extracted open-source repo both collapse to simpler paths, so check them against your own layout first. And the health check is `/health`, deliberately not `/ready`/health answers as soon as the WSGI app is up, while /ready returns 503 whenever the signer or scheduler is degraded, and wiring a restart loop to that kills the container at exactly the moment you need its logs.

Change region to whichever is closest to your proxies. Leave numInstances at 1.

Step 2 — Secrets and environment

The blueprint prompts for the values marked sync: false and generates the rest. SECRET_KEY uses generateValue: true — Render creates it once and keeps it. ENCRYPTION_KEY deliberately does not, and that is the most important line in the blueprint.

Account passwords and custom bot tokens are Fernet-encrypted with a key derived by SHA-256 over ENCRYPTION_KEY. That exact string is the only thing that decrypts them. There is no reset, no escrow, no way back from the ciphertext. If Render generated it, your only copy would live on the platform you are trying not to depend on — so generate it locally with openssl rand -base64 48, store it in a password manager, and paste it in when prompted. Rotating it later is a data migration, not a config change.

The rest need no action: HOST=0.0.0.0, GUNICORN_WORKERS=1, and every state path pointed inside the mount (DATABASE_PATH, SCHEDULER_JOBSTORE_PATH, EXPORTS_DIR, OAUTH_KEYS_DIR, OF_RATE_STATE_DIR). Render injects PORT; gunicorn binds HOST:PORT.

Step 3 — Why the mount path must be /data

The blueprint mounts the disk at /data and the Dockerfile sets WORKDIR /data. Those two facts have to agree.

multi_tenant_auth.py builds session paths as a relative string — saved_sessions/<crm_id>/<of_user_id>.json — with no environment variable for it, so it resolves against the process working directory, which makes that directory the persistence root. Move the mount and the database follows (those paths are explicit env vars) but the session files do not. The failure is quiet: everything looks fine until the first redeploy, at which point every connected account is logged out.

Render's own wording is the thing to internalise: only data written under the mount path is preserved across deploys and restarts. Everything else on the filesystem is thrown away every time.

One consequence of attaching a disk: it prevents zero-downtime deploys. Render stops the existing instance before starting the new one, so every deploy costs a few seconds of unavailability. Fine for a background CRM, but worth knowing. You can grow the disk later; you cannot shrink it.

Step 4 — The dashboard service and the build-time trap

The second service builds the Next.js dashboard from Dockerfile.web. It is stateless and needs no disk. This is also where the blueprint's own comments flag an unproven caveat, and where I can be more precise than they are.

**NEXT_PUBLIC_* values are inlined into the browser bundle by next build.** They are compiled in, not read at runtime. Editing them on a running service changes nothing, because the old value is already inside the JavaScript the browser downloads.

The good news: Render handles this. Its Docker documentation says Render "automatically translates those values to Docker build arguments that are available during your image's build process", and the shipped Dockerfile.web already declares each one with an ARG line. The mechanism is not missing.

The real problem is ordering. The blueprint wires those values with fromService against RENDER_EXTERNAL_URL:

yaml
- key: NEXT_PUBLIC_API_URL
  fromService:
    type: web
    name: onlyfans-api
    envVarKey: RENDER_EXTERNAL_URL

On the very first blueprint apply that URL does not exist yet — the service is being created in the same operation. If it resolves to an empty string at build time, the bundle ships with an empty API URL and the dashboard cannot reach the backend, with a console error that looks nothing like a config problem.

The fix is deterministic: after the first successful deploy, note the two onrender.com URLs (or your custom domains, if added already), replace the fromService blocks for the three NEXT_PUBLIC_* keys with literal values, and trigger Manual Deploy → Clear build cache & deploy. Leave BACKEND_URL and NEXTAUTH_URL as fromService — those are read at runtime and resolve correctly.

One more thing about that hostname. The browser never talks to Flask directly: /api/auth/* is NextAuth, and /api/crm/[...path] and /api/events/stream are server-side proxies that attach your X-API-Key from the signed JWT — an EventSource cannot set request headers, which is why the SSE proxy exists. BACKEND_URL is how those routes reach Flask. Never point /api/* on the dashboard hostname at the API service.

Step 5 — Custom domain and TLS

On each service, open Settings → Custom Domains → + Add Custom Domain and enter the hostname. Add a CNAME at your DNS provider pointing at the service's onrender.com subdomain, remove any AAAA records for that name — Render is IPv4 only — and click Verify. Render creates and renews TLS certificates automatically and redirects HTTP to HTTPS. Do both hostnames together, then run the NEXT_PUBLIC_* rebuild from step 4 with the final URLs.

Step 6 — First run, and connecting an account

Open the dashboard hostname. The first-run screen creates your owner account and is only reachable while no owner exists, so claim it as soon as the first deploy is green.

Then Accounts → add account, choose OnlyFans or Fansly, and supply the account's dedicated proxy at the same time. The first login runs a Cloudflare init plus a paid Turnstile solve and takes 20-30 seconds. Render allows HTTP responses up to 100 minutes, so there is nothing to tune — one of the places Render is genuinely more comfortable than its competitors.

That same limit is what makes SSE pleasant here. sse_hub.py sends a keep-alive comment every 15 seconds and use-sse.ts reconnects with exponential backoff if the stream drops, but on Render the stream survives the better part of two hours before the client has to re-establish it, rather than the quarter-hour some platforms allow. If live counters stop moving anyway, suspect the proxy path or the API URL, not a timeout.

Backups

The disk is the thing to protect; the key is what makes the disk worth anything.

  • The disk holds crm_data.db, scheduler_jobs.db, saved_sessions/, oauth_keys/, exports/ and rate-budgets/, all under /data. Pull a copy off Render on a schedule — the built-in data-export endpoints, a cron job on a background worker, or a shell into the service — and store it somewhere that is not Render.
  • `ENCRYPTION_KEY` lives outside all of that. A perfect disk backup plus a lost key means every connected account must be re-entered by hand. Keep it in a password manager, separate from your Render credentials.

Test a restore once, into a scratch service, and confirm a connected account still authenticates. An untested backup of an encrypted session store is not a backup.

Upgrades and redeploys

Push to the tracked branch and Render builds and swaps the container; the disk is untouched. Schema changes are additive and applied by _ensure_column in init_database() at startup, so there is no migration step — take a backup regardless. Remember the disk removes zero-downtime deploys: expect a few seconds of unavailability on each one, and do not upgrade during a campaign send.

Troubleshooting

SymptomCause and fix
API service exits immediately, before binding a portA missing or too-short SECRET_KEY, ENCRYPTION_KEY or TWOCAPTCHA_API_KEY. config.py raises at import; the deploy log names which one
Every connected account is gone after a deployThe disk is not mounted at /data, or WORKDIR was changed. Session paths resolve against the working directory. Remount and reconnect
RuntimeError mentioning GUNICORN_WORKERS at startupSomething set it above 1. Set it back
Dashboard calls an empty or wrong API URL, and editing the variable changes nothingNEXT_PUBLIC_* is baked in at build time. Replace the fromService blocks with literal URLs, then redeploy with Clear build cache
Deploy succeeds but the service is briefly unreachable every timeExpected. A disk prevents zero-downtime deploys
Live counters never moveCheck that /api/* on the dashboard host is served by Next.js rather than pointed at the API service, and that BACKEND_URL is the API service URL
Login hangs then failsA 2captcha balance of zero, or a proxy the container cannot reach. The 100-minute response limit is not the cause
4xx from OnlyFans right after an upgrade, on calls that worked beforeA stale signing revision. The Node signer is built into the image — rebuild with Clear build cache rather than restarting the old container
exit code 137 during the dashboard buildOut of memory during next build. Move the web service to a larger instance type
Everything stopped while nobody was watchingA Free instance spun down after 15 idle minutes and took the scheduler with it. Free cannot run this
Render refuses to let you add instancesCorrect behaviour. A service with a disk cannot scale horizontally, and neither can this application

Where Render sits

Render is the most complete managed option for this stack: a committed blueprint, a real persistent disk, a 100-minute request limit that makes both logins and SSE comfortable, and platform-level enforcement of the single-instance rule. If you want a managed platform, it is the one to pick.

Be clear about what you are buying. Roughly ten times a Hetzner CX23 for the same two containers, Render holds your disk, and their acceptable-use policy has a broad clause a grey-area workload sits inside. For the strongest privacy and the lowest deplatforming risk a plain VPS wins — the Hetzner guide walks through it with Caddy in front for TLS and SSE-safe proxying. For a deploy UI without giving up the box, Coolify on your own VPS reads the same docker-compose.yml.

And if none of this is how you want to spend your week, the hosted cloud runs the same code with our operations attached. Most teams choose it, and that is a reasonable decision rather than a failure of nerve.

Sources · last verified Sep 24, 2026

Render's official pricing page listed persistent disks at $0.25 per GB per month, 512 MB service compute at $7 per month and 2 GB / 1 CPU service compute at $25 per month when rechecked on 24 September 2026. Prices change; confirm render.com/pricing before budgeting. Render is a trademark of Render Services, Inc; this guide is descriptive use only and implies no endorsement, partnership or affiliation.