CI builds, Coolify pulls
August 30, 2026
Coolify sits on top of Docker, talks to your servers over plain SSH, and replaces the parts of a VPS you'd otherwise hand-roll: a reverse proxy with automatic Let's Encrypt certificates, Git-based deploys, a catalog of 280+ one-click services, and, since 4.0, native multi-server orchestration instead of the single-box setup most people start with. That's the whole pitch, and it's a good one. It's also not the part worth writing about.
The part worth writing about is what Coolify deliberately doesn't own: where your build happens, what a deploy is allowed to trust, and what "healthy" means for your specific container. Get those wrong and Coolify will report a clean, green deploy while serving last week's code, or tear down a stack that just finished its job correctly. I've hit every failure mode below running a real Next.js/Postgres app on it, and eventually stopped treating the fixes as personal notes and put them in a public repo instead: more on that at the end.
What Coolify actually owns
Coolify's job stops at the proxy and the resource. It watches a Git repository, builds or pulls an image, starts the container, and routes a domain to it through its own reverse proxy; you never publish a port on the host yourself; every route goes through Coolify's proxy layer, which is also what terminates TLS. It also derives a resource's health from its containers' state, which sounds obvious until a container you designed to run once and exit reads to Coolify as a crashed resource, and a resource that looks crashed gets torn down.
That single fact (health is inferred from container status, not from what the container actually did) shapes more of a real deployment than the docs make obvious. A one-shot migration container that finishes and exits 0 still looks, from Coolify's side, exactly like a container that died. If it's declared with depends_on, you've just built a stack that tears itself down on every successful deploy.
The build question nobody answers for you
The default path (point Coolify at a repo, let it build with Nixpacks or your Dockerfile on the server) works fine for small things. It stops working the moment your build itself is expensive. A next build on a real app can peak at 2-4 GB of memory; a VPS running that build is a VPS not serving the site it's supposed to be building, at the exact moment traffic is highest during a deploy.
The fix is unglamorous: build in CI, publish to a registry, have Coolify's server only ever pull. That single decision (CI builds, Coolify pulls) removes an entire category of deploy-time incident, but it also removes the one thing that made "point and deploy" convenient. You now own a registry, a tag strategy, and the plumbing that gets a finished image from CI into a docker-compose.yml Coolify can read.
In a monorepo the Dockerfile itself gets a stage most single-app setups skip: pruning the workspace down to one app's dependency subgraph before installing anything, and splitting the manifest copy from the source copy so a source-only commit reuses the cached install layer instead of reinstalling on every push:
1FROM oven/bun:1.3.14-alpine AS base2RUN bun install -g turbo@2.10.93WORKDIR /app45FROM base AS pruner6ARG APP_NAME7COPY . .8RUN turbo prune "${APP_NAME}" --docker910FROM base AS installer11ARG APP_NAME12# Manifests first: this layer only invalidates when a package.json or the13# lockfile changes, not on every source edit.14COPY --from=pruner /app/out/json/ .15RUN bun install --frozen-lockfile16COPY --from=pruner /app/out/full/ .17RUN turbo run build --filter="${APP_NAME}"
Collapsing those two COPY steps into one (copying out/full before installing) is the single most expensive line you can write in this file: every commit, including a one-line copy change, reinstalls the entire dependency tree from scratch.
A concrete trap here: Coolify has a "Docker Compose" option on the new-resource screen that looks like it does what you want. It doesn't clone anything: it pastes your YAML into Coolify's own database as a static config, which means any build: context in that file has no repository checked out to build from. The resource type you actually want is a Git Repository resource whose build pack happens to be Docker Compose. The file lives in the repo, Coolify clones it fresh on every deploy, and it's the actual source of truth instead of a copy rotting in the UI.
The CI side of this is a normal multi-job pipeline, but two details in the publish job matter more than they look. First, tag twice (once mutable, once immutable) so a bad deploy is always one docker service update away from a known-good commit instead of a guess:
1- uses: docker/build-push-action@v62 with:3 context: .4 file: Dockerfile5 build-args: APP_NAME=${{ matrix.app }}6 push: true7 tags: |8 ghcr.io/${{ github.repository }}/web:latest9 ghcr.io/${{ github.repository }}/web:${{ github.sha }}10 cache-from: type=registry,ref=ghcr.io/${{ github.repository }}/web:buildcache11 cache-to: type=registry,ref=ghcr.io/${{ github.repository }}/web:buildcache,mode=max,image-manifest=true,oci-mediatypes=true
Second, the build cache goes to the registry, not type=gha. A Next.js build blows past GitHub Actions' 10 GB cache limit fast enough to start evicting its own layers mid-pipeline; a registry cache doesn't have that ceiling and survives across branches. image-manifest=true and oci-mediatypes=true aren't cosmetic either: GHCR rejects a mode=max cache manifest without them, silently enough that the first sign is a cold cache on every single run.
The webhook race you won't see until it bites
Coolify's "Automatic Deployment" toggle wires GitHub's push webhook straight to a redeploy. If your image is built in CI, this is actively wrong: the push webhook fires within seconds, GitHub Actions takes minutes to build and publish, and Coolify happily redeploys the previous tag while your CI is still compiling the fix. Every indicator goes green. The bug you just shipped a fix for is still live.
The two triggers are mutually exclusive on purpose. Automatic Deployment stays off; the only thing allowed to call Coolify's deploy webhook is CI's own deploy job, gated behind lint, typecheck and the test suite passing first.
That deploy webhook carries its own footgun: the URL is just a UUID. Nothing about it tells you, at a glance, which project it points to. A copy-pasted webhook aimed at the wrong resource will happily 200 while redeploying somebody else's live site. That's not hypothetical; it's a documented failure mode of sharing one Coolify instance across projects. The fix is to read the resource name back out of the response instead of trusting the status code alone:
1- name: Trigger Coolify deployment2 env:3 COOLIFY_WEBHOOK: ${{ secrets.COOLIFY_WEBHOOK }}4 COOLIFY_TOKEN: ${{ secrets.COOLIFY_TOKEN }}5 EXPECTED_RESOURCE: next-coolify-boilerplate6 run: |7 code=$(curl -sS -o /tmp/coolify-response -w '%{http_code}' \8 --request POST "$COOLIFY_WEBHOOK" \9 --header "Authorization: Bearer $COOLIFY_TOKEN")10 echo "Coolify answered HTTP $code"11 cat /tmp/coolify-response; echo12 case "$code" in13 2*)14 if ! grep -q "$EXPECTED_RESOURCE" /tmp/coolify-response; then15 echo "::error::Coolify accepted the request for a resource that is not '$EXPECTED_RESOURCE'."16 exit 117 fi18 ;;19 401|403) echo "::error::Token rejected: needs the Deploy permission, and API access must be on."; exit 1 ;;20 404) echo "::error::Webhook not found: check the resource uuid."; exit 1 ;;21 405) echo "::error::Wrong HTTP method: Coolify's deploy webhook wants POST, not GET."; exit 1 ;;22 *) echo "::error::Coolify returned HTTP $code."; exit 1 ;;23 esac
The 405 branch exists for a reason worth keeping: Coolify's own API reference documents GET for this endpoint, and a live instance answered 405 Method Not Allowed to it: the URL was reachable and the token was never even evaluated, only the verb was wrong. A bare curl --fail collapses that into the same generic non-zero exit as an invalid token, which sends you debugging the wrong thing first.
:latest is a promise Docker doesn't keep
pull_policy: always looks like a default you can skip. It isn't, if your compose file references a mutable tag like :latest. docker compose up -d does not re-pull a tag it already has cached locally; CI can publish a brand-new image, the Coolify deploy can report success, and the container underneath keeps serving last week's build. The only artifact that will tell you this happened is the image's own .Created timestamp, buried in docker inspect, which is not somewhere anyone looks during an incident.
That one line sits next to two others that matter just as much and get skipped because they look like defaults:
1services:2 app:3 image: ghcr.io/creativoma/next-coolify-boilerplate/web:latest4 pull_policy: always # forces the re-pull `up -d` otherwise skips5 restart: unless-stopped6 depends_on:7 postgres:8 condition: service_healthy9 environment:10 DATABASE_URL: postgresql://app:${POSTGRES_PASSWORD}@postgres:5432/app?schema=public11 # No `ports:` block. Coolify's proxy is the only thing that talks to this12 # container from outside the Docker network; publishing 3000 on the host13 # as well makes the app reachable by IP, bypassing TLS entirely.
DATABASE_URL pointing at postgres (the Compose service name) rather than a host-published port is the same idea from the other direction: it only resolves inside the Docker network Coolify creates for the stack, so there's nothing to accidentally expose even if you wanted to.
Health checks have to mean something
node:22-alpine doesn't ship curl or wget, so a Node one-liner against http.get is genuinely what's available for a healthcheck in a slim image, and it's worth making that check actually check something:
1healthcheck:2 test:3 [4 'CMD',5 'node',6 '-e',7 "require('http').get('http://localhost:3000/api/health', r => process.exit(r.statusCode < 500 ? 0 : 1)).on('error', () => process.exit(1))",8 ]9 interval: 15s10 timeout: 5s11 retries: 512 start_period: 40s
/api/health here also probes the database connection. A container that's up but can't reach Postgres shouldn't be marked healthy and shouldn't receive traffic, and depends_on: condition: service_healthy on anything downstream only works if the healthcheck is actually load-bearing, not a route that always returns 200.
Where migrations actually belong
There are three plausible places to run prisma migrate deploy in a Coolify deployment, and only one of them survives contact with how Coolify models health:
- Coolify's Pre-Deployment Command: the docs never settle whether it runs against the image you're deploying or the one it's replacing, which is not a question you want open during a schema change.
- A one-shot migrate service in compose: correct in theory (same image, same deploy, ordering guaranteed by
depends_on), wrong in practice, for the reason above: it exits 0 on success and Coolify reads that exit as a dead resource. - Inside the app container's own start command: the one that works. Same image, migrations run before the server binds a port, and a failed migration means the server never comes up instead of coming up half-migrated.
1CMD ["sh", "-c", "cd /prisma-cli && node node_modules/prisma/build/index.js migrate deploy && cd /app && exec node apps/$APP_NAME/server.js"]
migrate deploy is idempotent by design (nothing pending, it prints that and exits 0), which is exactly what makes it safe to run unconditionally on every boot. And exec on the final command matters more than it looks: without it, Node isn't PID 1 and never sees the SIGTERM Coolify sends on a redeploy, so shutdown waits out the full kill timeout instead of draining cleanly.
Where it lives now
Every point above is a line of configuration with an incident behind it, so I put the whole thing in a repo instead of leaving it scattered across deploy logs: next-coolify-boilerplate: Next.js 16, Prisma 7 and Postgres, with the CI-builds/Coolify-pulls pipeline above wired end to end, plus a layered architecture (domain → application → infrastructure) enforced by the module graph, ESLint and a dependency test, not just convention. Its README is a table of traps versus fixes, closer to a postmortem log than a features list, the only kind of infrastructure documentation worth trusting when you didn't write it yourself.
The two halves are intentionally separable. If you want the deploy pipeline without the DDD layering, it says so directly: delete packages/domain and packages/application, drop the import rules, call Prisma from your route handlers. The Dockerfile and CI don't care either way.
Conclusion
Coolify earns the "self-hosted Heroku" comparison for the parts it actually replaces: the proxy, the TLS, the service catalog, the SSH orchestration. What it doesn't replace is the part every managed PaaS quietly does for you: deciding where a build happens, what a deploy is allowed to trust, and what "healthy" means for your specific container. Coolify gives you the primitives and gets out of the way. The pipeline on top of them is still yours to design, and it stays yours to get wrong until something forces you to write it down.
Docs and links: Coolify · next-coolify-boilerplate