Railway is the shortest path from a Git repository to a running container with a disk attached. If you want to self-host the open-source OnlyFans and Fansly CRM API without learning systemd, firewalls or Let's Encrypt, it will get you there in about half an hour — and it is also the option that gives you back the least of what self-hosting was for.
Read this part before the tutorial
The reason to self-host is that your creators' messages, your fans' lifetime spend and your subscriber book live on a disk you control. A managed platform moves that disk onto someone else's infrastructure. You still hold the encryption key and there is still no telemetry, but a third party now has custody of the database and the ability to switch the service off. That is a smaller win than a VPS.
There is a second, more concrete risk. Railway's Terms of Service prohibit content that is, among other things, obscene or otherwise objectionable, and its Acceptable Use Policy prohibits operating proxies or anonymization services and running bots or scrapers that violate applicable terms of service. None of those clauses names adult-industry tooling, and none is obviously aimed at a CRM a creator runs against their own account. But they are broad, the judgement is Railway's, and there are public help-station threads where accounts hosting explicit content were suspended on a single report.
Treat that as a risk assessment, not a warning off. Practically: keep off-platform backups, keep ENCRYPTION_KEY somewhere that is not Railway, and know 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).
What the stack demands
Architectural facts, not preferences. Each was checked against the code.
| Requirement | Why | Railway |
|---|---|---|
| Exactly one always-on process | gunicorn.conf.py raises at startup if GUNICORN_WORKERS is not 1 | One replica, serverless off |
| Persistent disk | SQLite holds app data and the APScheduler jobstore | A volume, mounted at /data |
| Python and Node in one image | The request signer is a Node subprocess | The shipped Dockerfile does both |
| Long-lived HTTP for SSE | One event stream per open dashboard tab | Works, with a 15-minute cap |
| Request tolerance in minutes | A login runs a Turnstile solve and takes 20-30 seconds | Fine |
| One proxy per connected account | Each account is bound to its own egress IP | You supply these |
The single-instance rule is the one people try to work around. 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://. Run two replicas and every poll fires twice against the same account, every webhook retries twice, live events reach half your open dashboards and every rate limit is twice as loose. Railway enforces the rule anyway — replicas cannot be used with volumes.
Prerequisites
- A Railway account on Hobby or Pro. Free caps volumes at 0.5 GB with 1 vCPU and 0.5 GB of RAM, which is not enough. Hobby is $5/month including $5 of usage credits and allows a 5 GB volume; Pro is $20/month per workspace including $20 of credits and lets you self-serve a volume up to 1 TB.
- Your fork of the open-source repository on GitHub.
- A 2captcha API key with credit.
config.pyreadsTWOCAPTCHA_API_KEYat import and raises without it, so the container will not boot. - One dedicated residential or mobile proxy per account you intend to connect — required on self-hosted installs exactly as on the hosted cloud.
- A domain you control, if you want your own hostname.
Railway bills metered per second on top of the plan fee: roughly $20 per vCPU per month, $10 per GB of RAM, $0.15 per GB of volume storage and $0.05 per GB of egress. All figures checked September 2026. A realistic shape here — API at about 1 vCPU and 1 GB, dashboard at 0.5 vCPU and 1 GB, a 5 GB volume — is $45 to $60 a month before proxies. A Hetzner CX23 running the same two containers is about €5.49 (checked September 2026). You are paying roughly ten times the hardware cost for a deploy button and automatic TLS. Legitimate if your time is worth the difference; not a saving.
Step 1 — Create the API service
In a new Railway project, choose Deploy from GitHub repo and point it at your fork. Then open the service's Settings and set:
- Root Directory to the backend source —
onlyfans-apiin the staging layout; blank in the extracted open-source repo, where the backend sits at the root. Railway only pulls files from this directory into the build, which is also the Docker build context. - A service variable
RAILWAY_DOCKERFILE_PATHpointing at the backend Dockerfile, for exampleoss-packaging/Dockerfile. Railway looks at the root of the source directory otherwise.
The image builds Python 3.11 and Node 20 into one runtime, then runs gunicorn. The signer needs no npm packages — only Node builtins — so there is no npm install in the backend image.
Step 2 — Environment variables
Generate the two secrets locally with openssl rand -base64 48. Do not let a platform generate ENCRYPTION_KEY for you. Then set on the API service:
SECRET_KEY=<generated>
ENCRYPTION_KEY=<generated, and backed up off Railway>
TWOCAPTCHA_API_KEY=<your key>
HOST=0.0.0.0
GUNICORN_WORKERS=1
DATABASE_PATH=/data/crm_data.db
SCHEDULER_JOBSTORE_PATH=/data/scheduler_jobs.db
EXPORTS_DIR=/data/exports
OAUTH_KEYS_DIR=/data/oauth_keys
OF_RATE_STATE_DIR=/data/rate-budgets
LOG_LEVEL=INFORailway injects PORT; gunicorn.conf.py binds HOST:PORT, so you do not set a port yourself.
On `ENCRYPTION_KEY`, one paragraph that matters more than the rest of this guide. Stored account passwords and custom bot tokens are Fernet-encrypted with a key derived by SHA-256 over this string. That exact string is the only thing that decrypts them. There is no reset flow, no escrow and no way back from the ciphertext. Lose it and every connected account has to be re-entered by hand. Put it in a password manager before you paste it into Railway, and treat rotating it as a data migration.
Step 3 — The volume, mounted at /data
Open the command palette with Cmd-K, create a volume, attach it to the API service, and set the mount path.
The mount path must be `/data`, and this is not cosmetic. The backend container's working directory is /data, and multi_tenant_auth.py builds session paths as a relative string — saved_sessions/<crm_id>/<of_user_id>.json — with no environment variable to override it, so it resolves against the process working directory. Mount the volume anywhere else and the database moves but every saved platform session does not. You lose every connected account on the first restart, silently, with no error in the logs.
Everything that must survive lives under that path: crm_data.db, scheduler_jobs.db (the APScheduler jobstore), saved_sessions/, oauth_keys/, exports/ and rate-budgets/.
Two Railway behaviours to know: data written at build time does not persist, even to the mount path, and volumes are not mounted during pre-deploy, so no pre-deploy command may touch /data. Volume storage can be grown later with Live Resize on paid plans, usually without downtime; it cannot be shrunk.
Step 4 — Pin one replica and turn serverless off
Confirm the replica count is 1. Railway blocks replicas on a service with a volume, so this should already be true — check it after any plan change.
Then find Serverless (app sleeping) and make sure it is off. A slept container runs no scheduler: polling stops, webhook retries stop, automations stop, and nobody gets an alert because the thing that would send it is asleep. Railway considers a service inactive after five minutes without outbound packets and sleeps it five to ten minutes later. An active poller generates outbound traffic continuously, so it may never sleep in practice — but a quiet account overnight is exactly the case where it might, which is the worst possible time for the scheduler to stop.
Step 5 — The dashboard service
Add a second service from the same repository. Set its Root Directory to the dashboard source (xcelerate-company-page in the staging layout) and RAILWAY_DOCKERFILE_PATH to oss-packaging/Dockerfile.web.
Here is the gotcha that costs people an afternoon. **NEXT_PUBLIC_* values are inlined into the browser bundle by next build.** They are compiled in, not read at runtime — changing them on a running container does nothing, because the old value is already inside the JavaScript the browser downloads.
Railway does inject service variables at build time, but only into build stages that declare them with ARG. The shipped Dockerfile.web already declares all six NEXT_PUBLIC_* names, so the mechanism works — if the value is set before the build that bakes it in. On a first deploy you usually do not know your own public URL yet, so: deploy once with placeholders, generate the domains in step 6, set the real values, then redeploy.
Set on the dashboard service:
BACKEND_URL=https://<api-service-domain>
NEXTAUTH_SECRET=<openssl rand -base64 48>
NEXTAUTH_URL=https://<dashboard-domain>
NEXT_PUBLIC_SITE_URL=https://<dashboard-domain>
NEXT_PUBLIC_APP_URL=https://<dashboard-domain>
NEXT_PUBLIC_API_URL=https://<api-service-domain>BACKEND_URL is server-side only. 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 the entire reason the SSE proxy exists. Do not route /api/* on the dashboard hostname to Flask — login stops working and the failure looks like a NextAuth bug. If the dashboard build gets OOM-killed the symptom is a bare exit code 137: raise the build resources rather than hunting for a code error.
Step 6 — Custom domain and TLS
Under Settings → Networking on each service, generate a Railway domain first so the services can reach each other, then add your own. Railway gives you a CNAME and a TXT record; both are required. Add them at your DNS provider and wait for verification. SSL certificates are provisioned and renewed automatically and free.
Two hostnames is the clean layout — one for the dashboard, one for the API. Point BACKEND_URL and NEXT_PUBLIC_API_URL at the API hostname, then redeploy the dashboard.
Step 7 — First run and connecting an account
Open the dashboard hostname. The first-run screen creates your owner account; there is no default password, and it is only reachable while no owner exists. Claim it immediately after the first successful deploy.
Then go to Accounts, add an OnlyFans or Fansly account, and supply its dedicated proxy at the same time. A first login runs a Cloudflare init plus a paid Turnstile solve and takes 20-30 seconds — normal, and well inside Railway's request limits. If it hangs and then fails, the usual causes are a 2captcha balance of zero or a proxy the container cannot reach.
The SSE cap you need to know about
Railway applies its HTTP request limits to Server-Sent Events, because SSE is just a long HTTP response: a connection is closed after 5 minutes with no data transferred, and lives up to 15 minutes with keep-alive heartbeats. Checked September 2026.
The idle limit is a non-issue: sse_hub.py sets HEARTBEAT_SECONDS = 15, so a keep-alive comment goes down the wire every fifteen seconds regardless of traffic.
The 15-minute ceiling is real and you will see it. Every open dashboard tab has its event stream cut roughly every quarter of an hour. The client handles it — use-sse.ts reconnects with exponential backoff starting at one second — so you get a brief gap rather than a dead dashboard, plus a steady drip of reconnects in your logs. Against a VPS behind Caddy, where the stream stays open for the life of the tab, it is a genuine drawback. Nothing breaks; it is just less clean.
Backups
Railway supports manual and scheduled volume backups. Scheduled retention is 6 days for daily, 27 for weekly and 89 for monthly. Manual backups are limited to 50% of the volume's size, restores only work within the same project and environment, and wiping a volume deletes all of its backups. That last clause is why Railway's own backups cannot be your only backups. Turn on daily schedules, then also pull a copy off the platform:
- The volume is everything at
/data— database, jobstore, sessions, OAuth keys. ENCRYPTION_KEYis separate, and losing it makes a perfect volume backup useless. Store it somewhere that is not Railway, and not in the same password-manager entry as your Railway login.
An untested backup of an encrypted session store is not a backup. Restore into a scratch project once and confirm a connected account still authenticates.
Upgrades and redeploys
Push to the tracked branch, or trigger a redeploy. Railway builds a new image and swaps the container; the volume is untouched. Schema changes are additive and applied by _ensure_column in init_database() on startup, so there is no migration step — take a backup first anyway. Set watch paths on each service so a dashboard-only change does not rebuild the backend.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
| API container exits immediately, before it binds 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 restart | The volume is not mounted at /data. Session paths are relative to the working directory. Remount and reconnect |
| RuntimeError mentioning GUNICORN_WORKERS at startup | Something set it above 1. Set it back |
| Dashboard shows the wrong API URL, and editing the variable does nothing | NEXT_PUBLIC_* is compiled into the browser bundle at build time. Set the value, then redeploy so the bundle is rebuilt |
| Live counters freeze briefly every quarter of an hour | Railway's 15-minute request cap closing the SSE stream. The client reconnects. Expected here |
| Live counters never move at all | Confirm the dashboard reaches the API over BACKEND_URL, and that /api/* on the dashboard host is served by Next.js rather than proxied to Flask |
| Login hangs then fails after 20-30 seconds | A 2captcha balance of zero, or a proxy the container cannot reach |
| 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 without cache instead of restarting the old container |
| exit code 137 during the dashboard build | Out of memory during next build. Give the build more resources |
| Polling and webhooks silently stopped overnight | The service slept. Turn Serverless off |
The cheaper option, stated honestly
Railway is the right choice if you want a disk, a deploy button and TLS without touching a server, and you accept both the ten-times markup and a third party holding your database. It works, and every constraint above is manageable.
If the reason you are here is privacy or platform risk, a plain VPS is the stronger answer: cheaper, no acceptable-use exposure to a US PaaS, no 15-minute SSE cap, and the disk is yours. The Hetzner guide walks through the same stack on a ~€5.49/mo box with Caddy in front. If you want buttons without giving up the box, Coolify on your own VPS reads the same docker-compose.yml.
And if you would rather not run any of it, the hosted cloud is the same software with our operations attached. Most teams pick it, and that is a reasonable decision rather than a failure of nerve. The code being public is what makes either choice an informed one.