The objection to a bare VPS is not that it is hard. It is that ssh and docker compose are a bad interface for a team. Nobody wants to be the only person who can redeploy, and nobody enjoys explaining over Slack which directory the compose file lives in.
Coolify solves that without the usual trade. It is an open-source, self-hosted PaaS that runs on your own server: git-push deploys, a deployment UI, environment variable management, automatic Let's Encrypt certificates, logs and rollbacks — while the box, the disk and the SQLite database stay entirely yours. It consumes the project's docker-compose.yml directly. You get the buttons without handing a vendor your database.
This guide is the full path: server, Coolify, repository, environment, domains, and the proxy settings that decide whether your live event stream works or dies quietly.
What you are actually paying for
| Line item | Indicative monthly cost | Notes |
|---|---|---|
| Coolify, self-hosted | Free | Full feature set, no restrictions |
| Coolify Cloud (optional) | $5 base for 2 servers, +$3 per extra server | Their hosted control plane; your servers stay yours |
| VPS — 4 vCPU / 8 GB (e.g. Hetzner CX33) | €8.49 | 8 GB is the comfortable floor with Coolify on the same box |
| VPS — 2 vCPU / 4 GB (e.g. Hetzner CX23) | €5.49 | Workable, but add swap |
| Residential or mobile proxy | Varies by supplier and location | Mandatory per connected OnlyFans account; quote separately |
| 2captcha Turnstile solves | $1.45 per 1,000 | One paid solve per login attempt |
All checked September 2026, excluding VAT.
Note the sizing difference from a bare VPS install. Coolify itself wants 2 cores, 2 GB RAM and 10 GB of disk as a minimum, and it runs alongside your application on the same machine. Add a Next.js build to that and a 4 GB box is tight. 8 GB is the number to pick if you would rather not think about it.
And the same honesty as everywhere else: below roughly five connected accounts, self-hosting costs more than the hosted plan once proxies, captcha credits and your own hours are counted. The reason to do this is control, not price.
The constraints Coolify has to respect
Before touching the UI, know what cannot be changed, because a PaaS makes two of these very easy to get wrong.
- One API process, total.
gunicorn.conf.pyraises at startup ifGUNICORN_WORKERSis not 1, because the APScheduler instance, therefresh_stateprogress store, thesse_hubsubscriber registry and the memory-backed rate limiter all hold authoritative state in-process. Coolify's scaling controls are right there in the UI. Do not use them on theapiservice. Two replicas means every poll, webhook retry and captcha solve runs twice against the same accounts, and live events reach only half your open dashboards. Thewebservice is stateless and could scale; there is no reason to. - A persistent volume. SQLite holds both the application data and the scheduler jobstore.
- The container's working directory is the persistence root.
multi_tenant_auth.pybuilds session paths as the relative stringsaved_sessions/<crm_id>/<of_user_id>.json, with no environment override. The image sets/dataas the working directory so that lands inside the volume. Do not override it. - Python and Node in one image. The request signer is a Node subprocess invoked by
header_generator.py. This is why there is a single custom Dockerfile and not a Python buildpack. - Long-lived HTTP. One SSE connection per open tab, held for hours, and logins that take 20–30 seconds. This is the part Coolify's default proxy needs help with.
Step one: the server
Any VPS works. Ubuntu 24.04 on a Hetzner CX33, a DigitalOcean droplet, whatever you already trust. Coolify supports Debian, Ubuntu LTS, RHEL-family, SUSE, Arch, Alpine and Raspberry Pi OS on amd64 or arm64.
Before installing, open the ports you need at the cloud firewall: 22 for SSH, 80 and 443 for the proxy, and 8000 for the Coolify dashboard. Restrict 8000 to your team's IPs if your provider's firewall allows it — there is no reason for the control panel to be reachable from the whole internet.
Step two: install Coolify
As root:
curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bashThen open http://your-server-ip:8000 and register. Do that immediately. Registration is open until the first account exists, exactly like every other self-hosted panel. If you would rather not race anyone, Coolify supports pre-creating the admin with ROOT_USERNAME, ROOT_USER_EMAIL and ROOT_USER_PASSWORD environment variables at install time.
Back up /data/coolify/source/.env once you are in. It holds Coolify's own encryption keys, and without it a restored Coolify cannot read its stored credentials. That is a separate backup from the application's — you will have two.
Step three: DNS
Create an A record for crm.example.com pointing at the server, and let it resolve before you deploy. Coolify issues Let's Encrypt certificates automatically, and like every ACME client it cannot do that for a name that does not point here yet.
Add a second record, api.example.com, only if you want to call the REST API from scripts or an MCP client. The dashboard alone needs one hostname.
Step four: add the project as a Docker Compose resource
In Coolify: Projects → New → Docker Compose, then point it at the repository and select the Docker Compose build pack. Coolify parses docker-compose.yml, adds its own proxy labels and networking, and manages the services it finds.
Two settings to get right on the way in:
- Leave the tls compose profile disabled. The shipped Caddy service is profile-gated and off unless you explicitly enable it. Coolify fronts everything with its own Traefik instance; two proxies fighting over ports 80 and 443 is a bad afternoon.
- Do not publish 5000 and 3000 on the host. The compose file publishes both by default for the bare-VPS case. Behind Coolify, the proxy reaches containers over the Docker network — bind them to
127.0.0.1or drop theportsentries entirely.
The first build compiles the whole Next.js dashboard and takes a while — five to fifteen minutes on a modest box. If it dies with a bare exit code 137, that is the OOM killer during next build, not a Coolify problem. Add swap or move up a plan size.
Step five: environment variables
Set these in Coolify's environment editor before the first deploy, because three of them are read at import and the container will not boot without them.
SECRET_KEY= # 32+ chars
ENCRYPTION_KEY= # 32+ chars — see the warning below
TWOCAPTCHA_API_KEY= # from 2captcha.com
NEXTAUTH_SECRET= # 32+ chars, different from SECRET_KEY
NEXTAUTH_URL=https://crm.example.com
BACKEND_URL=http://api:5000
CORS_ORIGINS=https://crm.example.com
GUNICORN_WORKERS=1Generate the three secrets with openssl rand -base64 48 | tr -d '\n=' and paste them in. Any sufficiently long random string works — ENCRYPTION_KEY is SHA-256'd into a Fernet key by crm_database.get_encryption_key.
Then copy ENCRYPTION_KEY somewhere off this server. Stored platform passwords and custom bot tokens are Fernet-encrypted with it and nothing else. There is no reset flow, no escrow, and no way to derive it back from the ciphertext. Lose it and every connected account has to be re-entered by hand. A password manager entry takes ten seconds and is the single highest-value thing in this guide.
TWOCAPTCHA_API_KEY is not optional either: OnlyFans gates login behind a Cloudflare Turnstile challenge and the flow pays for a solve every time, so config.py raises at import without it.
One more, and it is the one that catches people on a PaaS: every NEXT_PUBLIC variable is compiled into the browser bundle at build time. Changing them in Coolify's environment editor afterwards does nothing for client code. Set NEXT_PUBLIC_SITE_URL and NEXT_PUBLIC_APP_URL to your real dashboard URL as build variables before the first build, and if you change one later, trigger a rebuild rather than a restart.
Step six: domains, and the route that must not exist
Assign https://crm.example.com to the web service. Coolify handles the certificate.
Assign nothing to api unless you genuinely need external REST access, in which case give it https://api.example.com — its own hostname, not a path on the dashboard's.
This is the important part, and it is the single most common way to break this deployment: never point the dashboard hostname's /api routes at the Flask container. That whole path belongs to Next.js.
/api/auth/*is NextAuth — sign-in, session, callbacks/api/crm/[...path]is a server-side proxy that attachesX-API-Keyfrom the signed JWT, so the browser never holds the key/api/events/streamis the SSE proxy, which exists because anEventSourcecannot set headers
The browser never talks to Flask directly. Every dashboard request goes to Next.js, which calls Flask over the internal network at http://api:5000. Route /api/* to Flask and NextAuth's own endpoints land in an application that has never heard of them. The symptom is "login does nothing", an empty dashboard, and nothing useful in any log.
Step seven: stop Traefik killing your event stream
Coolify's default proxy is Traefik, and this is where a PaaS install differs most from the bare-VPS one — the shipped Caddyfile already solves this, and Traefik needs telling.
Two things matter. Response buffering must be off, or the SSE stream never reaches the browser: the dashboard opens one connection, the server writes a keep-alive comment every 15 seconds, and with buffering anywhere in the path the browser receives nothing until the buffer fills, which for a low-volume event stream can be never. The UI just stops updating, with no error. Traefik streams responses without buffering by default, so the usual culprit is something you added — a CDN in front, or a compression middleware applied to text/event-stream.
Second, the timeouts. Traefik's entrypoint responding timeouts are far below what a 20–30 second login and an hours-long stream need. In Servers → your server → Proxy → Configuration, add generous responding timeouts to the HTTPS entrypoint:
--entrypoints.https.transport.respondingTimeouts.readTimeout=300s
--entrypoints.https.transport.respondingTimeouts.writeTimeout=300s
--entrypoints.https.transport.respondingTimeouts.idleTimeout=300sRestart the proxy afterwards. Coolify's proxy configuration surface has moved between releases, so check the layout in the version you installed rather than assuming this path; the community has a long-running thread on SSE and WebSocket behaviour behind Coolify's Traefik that is worth reading if yours misbehaves.
Verify it properly rather than trusting the UI. Open the dashboard, leave a tab on the overview page, and watch a counter move after an event fires. If nothing ever moves but a page refresh shows the new data, your stream is being buffered or cut.
If you would rather not fight a proxy at all, Coolify can run Caddy instead of Traefik — and the project already ships a Caddyfile tuned for exactly this workload.
Step eight: persistent storage
Coolify reads the volumes: block from the compose file and shows api_data under Persistent Storage. Confirm it is there and mounted at /data before the first deploy, and leave it alone afterwards. That volume holds everything:
/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 stateDeleting a resource in Coolify can offer to delete its volumes with it. That prompt is asking whether to destroy your CRM.
Step nine: owner account and first connected account
Open the dashboard and register. That calls POST /api/auth/register, which mints your crm_id and API key.
Do it immediately, for the same reason as Coolify's own panel. The endpoint is unauthenticated by design and gated behind Cloudflare Turnstile — but turnstile_verify.verify() fails open when TURNSTILE_SECRET_KEY is unset, which is the state of a fresh install. Until you configure it, anyone who finds your hostname can create a panel on your server. Either set TURNSTILE_SECRET_KEY and NEXT_PUBLIC_TURNSTILE_SITE_KEY (and rebuild web, since the public half is compiled in), or restrict the hostname at your firewall. Never set CAPTCHA_BYPASS_TOKEN on a public box.
Then go to Accounts, add an account, and give an OnlyFans account its proxy: http://user:pass@host:port or socks5://user:pass@host:port. The connection flow requires a proxy for OnlyFans and keeps that account assigned to it; Fansly can connect without one. Proxy isolation reduces unnecessary session and location changes, but it does not guarantee an account outcome. The first credential login can run a Cloudflare init and a paid Turnstile solve, so allow time for the challenge.
Backups and upgrades
You now have two backup obligations, which is the honest cost of the nicer interface.
- Coolify's own /data/coolify/source/.env — it holds Coolify's encryption keys.
- The application's ENCRYPTION_KEY and the
api_datavolume. Find the volume withdocker volume lson the host and archive it:docker run --rm -v <volume>:/data -v "$PWD:/backup" alpine tar czf /backup/onlyapi-backup-$(date +%F).tar.gz -C /data .
Ship both off the box. A restored database without the key is undecryptable ciphertext.
Upgrades are the one place Coolify earns its keep: push to the tracked branch, or press Redeploy. Schema changes are additive and applied by _ensure_column in init_database() on startup, and the volume is untouched. Take a volume snapshot first regardless. Coolify itself updates from its settings page — do that on its own, not in the same sitting as an application deploy, so you know which change broke something.
Troubleshooting
| Symptom | What it actually is |
|---|---|
| api container restarts in a loop | 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. The deploy log names which one |
| exit code 137 during the build | The OOM killer during next build. Coolify and a Next.js build on a 4 GB box is too tight — add swap or resize |
| Every signed platform call starts returning 4xx | A stale signing revision. X_OF_REV and APP_TOKEN track OnlyFans' deployed frontend build and go stale when OF ships. Redeploy from the latest release, or set both as environment variables and restart api. /health stays green, which is what makes this one confusing |
| Live counters never move, refresh shows new data | SSE is buffered or cut. Check compression middleware, any CDN in front, and the entrypoint timeouts above |
| Login hangs, then fails | Proxy timeout below 300s, or a 2captcha balance of zero |
| Login does nothing at all, no errors | An /api/* route pointed at the Flask container. Remove it |
| Dashboard shows the wrong API URL | NEXT_PUBLIC_* is compiled in. Rebuild web; a restart will not do it |
| RuntimeError: GUNICORN_WORKERS=… | Scaling got switched on. Set it back to 1 |
| /ready returns 503 | The signer or scheduler is unavailable; the body says which. /health still answers, deliberately, so a degraded container is not restart-looped |
When Coolify is the wrong choice
If one person deploys and that person is comfortable with ssh, Coolify is a second system to keep patched for no gain — take the bare Hetzner route instead. Coolify pays off when a team shares the deploy button, when you want environment variables managed somewhere other than a .env file on a server, or when you are running several projects on one box.
And if none of this appeals — if you would rather not own a proxy, a captcha balance and two backup jobs — the hosted cloud is the same software with the operations attached. Most teams pick it, and that is a perfectly reasonable answer. The open-source overview lays out both paths honestly, including the platforms that cannot run this at all.