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.
| Requirement | Why | On Render |
|---|---|---|
| Exactly one instance | gunicorn.conf.py raises if GUNICORN_WORKERS is not 1 | numInstances: 1; a disk forces it |
| Persistent disk | SQLite holds app data and the scheduler jobstore | disk block, paid instance only |
| Working directory is the persistence root | Session paths are relative to the process CWD | Mount at /data |
| Python and Node in one image | The request signer is a Node subprocess | The shipped Dockerfile does both |
| Hours-long SSE connections | One event stream per open dashboard tab | 100-minute response limit |
| 20-30 second logins | A login runs a paid Turnstile solve | Well inside the limit |
| One proxy per connected account | Each account is bound to its own egress IP | You 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.yamlin it. - A 2captcha API key with credit.
config.pyreadsTWOCAPTCHA_API_KEYat 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:
- 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: 10Two 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:
- key: NEXT_PUBLIC_API_URL
fromService:
type: web
name: onlyfans-api
envVarKey: RENDER_EXTERNAL_URLOn 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/andrate-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
| Symptom | Cause and fix |
|---|---|
| API service exits immediately, before binding a port | A 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 deploy | The 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 startup | Something set it above 1. Set it back |
| Dashboard calls an empty or wrong API URL, and editing the variable changes nothing | NEXT_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 time | Expected. A disk prevents zero-downtime deploys |
| Live counters never move | Check 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 fails | A 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 before | A 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 build | Out of memory during next build. Move the web service to a larger instance type |
| Everything stopped while nobody was watching | A Free instance spun down after 15 idle minutes and took the scheduler with it. Free cannot run this |
| Render refuses to let you add instances | Correct 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.