Deploying Next.js on Coolify: The Production Posture We Run Across the FH Client Book
3/26/2026 · John
Coolify is the deploy environment we run every FH client site on. It’s a self-hosted Heroku alternative — push to GitHub, Coolify rebuilds, Coolify deploys, Coolify manages the reverse proxy. The whole stack costs $20–80/month on a Hetzner or DigitalOcean VPS, beats Vercel’s pricing at scale, and gives us control over the runtime. It’s also where every painful deploy incident in our history has happened. Here’s the configuration that holds up.
Why Coolify (and when not)
We pick Coolify for any client site where (1) the traffic is predictable enough that we don’t need the autoscaling of Vercel, (2) the budget is real enough that $300/month on Vercel adds up, and (3) the team is technical enough to maintain a VPS. That covers most SMB sites we build. For clients with spike traffic, complex serverless needs, or no in-house tech ability, we’ll route them to Vercel and accept the cost.
Dockerfile vs Nixpacks: when to use which
Coolify supports two build paths. Dockerfile gives you full control — you write the Dockerfile, Coolify builds the image and runs it. Nixpacks auto-detects the stack and generates a Dockerfile for you. We use Dockerfile for projects that need anything custom (sharp for image processing, a Python sidecar, custom build steps) and Nixpacks for vanilla Next.js apps where the defaults are fine.
The minimal Next.js Dockerfile
Required: `output: "standalone"` in next.config.ts. Without it, the runtime image carries the entire node_modules (often 5–10× bigger) and start times balloon. With it, the image carries only the files Next actually needs to run.
FROM node:22-alpine AS baseFROM base AS depsWORKDIR /appCOPY package.json package-lock.json ./RUN npm ci --omit=optionalFROM base AS builderWORKDIR /appCOPY --from=deps /app/node_modules ./node_modulesCOPY . .ENV NODE_OPTIONS="--max-old-space-size=4096"RUN npm run buildFROM base AS runnerWORKDIR /appENV NODE_ENV=productionCOPY --from=builder /app/public ./publicCOPY --from=builder /app/.next/standalone ./COPY --from=builder /app/.next/static ./.next/staticEXPOSE 3000CMD ["node", "server.js"]
The minimal nixpacks.toml for a subdirectory project
If your Next app lives in a subfolder (fh-site/site, CabCarpentry/cab-app), Nixpacks can’t find it without help. Add a `nixpacks.toml` at the repo root.
[phases.setup]nixPkgs = ["nodejs_22", "npm"][phases.install]cmds = ["cd site && npm ci"][phases.build]cmds = ["cd site && npm run build"][start]cmd = "cd site && npm start"[variables]NODE_OPTIONS = "--max-old-space-size=4096"
Memory: size your VPS, set NODE_OPTIONS
Default Coolify VPSes are 1–2GB RAM. `next build` on a Next-16 + React-19 project with 100+ components routinely OOMs there. Size to 4GB minimum. Set `NODE_OPTIONS=--max-old-space-size=4096` in either Dockerfile or nixpacks.toml. We’ve killed three deploys to this exact issue across BHR, fh-site, and CabCarpentry before formalizing the rule in the migration playbook.
Never put cacheDirectories inside node_modules
npm ci wipes node_modules, the Docker cache mount is held by Docker, and you get EBUSY errors. The default nixpkgs cache is enough. We hit this once and lost an evening.
Environment variables in Coolify
Coolify’s UI lets you set env vars per-application. NEXT_PUBLIC_-prefixed vars are baked into the build (they’re client-side). Server-only vars (DATABASE_URL, SUPABASE_SERVICE_ROLE_KEY, RESEND_API_KEY) stay server-side. Set them in the Coolify UI, redeploy, and they’re live. Never commit them to git.
If you have NEXT_PUBLIC_ vars that need to change per-deploy, you have to rebuild — they get inlined at build time. This is a Next.js choice, not a Coolify one. If you need runtime env vars on the client, fetch them from a server endpoint instead of inlining.
The reverse proxy and SSL
Coolify uses Caddy or Traefik for the reverse proxy. Both terminate SSL automatically using Let’s Encrypt. You add the domain in Coolify’s UI, point the DNS A record at the VPS IP, wait 30 seconds for the cert to provision, and you’re live. We always set up DNS on Cloudflare for the extra DDoS protection.
Health checks and auto-restart
Coolify pings your app at `/` or a custom health endpoint. If the ping fails, Coolify restarts the container. We add a `/api/health` route that returns a 200 with a brief body — it’s cheaper than the homepage and makes monitoring deterministic.
// app/api/health/route.tsexport async function GET() { return new Response(JSON.stringify({ ok: true, time: new Date().toISOString() }), { headers: { "content-type": "application/json" }, });}
Zero-downtime deploys
Coolify does rolling deploys by default — new container starts, health-checks pass, traffic switches, old container stops. Make sure your app starts cleanly without long warmup; we’ve had a couple deploys flap because the new container was slow to read a config file and failed the first health check. The fix is usually to lazy-load whatever was being eagerly fetched at startup.
Logs and observability
Coolify gives you per-container logs in the UI. For anything more — alerting, long retention, structured queries — pipe to a real log destination. We use Better Stack on most sites because it’s cheap and the alerting works. The Coolify logs are fine for ad-hoc debugging but not for actual production monitoring.
Backups and rollbacks
Coolify keeps the previous container image and lets you roll back in one click. We’ve used this exactly twice in the last 18 months — both times a third-party API change broke our app and we needed to roll back while we patched. It works. Just verify that your DATABASE migrations are rollback-safe before relying on container rollback.
The pre-deploy checklist
- `output: "standalone"` in next.config.ts.
- `next` pinned to exact version (no caret).
- No `puppeteer` in dependencies (move to devDeps).
- Exactly one lockfile.
- `reactCompiler` correctly placed (root for Next 16, `experimental` for Next 15).
- `NODE_OPTIONS=--max-old-space-size=4096` set if project has 50+ components.
- Subdirectory projects have a `nixpacks.toml` at repo root.
- Coolify health-check route returns 200.
When Coolify is the wrong choice
Three signals to walk away. (1) Your traffic is spiky — Vercel’s autoscaling will save you more than the Coolify savings on a steady VPS. (2) You need true zero-ops — the client has no one who will SSH into a VPS at 2am. (3) Your app needs serverless features like cron, queues, or background jobs and you don’t want to run them yourself. For SMB marketing sites, none of those apply, and Coolify is the right call.
How this lands on FH client work
Every FH client site currently runs on Coolify. The runtime cost is $20–60/month per VPS, each VPS hosts 1–3 sites comfortably, and the deploy cadence is fast enough that we can ship multiple times a day without thinking about it. If you’re hosting on a slow shared host or paying $200+/month on a CMS platform, book a consultation — moving to a Coolify-backed Next.js stack is one of the highest-ROI changes you can make.
Skip the ops, keep the posture
Every site in the FH client book runs on this deploy posture, and the client never touches it. If you want the hosting handled with the build, that is marketing site build.
Frequently asked questions
Why deploy on Coolify rather than a managed platform?
Cost and control at small scale, on infrastructure you own, which suits a book of client sites where per-site platform pricing adds up quickly. The trade is that the operational details, meaning memory, environment, health checks, and proxying, are now yours to get right.
Dockerfile or Nixpacks?
Nixpacks when the project is standard and you want the build detected for you; a Dockerfile when the build has specifics worth pinning. Subdirectory projects need explicit configuration either way, because the detector scans the repository root and finds nothing to build there.
What does a minimal Next.js Dockerfile need?
A build stage that installs and builds, then a runtime stage that copies only the standalone output and its static assets. The standalone output is what keeps the runtime image small; copying the whole project into the final image is the mistake that makes deploys slow.
Why do builds fail with no clear error?
Almost always memory. A Next build on a component-heavy site exceeds the default heap on a small VPS and the process is killed rather than erroring usefully. Raise NODE_OPTIONS in the build environment and size the host to at least 4GB.
How should environment variables be handled?
Set in the platform rather than committed, with the build-time and runtime distinction understood: anything the client bundle needs is baked at build, so changing it later requires a rebuild rather than a restart. Confusing the two produces changes that appear not to take effect.
What health check should a Next app expose?
A cheap route that confirms the process is serving, not one that touches the database, because a slow dependency should not make the platform restart a healthy application. Pair it with a version endpoint, which is what lets you confirm which build is actually live.
How do zero-downtime deploys work here?
The new container has to become healthy before the old one is removed, which requires a health check the platform trusts and an application that starts quickly. Standalone output helps directly, because a smaller image and faster start shorten the window where both are running.
Why does puppeteer break deploys?
Because it downloads a large browser during install, which exhausts disk or exceeds the install timeout on small hosts. It belongs in devDependencies. If production genuinely needs a browser, that is a separate worker rather than a dependency of the web application.
How do I confirm what is actually running in production?
Expose a version endpoint returning the deployed commit, and check it after every deploy. Assuming that a green build means the new code is live is how a stale container survives for days while everyone debugs code that was never deployed.
What belongs on a pre-deploy checklist?
Standalone output configured, the framework version pinned exactly, no browser automation in dependencies, exactly one lockfile, compiler configuration correct for the version, and build memory raised for a component-heavy project. Six checks, each of which has broken a real deploy.
How should logs be handled?
Streamed off the host to somewhere searchable, because logs that live only in a container are gone at the moment you most want them. The minimum useful set is application errors, build output, and a record of which commit each deploy shipped.
When is this the wrong choice?
When the team has no appetite for operations, when traffic needs elastic scaling you would have to build yourself, or when compliance requires managed guarantees. Self-hosting trades money for responsibility, and it is a bad trade for a team without someone willing to own it.