Self-Hosting Coolify: Migrating Real Apps, Wiring Cloudflare and What Build-on-Push Actually Does
Coolify gives you a Heroku-shaped button on a box you own. It is worth it — but only once you understand that you have not bought a platform, you have bought a generator for Docker and Traefik configuration, and it is Docker and Traefik that will page you.
Every self-hosting decision starts with the same invoice. A handful of small applications on a managed platform, each individually cheap, collectively costing more per month than a machine that could run all of them with room to spare. So you buy the machine, install Coolify, and within an hour you have a dashboard that looks reassuringly like the platform you left.
That hour is the easy part, and it is the part every tutorial covers. This article is about the other parts: what the deploy button is actually doing, what breaks when you put Cloudflare in front of it, what a real migration costs, and what starts failing in month three when the novelty has worn off and the disk is at 94%.
Everything here was checked against Coolify 4.3.21 (released today; the instance the two sites you are reading this on run against reports 4.3.19), the controller source at that tag, and the live server.
What Coolify actually is
Coolify is a Laravel application that talks to a Docker daemon over a socket and writes Traefik labels. That is the whole trick. When you click "Deploy", it clones your repository onto the server, produces an image, starts a container on a Docker network called coolify, and attaches labels that tell Traefik which hostname routes to which container port.
This matters because it determines who you call when it breaks. There is no control plane absorbing the failure for you. A container that will not start is a Docker problem. A domain that 404s is a Traefik label problem. Coolify's logs will tell you which of the two it is, and then you are on your own in a way you were not on Vercel.
The upside is proportionate: the abstraction is thin enough to see through. You can docker inspect any of it. Nothing is proprietary, and the exit path is docker compose up on another box.
Installing it
The requirements are genuinely modest — 2 CPU cores, 2 GB RAM, 10 GB disk, amd64 or arm64 — but treat those as the floor for Coolify itself, not for Coolify plus your builds. A Next.js production build will comfortably consume more memory than the control plane does. On a 2 GB box, a single build will OOM.
curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bashThe dashboard comes up on port 8000. The documentation's advice to "use a fresh server for Coolify to avoid any conflicts with existing applications" is not boilerplate: the installer manages Docker, and Coolify will later take ownership of ports 80 and 443 for its proxy. Anything already bound there will fight it.
The first real decision comes immediately, and the installer does not ask you: does this server run Coolify, or does it run your applications? Coolify allows both, and the default single-server install does both. It is the right choice for two or three small applications and the wrong choice the moment the box matters, because a build that exhausts memory takes down the thing you use to diagnose the build.
Importing from GitHub: three mechanisms, not one
Coolify offers three ways to connect a repository, and they are not variations on a theme. They have different failure modes.
| Mechanism | Auth | Webhook | Private repos | Best for |
|---|---|---|---|---|
| GitHub App | Installation token, short-lived | Created and maintained by Coolify | Yes | Anything you own |
| Deploy key | Per-repo SSH key | You create it manually | Yes | Repos you cannot install an App on |
| Public repository | None | Manual webhook | No | Demos |
The GitHub App is the one to use. You create it from Coolify's UI, GitHub redirects back with the app credentials, and you choose which repositories to install it on. From then on Coolify holds an App ID and a private key, mints short-lived installation tokens per clone, and — this is the part that saves you — registers and owns the webhook itself. The documentation is explicit: "The integration receives provider events for Coolify, so you do not need to copy a manual Git webhook URL."
On our instance that looks like this — one App per GitHub owner, because a GitHub App installation cannot span accounts:
[
{ "uuid": "fpkzmddjsh12km4yiexjyajm", "name": "Public GitHub",
"organization": null, "is_public": true, "app_id": null },
{ "uuid": "by2yufvvtuhljgnyk1g1kt8t", "name": "coolify-altixcode",
"organization": "AltixCode", "is_public": false, "app_id": 4918996 },
{ "uuid": "fijk1bjebsfugxqvdfnfwcnc", "name": "coolify-github-ata-personal",
"organization": null, "is_public": false, "app_id": 4919778 }
]The deploy-key path exists for repositories on hosts where you cannot install an App, and it is strictly worse: you manage the webhook, you manage the secret, and the webhook is validated by HMAC against a per-application secret that Coolify stores in manual_webhook_secret_github. The manual endpoints are stable and documented in the route table:
POST /webhooks/source/github/events # GitHub App deliveries
POST /webhooks/source/github/events/manual # deploy-key / manual webhook
POST /webhooks/source/gitlab/events/manual
POST /webhooks/source/gitea/events/manual
POST /webhooks/source/bitbucket/events/manualWhat "builds on push" actually means
This is the feature everyone buys Coolify for, and it is worth reading the controller rather than the marketing, because there are three ways a push can arrive and produce nothing.
When a push event lands, Coolify extracts the branch from refs/heads/…, finds every application whose git_branch matches and whose repository full name matches, and then applies two filters before queueing anything.
The first filter is watch paths. If an application has watch_paths set, Coolify unions the added, removed and modified arrays across the commits in the payload and deploys only if something matches. Otherwise you get a response body saying Changed files do not match watch paths. Ignoring deployment. — a webhook that returns 200 and does nothing.
Here is the part nobody mentions: GitHub truncates the commits array in a push payload to the first 20 commits. Coolify computes the changed-file set from that array. Push 25 commits to a monorepo and the last five contribute nothing to the match. Your watch path can be correct, your change can be real, and the deployment will still be skipped — silently, with a success status on the delivery. If you use watch paths on a repository that receives large merges, this will bite you, and the only signal is the webhook response body.
The second filter is the commit message. From DetectsSkipDeployCommits:
Returns true if there is at least one non-empty message and every message
contains [skip cd] or [skip ci] (case-insensitive).Note every. One [skip ci] commit in a push of five does not skip the deployment — all five must carry it. That is the correct semantic and the opposite of what most people assume.
Both filters are webhook-only. A manual deploy from the dashboard, or a POST to the API, ignores both.
Choosing a build pack
Four options, and the choice has more consequence than it looks.
| Build pack | Mechanism | Config file | Use when |
|---|---|---|---|
| Nixpacks | Generates a Dockerfile, packages from Nix | nixpacks.toml |
Legacy; maintenance mode |
| Railpack (beta) | BuildKit frontend via Buildx, packages from Mise | railpack.json |
Greenfield, no Dockerfile |
| Dockerfile | Your Dockerfile | Dockerfile |
Anything you care about |
| Static | Prebuilt files into nginx:alpine |
— | SPA and static output |
Railpack is Railway's successor to Nixpacks and Coolify labels it Beta with a warning to "test the generated image and application behavior before replacing an existing production build." It also "requires Docker Buildx" on both the build server and the Coolify helper container.
The uncomfortable recommendation: write the Dockerfile. Auto-detection is the feature that makes the demo short and the incident long. A generated build is a build you cannot reproduce locally, cannot pin, and cannot debug except by reading someone else's generator. Both sites in this series use "build_pack": "dockerfile" for exactly that reason. The fifteen minutes you spend on a multi-stage Dockerfile buys you a build that is identical on your laptop and on the server, and that property is worth more than every other convenience Coolify offers.
Migrating an existing application
The mechanical steps are dull. The two things that actually cost you time are these.
Environment variables have two scopes and the UI defaults to both. Coolify distinguishes build-time from runtime variables; new variables enable both. Build-time variables are passed as Docker build args, and — as the documentation notes — "traditional Docker build args remain visible in image metadata", so Coolify prefers BuildKit secrets when available and falls back to plain build args when it is not.
This is where framework migrations break. Anything inlined at build time — NEXT_PUBLIC_*, VITE_*, PUBLIC_* — must have the build-variable flag on or it will be undefined in the client bundle while being perfectly present in the container's environment. You will see a runtime error in the browser, check the container env, find the variable there, and lose an hour. Conversely, a database password has no business being a build variable; turn that flag off.
Shared variables are the quiet win: define once at team, project or environment level and reference with {{environment.DATABASE_URL}} rather than pasting the same connection string into nine applications.
Persistent data does not migrate itself. Anything your container writes that must survive a redeploy needs an explicit volume mount, because a deployment replaces the container entirely. The pattern that works is: stand the new stack up alongside the old one on the sslip.io hostname Coolify assigns by default, restore the database, verify against that hostname, and only then move DNS. That gives you a rollback that is a DNS change rather than a restore.
Coolify does have first-class migration between servers it manages — POST /applications/{uuid}/migrate stops the application, "optionally transfers persistent volume data when both servers are managed by Coolify", and updates the records — but that is for Coolify-to-Coolify. Coming from Heroku or a hand-rolled box, you are doing it by hand.
Domains, Cloudflare and the certificate you think you have
DNS is simple: A records to the server's public IPv4, one per hostname, wildcard * if you want subdomains — noting that "the wildcard record covers matching subdomains, but it does not cover the apex domain." Add the domain in Coolify, redeploy so the Traefik labels are regenerated, done.
Cloudflare is where it stops being simple, and there are two distinct problems.
Problem one: the orange cloud breaks HTTP-01. Traefik requests a Let's Encrypt certificate and Let's Encrypt tries to reach your domain on port 80. With the proxy on, it reaches Cloudflare. The docs put it plainly: "the proxy interfering with the HTTP or TLS-ALPN-01 challenge." Your options are to turn the proxy off, or to switch Traefik to the DNS-01 challenge, which never touches your server. That is a proxy configuration edit under Servers → Proxy → Configuration: drop the HTTP challenge flags, add
- '--certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare'
- '--certificatesresolvers.letsencrypt.acme.dnschallenge.delaybeforecheck=0'
- '--certificatesresolvers.letsencrypt.acme.storage=/traefik/acme.json'and set CF_DNS_API_TOKEN in the traefik service environment, scoped to DNS edit on that zone only.
Problem two is the one that actually costs people days. Once the orange cloud is on, the certificate your browser validates is Cloudflare's, not yours. Coolify's certificate can be missing, expired, or self-signed and the padlock will still be green. Here are two hostnames on our instance:
echo | openssl s_client -connect altixcode.com:443 -servername altixcode.com 2>/dev/null \
| openssl x509 -noout -issuer -dates
echo | openssl s_client -connect fleet.altixcode.com:443 -servername fleet.altixcode.com 2>/dev/null \
| openssl x509 -noout -issuer -datesissuer=C=US, O=Let's Encrypt, CN=YE2
notBefore=Aug 20 05:58:29 2026 GMT
notAfter=Nov 18 05:58:28 2026 GMT
issuer=C=US, O=Let's Encrypt, CN=YE2
notBefore=Aug 20 05:58:29 2026 GMT
notAfter=Nov 18 05:58:28 2026 GMTIdentical validity windows on two different hostnames. That is one Cloudflare edge certificate covering the zone, issued on Cloudflare's schedule, nothing to do with Traefik. The origin leg is a separate negotiation governed by your SSL/TLS mode, and Flexible is the trap: Cloudflare terminates TLS and speaks plain HTTP to your origin, which then sees an insecure request, redirects to HTTPS, and you get an infinite redirect loop that looks like an application bug. Use Full (strict) with a real origin certificate — either the Let's Encrypt one Traefik obtained via DNS-01, or a Cloudflare Origin CA certificate loaded into Traefik.
The test that actually proves your origin works is to bypass the edge entirely: resolve the hostname to the server IP yourself with curl --resolve and see whose certificate comes back.
The API, and the breaking change in every stale CI snippet
If you trigger deployments from external CI, you will find hundreds of blog posts and gists with this shape:
curl "$COOLIFY_URL/api/v1/deploy?uuid=$APP_UUID&force=false" \
-H "Authorization: Bearer $COOLIFY_TOKEN"Run it against anything from 4.2 onward:
{"message":"This endpoint has changed to a POST request."}
[status 405]Every state-changing endpoint moved to POST in 4.2 — /deploy, the enable/disable pair, server validation, and the start/restart/stop actions on applications, databases and services. The GET routes still exist purely to return that message instead of a 404, which is a genuinely thoughtful piece of API design and also the reason the failure is easy to miss in a pipeline that does not check status codes. The corrected call:
curl -X POST "$COOLIFY_URL/api/v1/deploy?uuid=$APP_UUID&force=false" \
-H "Authorization: Bearer $COOLIFY_TOKEN"uuid and tag both accept comma-separated lists, force rebuilds without cache, and pr deploys a specific pull-request preview. Tags are the underrated feature here: tag five applications frontend and one POST redeploys all of them.
What breaks in month three
The install is not the risk. These are.
Disk. Every deployment produces an image, and the defaults keep two per application. Coolify runs a cleanup on */30 * * * * when usage crosses a 60% threshold, which handles images and build cache. It does not, by default, handle volumes or networks — our server reports "delete_unused_volumes": false and "delete_unused_networks": false, which are the shipped defaults. Delete an application and its volume stays on disk forever. Check docker volume ls -f dangling=true occasionally, or you will discover the backlog at 98%.
Restart loops. Coolify restarts unhealthy containers up to max_restart_count, then gives up and sets restart_limit_reached. One of our applications currently reports "restart_count": 29, "restart_limit_reached": true, "last_restart_type": "crash" — it is up now, but the flag is a record that it was flapping, and nothing clears it but attention. Configure notifications on day one, not after the first silent outage.
Build concurrency. The default is "concurrent_builds": 2 with a "deployment_queue_limit": 25. Push to four applications at once on a shared box and two builds wait — which is correct behaviour, and also why a monorepo that fans out to six services feels slow in a way that has nothing to do with your Dockerfile.
The control plane is on the same Docker socket. Coolify manages containers through the daemon it also runs inside. A wedged daemon is a total outage with no dashboard to diagnose it from. Keep SSH working, keep the instance encryption key backed up somewhere that is not the server, and know that restoring Coolify without that key means re-entering every secret by hand.
The compressed version
- Coolify is a generator for Docker and Traefik configuration. Debug at that layer.
- Use a GitHub App, not a deploy key. It owns the webhook so you do not have to.
- Write a Dockerfile. Auto-detection optimises the wrong fifteen minutes.
- Watch paths are computed from the push payload's commits array, which GitHub truncates at 20. Large merges can silently skip deployment.
[skip ci]skips only if every commit in the push carries it.- Mark build-time variables explicitly, or
NEXT_PUBLIC_*will beundefinedin the bundle and present in the container. - Cut over by DNS, not by restore: run on the sslip.io hostname until it is verified.
- With Cloudflare proxying, the padlock is Cloudflare's certificate, not yours. Use Full (strict) and DNS-01, and verify the origin with
curl --resolve. /api/v1/deployis POST since 4.2. GET returns 405 with a message, not a 404.- Unused volumes are never reclaimed by default. Watch the disk.
Sources
- Coolify documentation — Start with Self-hosted, GitHub Auto Deploy, Automatic Deployments, DNS, Traefik DNS Challenge, Let's Encrypt troubleshooting, Railpack, Environment Variables
- Coolify source at tag
v4.3.21—routes/webhooks.php,routes/api.php,app/Http/Controllers/Webhook/Github.php,app/Http/Controllers/Webhook/Concerns/DetectsSkipDeployCommits.php,app/Http/Controllers/Api/DeployController.php - Coolify v4.2.0 release notes — the GET-to-POST change
- GitHub webhook events and payloads — push payload commit truncation
- Live instance readings and
openssl/curltranscripts taken 15 September 2026