Blog/Tutorials
Tutorials

Self-Hosting an Open-Source OnlyFans API on Hetzner Cloud: The Complete Walkthrough

A real end-to-end guide to running the open-source OnlyFans and Fansly CRM API on a €6 Hetzner box: provisioning, DNS, TLS, proxies, backups, upgrades, and the six mistakes that break it silently.

J
Jordan H. · Co-Founder & CEO, The Only API·Sep 19, 2026·12 min read·Updated Sep 24, 2026
Self-Hosting an Open-Source OnlyFans API on Hetzner Cloud: The Complete Walkthrough

Self-hosting this stack is not hard. On a fresh Hetzner box it is about forty minutes, and most of that is Docker pulling layers while you make coffee. What makes it worth a proper guide is that roughly six specific details will quietly ruin the install if you get them wrong — and not one of them produces a useful error message.

A lost encryption key. A second worker. A reverse proxy that swallows the event stream. A /api/* route that looks obviously correct and kills login with nothing in any log. This guide walks the whole thing end to end on Hetzner Cloud, a low-cost VPS option where the server and its disk stay under the operator's account.

The honest version of the cost

Start here, because the server is the smallest line on the bill and everyone assumes it is the biggest.

Line itemIndicative monthly costNotes
Hetzner CX23 — 2 vCPU, 4 GB, 40 GB€5.49The current cost-optimized entry plan, DE/FI
Hetzner CAX11 — 2 Arm vCPU, 4 GB, 40 GB€5.99Arm64; the stack builds and runs fine on it
Hetzner CX33 — 4 vCPU, 8 GB, 80 GB€8.49Buy this if you skip the swapfile step
Primary IPv4 address~€0.50Charged separately per IPv4
Residential or mobile proxyVaries by supplier and locationPrice this separately for every connected account
2captcha Turnstile solves$1.45 per 1,000One paid solve per login attempt

Prices checked September 2026, excluding VAT.

Read that table again and notice the proxy row. One dedicated residential or mobile proxy per connected OnlyFans account is mandatory — the login routes require one, and each account keeps its assigned egress IP. Five OnlyFans accounts therefore means five proxies. Supplier prices vary materially by country, traffic allowance and proxy type, so get a written quote rather than relying on an illustrative range. Self-hosting does not avoid this cost.

So be blunt about the arithmetic: below roughly five connected accounts, self-hosting is more expensive than the hosted plan once you count proxies, captcha credits, an hour a month of your own attention, and the evening you will eventually spend restoring a backup. The reason to do it anyway is not price. It is that your creators' DMs and your fans' spending history never sit on anyone else's disk.

What the software actually needs

Five constraints shape every decision in this guide. They are not preferences.

  • One always-on process. Exactly one. gunicorn.conf.py raises a RuntimeError at startup if GUNICORN_WORKERS is anything other than 1, because four things hold authoritative state inside the process: the APScheduler instance, the refresh_state progress store, the sse_hub subscriber registry, and the memory-backed rate limiter. A second process means every poll, every webhook retry and every captcha solve happens twice against the same accounts, and live events reach only some of the open dashboards. Concurrency comes from 32 threads, not from workers.
  • A persistent disk. SQLite holds the application data and the scheduler jobstore, in the same file tree. There is no external database to point at.
  • The working directory is the persistence root. multi_tenant_auth.py builds session paths as the relative string saved_sessions/<crm_id>/<of_user_id>.json, with no environment override. The container runs with /data as its working directory precisely so that resolves inside the volume. Change the working directory and every connected account's session disappears on the next restart.
  • Python and Node in the same image. header_generator.py shells out to node onlyfans-sign-generator.js for every signed request. No Node on PATH means every platform call fails. This rules out every Python-only base image and buildpack.
  • Long-lived HTTP. The dashboard holds one Server-Sent Events connection open for the life of the tab, and a fresh login runs a Cloudflare init plus a paid Turnstile solve and measures 20–30 seconds.

Step one: provision the server

Sign in at console.hetzner.cloud, create a project, and choose Add server. The server creation docs cover the console in detail; the choices that matter here are:

  • Location. Falkenstein, Nuremberg or Helsinki if EU data residency is part of what you are selling to your creators. Hillsboro or Ashburn if your team is in the US. It does not affect platform traffic, which egresses through your per-account proxies regardless.
  • Image. Ubuntu 24.04. The installer is written and tested against 22.04 and 24.04, and warns loudly on anything else.
  • Type. CX23 (2 vCPU / 4 GB) is enough. CX33 is more comfortable during builds. Arm CAX plans work — the installer accepts x86_64 and aarch64 and dies on anything else.
  • SSH key. Add it now. Hetzner cannot attach one through the console after the server exists.

Under Firewalls, create one that allows inbound 22, 80 and 443 and nothing else — the firewall docs walk through it. The compose file publishes ports 5000 and 3000 on the host by default; a cloud firewall in front means those are not reachable from the internet even before you tighten the port bindings.

Step two: DNS, before anything else

Create an A record for the hostname you will use — crm.example.com — pointing at the server's IPv4 address, and let it propagate. Caddy cannot issue a Let's Encrypt certificate until that name resolves to this box. Running the installer first and adding DNS afterwards means watching certificate failures scroll past and re-running the whole thing.

If you also want to call the REST API from scripts or an MCP client, add a second A record now, api.example.com. It is optional. If you only ever open the dashboard, you do not need it.

Step three: run the installer

Get a 2captcha key before you start. 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 even reach the point of binding a port. CapSolver and anti-captcha are functional competitors, but the client in captcha_solver.py speaks the 2captcha API.

Then, as root:

bash
git clone https://github.com/xcelerate-management/onlyfans-api-open-source.git
cd onlyfans-api-open-source
sudo ./install.sh

It will check the OS and architecture, add a 4 GB swapfile if the box has under about 6 GB of RAM, install Docker Engine and the compose plugin, ask for your domain and your captcha key, generate SECRET_KEY, ENCRYPTION_KEY and NEXTAUTH_SECRET, write .env at mode 600, open 80 and 443 if ufw is active, and bring up the stack with Caddy terminating TLS.

That swapfile step is not decoration. next build is the memory-hungry part of the build, and on a 4 GB box with no swap it gets OOM-killed part way through. The failure surfaces as a bare exit code 137 with no explanation attached.

For an unattended run:

bash
sudo APP_DOMAIN=crm.example.com TWOCAPTCHA_API_KEY=xxxx ./install.sh

Re-running it is safe. Every step checks whether it has already been done, and an existing .env is never rewritten, merged or touched — because that file holds ENCRYPTION_KEY.

Back up ENCRYPTION_KEY now, not later

Stored platform passwords and custom bot tokens are Fernet-encrypted. The key is derived by SHA-256 over your ENCRYPTION_KEY string in crm_database.get_encryption_key, so that exact string is the only thing in the universe that decrypts them. There is no reset flow, no escrow, and no way to derive it back from the ciphertext.

Copy .env off the server the moment the installer finishes — a password manager entry is fine, the server it runs on is not. Treat rotating ENCRYPTION_KEY as a data migration, not a config change.

Step four: what Caddy is doing, and the route you must never add

The shipped Caddyfile exists for two reasons, both of which will bite you with any other proxy.

It never buffers. flush_interval -1 plus read_timeout 0 and write_timeout 0 on the transport. The dashboard's SSE stream is a response body that stays open for hours; any buffering in the path means the browser receives nothing until the buffer fills, which for a low-volume event stream can be never. The UI simply stops updating, with no error anywhere. The nginx equivalent is proxy_buffering off; plus proxy_read_timeout 0;.

It waits. response_header_timeout 300s, matched to gunicorn's own 300-second timeout so neither side gives up before the other. A login that runs a Turnstile solve takes 20–30 seconds; a proxy with a 60-second default will eventually cut something important.

Now the mistake that looks most reasonable and breaks the most: never point the dashboard hostname's /api routes at Flask. That whole path belongs to Next.js. /api/auth/* is NextAuth. /api/crm/[...path] and /api/events/stream are server-side proxies that attach the X-API-Key from the signed JWT — which is the entire reason the SSE proxy exists, since an EventSource cannot set headers. The browser never talks to Flask directly; Next.js reaches it over the private compose network at http://api:5000.

Hijack /api/* at the proxy and NextAuth's own endpoints get routed into Flask, which knows nothing about them. The symptom is "login does nothing" and an empty dashboard. Flask is exposed two other ways instead: on its own hostname via API_DOMAIN, or under /flask/* on the dashboard hostname. If you only use the dashboard, you need neither.

Step five: claim the owner account, then close the door

Open https://crm.example.com and create your account. That calls POST /api/auth/register, which mints your crm_id and API key and creates the panel.

Do it immediately, and understand why. That endpoint is unauthenticated by design, gated behind a Cloudflare Turnstile check — and turnstile_verify.verify() fails open when TURNSTILE_SECRET_KEY is unset, which is exactly the state a fresh install is in. The reasoning is sound (a fresh clone or a CI run must not have its signup flow silently bricked) but the consequence on a public box is real: until you configure it, anyone who finds your hostname can mint a panel on your server.

Two ways to close it, pick either:

  • Set TURNSTILE_SECRET_KEY and NEXT_PUBLIC_TURNSTILE_SITE_KEY to a Cloudflare Turnstile pair, then docker compose build web && docker compose up -d — the public half is compiled into the browser bundle, so a rebuild is required.
  • Or leave registration unconfigured and put the hostname behind the Hetzner cloud firewall, restricted to your team's IPs. Crude, effective, free.

Never set CAPTCHA_BYPASS_TOKEN on a box reachable from the internet.

Step six: connect an account with its own proxy

In the dashboard, go to Accounts, add an account, and supply its proxy. The format is http://user:pass@host:port or socks5://user:pass@host:port. There is a PROXY_URL fallback in .env for accounts with none of their own, but in practice you want one residential or mobile proxy per account, set per account in the UI.

Saved proxies pointing at loopback or private addresses are rejected — that check stops a tenant turning your server into their port scanner. The one legitimate exception is a reverse SSH tunnel egressing an account's traffic from an operator's home IP, which is the only way to complete an OnlyFans face check; whitelist that exact host:port in PROXY_ALLOW_ENDPOINTS.

The first login is the slow one. Cloudflare init, a paid Turnstile solve, sometimes an OTP. Twenty to thirty seconds is normal. After that the account appears in the switcher and the poller picks it up.

Backups: exactly what to copy

Two things, and they are not in the same place.

One: the .env file, off the server. It holds ENCRYPTION_KEY. Without it a restored database is a pile of undecryptable ciphertext.

Two: the api_data volume. It is the installation:

/data/crm_data.db          accounts, fans, events, automations, webhooks
/data/scheduler_jobs.db    APScheduler jobstore — jobs survive restarts
/data/saved_sessions/      one JSON session file per connected account
/data/oauth_keys/          OAuth 2.1 signing keys
/data/exports/             generated "download your data" ZIPs
/data/rate-budgets/        upstream rate-limit state

Find the volume's real name with docker volume ls — Compose prefixes it with the project directory name — then archive it:

bash
docker volume ls
docker run --rm -v onlyfans-api_api_data:/data -v "$PWD:/backup" alpine tar czf /backup/onlyapi-backup-$(date +%F).tar.gz -C /data .

Put that on a cron job and ship the tarball somewhere else. Hetzner's own snapshot and backup options work too and cost a fraction of the server; use both if the data matters, since a snapshot of a live SQLite file is not a guaranteed-consistent backup on its own.

Upgrades

bash
cd onlyfans-api
git pull
docker compose build
docker compose up -d

The volume is untouched. Schema changes are additive and applied by _ensure_column in init_database() at startup. Back up first anyway.

One trap: NEXT_PUBLIC_* values are inlined into the browser bundle at next build time. Editing them in .env after the image is built changes nothing for client code. If the dashboard shows the wrong API URL, run docker compose build web.

Troubleshooting

SymptomWhat it actually is
API container exits immediatelyA missing or too-short SECRET_KEY (32+), ENCRYPTION_KEY (32+) or TWOCAPTCHA_API_KEY (10+). config.py raises at import, before Flask binds. docker compose logs api names which
exit code 137 during the web buildOut of memory during next build. Add swap, or move to a CX33
Every signed platform call starts returning 4xxA stale signing revision. X_OF_REV and APP_TOKEN track OnlyFans' deployed frontend build and go stale when OF ships. Pull the latest release, or set both in .env and restart the api container. /health stays green throughout, which is why this one confuses people
Dashboard loads, live counters never moveSSE is being buffered somewhere in the path. Check for a CDN, a compressor, or a second proxy you forgot about
Login hangs then failsA proxy timeout below 300s, or a 2captcha balance of zero
/ready returns 503The signer or the scheduler is unavailable; the body names which. /health still answers — that split is deliberate so a degraded container is not restart-looped out from under you
RuntimeError: GUNICORN_WORKERS=…You raised the worker count. Put it back to 1
Login silently does nothingYou added an /api/* route to Flask. Remove it

Should you actually do this?

If you are running one or two creators, no. The server is cheap but your time is not, and the proxy and captcha bills are identical either way. The hosted cloud is the same software with the upgrades, the proxy pool and the 3 a.m. pager attached, and most teams pick it for exactly that reason.

If you are running a book of accounts, have opinions about where your fans' spending history lives, or have lost a vendor before — a €6 Hetzner box, a cloud firewall, an off-site copy of .env and a weekly tarball is a genuinely solid operation. The open-source overview covers what ships, what the AGPL-3.0 licence asks of you, and every platform that will and will not run this.

Either way, back up ENCRYPTION_KEY today.

Sources · last verified Sep 24, 2026

Every price below is indicative, excludes VAT, and was rechecked against Hetzner's official pricing notice on 24 September 2026. Hetzner repriced its cloud range in June 2026, so older articles may be stale. Confirm the current number in the Hetzner Console before you budget.