Blog/Tutorials
Tutorials

Self-Hosting the OnlyFans API on a DigitalOcean Droplet: Full Walkthrough

Run the open-source OnlyFans and Fansly CRM API on a DigitalOcean Droplet with Docker, Caddy and automatic TLS. Real prices, the reverse-proxy rule that breaks login, and what the extra cost over Hetzner actually buys.

J
Jordan H. · Co-Founder & CEO, The Only API·Sep 19, 2026·12 min read·Updated Sep 24, 2026
Self-Hosting the OnlyFans API on a DigitalOcean Droplet: Full Walkthrough

A DigitalOcean Droplet is a plain Ubuntu server with a public IP, and that is the entire appeal: root, a disk that survives reboots, and no platform between you and the process. For a stack that needs one always-on scheduler, a persistent SQLite file, Python and Node in the same container, and HTTP connections held open for hours, a plain VPS is the least surprising place to run it.

It is also, for the same specifications, roughly four to five times the price of a European budget host. That is worth saying plainly up front rather than glossing over, because for some teams the extra buys something real and for others it buys nothing at all.

This guide is a full walkthrough: provisioning, DNS, the installer, TLS, your first connected account, backups, upgrades, and the failure modes. DigitalOcean is a trademark of its owner; this guide is descriptive and not affiliated with or endorsed by DigitalOcean.

What you are deploying

Three containers, defined in the repository's docker-compose.yml.

  • `api` — Flask, an APScheduler event engine that polls connected accounts, a webhook queue with retries, an automation rule evaluator and a Server-Sent Events hub. It owns the SQLite database and one saved session file per account.
  • `web` — the Next.js dashboard. Stateless; it proxies to api over the private compose network.
  • `caddy` — an optional, profile-gated reverse proxy with automatic Let's Encrypt TLS. The installer enables it.

The backend image ships Python and Node 20. header_generator.py shells out to node onlyfans-sign-generator.js for every signed OnlyFans request, so with no node on PATH the product does nothing. That rules out every Python-only buildpack and every serverless runtime.

One rule underpins all of it: never run more than one `api` container. gunicorn.conf.py raises a RuntimeError at startup if GUNICORN_WORKERS is anything but 1, because the scheduler, the refresh_state progress store, the sse_hub subscriber registry and a memory-backed rate limiter all hold authoritative state in-process. Two instances 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 one of them. Throughput comes from threads instead — 32 in a single worker.

What it costs, honestly

DigitalOcean Basic (shared CPU) Droplets, Regular tier, checked September 2026:

DropletvCPURAMSSDTransferPrice
Basic 2 GB12 GB50 GB2 TB$12/mo
Basic 2 GB22 GB60 GB3 TB$18/mo
Basic 4 GB24 GB80 GB4 TB$24/mo
Basic 8 GB48 GB160 GB5 TB$48/mo

The 4 GB / 2 vCPU Droplet at $24/month is the right starting size. Billing is per second with a 60-second minimum, extra outbound transfer is $0.01 per GiB pooled across your team, and Cloud Firewalls are free. Automated backups cost 20% of the Droplet price weekly or 30% daily; snapshots are $0.06 per GB per month. Starter support — email, under 24 hours — is included on every account, with paid tiers from $24/month.

Now the comparison the marketing pages will not draw for you. Hetzner's CX23 is the same 2 vCPU / 4 GB shape at €5.49/month excluding VAT and IPv4, with 20 TB of traffic, after Hetzner's June 2026 price increase. Against DigitalOcean's $24 that is roughly 3.5 to 4 times cheaper, with half the disk and five times the traffic. Step up a size and the gap widens: DigitalOcean's 8 GB / 4 vCPU is $48 against Hetzner's CX33 at €8.49, close to five times.

So what does the difference buy? Three things.

  • Documentation and recovery paths. DigitalOcean's docs and tutorials are the best in the budget-VPS market, and when something breaks at 2 a.m. the answer is usually a first-party article rather than a forum thread. If you have never run a Linux server, that is not a trivial benefit.
  • Regions. Sixteen datacentres across thirteen regions — North America, Europe, India, Singapore, Australia — against Hetzner's Germany, Finland and a smaller US footprint. If you need Sydney latency, the comparison stops being about price.
  • Ecosystem. doctl, Terraform providers, snapshots, reserved IPs, free Cloud Firewalls, and a support ticket that gets answered without a paid plan.

If none of those matter to you, the Hetzner guide is the cheaper deployment of an identical stack, and we will not pretend otherwise.

Whichever you pick, note what actually dominates the bill: one dedicated residential or mobile proxy per connected account is required here exactly as it is on the hosted cloud, and proxies usually cost more per month than the server.

Before you start

  • 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. CapSolver and anti-captcha are functional competitors, but the shipped client speaks the 2captcha API.
  • One proxy per account. Residential or mobile. A datacentre IP shared across accounts gets them all flagged together.
  • A domain with an A record you can edit. Caddy cannot issue a certificate until DNS resolves here.
  • Somewhere off the server for `ENCRYPTION_KEY`. The most important paragraph in this guide is below.

Step one — provision the Droplet

From the control panel, or with doctl. Confirm the current size slug first — the list command is authoritative:

bash
doctl compute size list --format Slug,PriceMonthly

doctl compute droplet create onlyapi \
    --size s-2vcpu-4gb \
    --image ubuntu-24-04-x64 \
    --region fra1 \
    --ssh-keys <your-key-fingerprint>

Ubuntu 24.04 LTS is the tested target and the installer also accepts 22.04. DigitalOcean lists 26.04 as well, but it is outside the script's tested path — it will warn and continue rather than being guaranteed.

Pick a region close to you, not close to OnlyFans: platform traffic egresses through each account's proxy regardless, so the latency that matters is browser-to-dashboard.

On sizing, RAM and CPU resizes are reversible but growing the disk is permanent and cannot be undone. Start at 4 GB rather than over-provisioning disk you can never give back.

Step two — lock the box down first

Create a non-root user, add your key, and close everything but SSH, HTTP and HTTPS. A DigitalOcean Cloud Firewall does this at the network edge for free and ufw does it on the host — use both.

bash
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable

Do not open port 5000 to the internet. Flask does not need to be publicly reachable for the dashboard to work — it is reached over the private Docker network.

Step three — point DNS at it

Create an A record for crm.example.com pointing at the Droplet's IPv4 address and wait for it to resolve. Caddy requests a certificate during startup, and a request that arrives before DNS propagates fails and backs off.

If you also want to call the REST API from scripts or an MCP client, create a second A record for api.example.com now and set API_DOMAIN later. It is optional.

Step four — run the installer

bash
git clone <your-repo> && cd <your-repo>/oss-packaging
sudo ./install.sh

It is safe to re-run; every step checks whether it has already happened. It sanity-checks the OS and memory, adds a 4 GB swapfile on any box under about 6 GB, installs Docker, asks for your domain and 2captcha key, generates the three secrets, writes .env at mode 600, opens 80 and 443 in ufw, then builds and starts api, web and Caddy. Unattended, if you prefer:

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

The swapfile step matters more than it looks. next build is the memory-hungry part of the first build, and on a 4 GB box without swap it gets OOM-killed part way through. The failure surfaces as a bare exit code 137 with no explanation attached — that is the kernel's OOM killer, not an application error. The first build compiles the entire dashboard and takes 5 to 15 minutes on a 2-vCPU box; later builds reuse the layer cache.

The installer refuses to touch an existing .env. That is deliberate: overwriting it would replace ENCRYPTION_KEY and make every stored password in the database permanently unreadable.

The reverse-proxy rule that silently breaks login

This is the mistake that looks most reasonable and costs the most time.

It is tempting to route crm.example.com/api/* straight to the Flask container. Do not. On the dashboard's hostname, /api/* belongs to Next.js:

  • /api/auth/* is NextAuth — sign-in, session, callbacks.
  • /api/crm/[...path] is a server-side proxy that attaches the X-API-Key from the signed JWT, so the browser never holds the key.
  • /api/events/stream is the SSE proxy, for the same reason: an EventSource cannot set an X-API-Key header, so Next.js adds it server-side.

The browser never talks to Flask directly. Route /api/* at the proxy and you send NextAuth's own endpoints into a Flask app that knows nothing about them. The symptom is that login does nothing and the dashboard stays empty, with no useful error anywhere.

The shipped Caddyfile gets this right. Flask is exposed two other optional ways — a dedicated hostname at {$API_DOMAIN}, or a path prefix at {$APP_DOMAIN}/flask/* that needs no extra DNS record. If you only use the dashboard, you need neither. The same file also tunes two things you would otherwise discover the hard way:

flush_interval -1

transport http {
    dial_timeout 10s
    response_header_timeout 300s
    read_timeout 0
    write_timeout 0
}

flush_interval -1 means flush immediately after every write, and it is what keeps Server-Sent Events working. The dashboard holds one SSE connection open for the life of the tab and the server writes a keep-alive comment every 15 seconds. With any buffering in the path the browser receives nothing until the buffer fills — which for a low-volume stream can be never, and the UI simply stops updating with no error anywhere. The zero read and write timeouts govern the body, and an SSE stream is a body that stays open for hours; any finite value caps how long a tab keeps receiving events.

response_header_timeout 300s covers the other end: a fresh login runs a Cloudflare init plus a paid Turnstile solve and measures 20 to 30 seconds. Gunicorn allows 300, and the proxy must not give up first. There is also no encode directive, on purpose — a blanket compressor in front of a text/event-stream response reintroduces exactly the buffering flush_interval exists to prevent. If you swap Caddy for nginx, the equivalents are proxy_buffering off; and proxy_read_timeout 0;.

First run and your first account

Open https://crm.example.com and create your account. The first sign-up creates a user, provisions a CRM panel and issues that panel's API key. That panel is your tenant.

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. The login takes 20 to 30 seconds and pauses for a code if the account has 2FA; there is also a cookie-based path if you would rather hand over a sess cookie than credentials.

Polling is off until you enable it, and switches on automatically at a five-minute interval when an account connects on a paid tier. The floor is 60 seconds, and an account whose polls keep failing is paused after five consecutive failures.

Backups

`ENCRYPTION_KEY` above everything else. Stored account passwords and bot tokens are Fernet-encrypted under a key derived by SHA-256 from that exact string. There is no reset flow, no escrow, and no way back from the ciphertext — a perfect backup of the volume without the key restores a database you cannot read. Copy .env off this server today, before you connect anything.

Then the data. One named volume holds the whole installation — the SQLite database, the APScheduler jobstore, saved sessions, OAuth signing keys, exports and rate-limit state:

bash
docker run --rm -v oss-packaging_api_data:/data -v "$PWD:/backup" \
    alpine tar czf /backup/onlyapi-backup-$(date +%F).tar.gz -C /data .

Ship that tarball off the Droplet — object storage, another provider, anywhere that is not the machine it came from. DigitalOcean's automated backups and snapshots are a useful second layer for whole-machine recovery, but a backup living only in the same account as the server is one compromised login from being no backup at all.

Upgrades

bash
cd oss-packaging
git pull
docker compose build
docker compose up -d

The volume is untouched and schema changes are additive, applied on startup. Take the tarball first anyway. One catch: NEXT_PUBLIC_* values are compiled into the browser bundle at build time, so if you change a public URL you must run docker compose build web — editing .env alone does nothing.

Troubleshooting

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 docker compose logs api names the one at fault.

`RuntimeError: GUNICORN_WORKERS=...` at startup. You raised the worker count. Do not — the process holds the scheduler, the SSE hub, the refresh state store and the rate limiter in memory.

The web build dies with `exit code 137`. Out of memory during next build. There is no stack trace because the kernel killed the process. Add swap; the installer does this on any box under about 6 GB.

Accounts ask to log in again after a restart. The api_data volume is not mounted, or the container is running with a different working directory. Session files are written as the relative path saved_sessions/<crm_id>/<of_user_id>.json with no environment override, so they resolve against the process CWD — which is why the image sets /data as its working directory. Anywhere else and they land on the container filesystem and vanish on the next rebuild.

Live counters never move. Something is buffering SSE. Check that no extra proxy, CDN or compressor sits in front of Caddy, and that you have not added an encode directive.

Login hangs, then fails. Either a proxy timeout below 300 seconds, or a 2captcha balance of zero.

`/ready` returns 503 while `/health` still answers. The signer or the scheduler is unavailable and the body says which. That split is deliberate, so an orchestrator does not restart-loop a degraded container while you are reading its logs.

Every signed call starts returning 4xx across every account at once. Not an account problem, and re-logging in will not help. 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 still reports the signer as present, because the probe only checks that Node can execute the file. Pull the updated repository and rebuild. Meanwhile, stop background jobs hammering upstream with requests that cannot succeed:

bash
docker compose exec api touch /data/.signed-jobs-paused

Is this the right choice for you?

A Droplet is the right answer if you want the familiarity and the documentation, if you need a region Hetzner does not serve, or if your team already speaks doctl and Terraform. It is the wrong answer if cost is all you are optimising for — you are paying roughly four times a European budget host for the same box, and the software does not care which one it runs on.

What it is genuinely good at is being boring. One server, one disk, one docker compose up, root when you need it, and no platform deciding on your behalf that an idle service should be suspended. For an application whose whole job is to keep running when nobody is watching, boring is the feature.

If you would rather not run any of this — the upgrades, the proxy pool, the backup rotation, the 3 a.m. page — the hosted cloud runs the same API and the same dashboard with our operations attached. That is a real trade, not a lesser one. Publishing the code is what makes the choice yours.

Sources · last verified Sep 24, 2026

The standard Droplet figures and billing rules were rechecked against DigitalOcean's official pricing and documentation on 24 September 2026. No Premium Intel or Premium AMD figures are quoted. Hetzner prices are the post-15-June-2026 rates excluding VAT and IPv4; the euro-to-dollar comparison is approximate and ours, not the vendor's. Recheck both vendors before budgeting.