Skip to content

Deploying Next.js on Coolify: The Production Posture We Run Across the FH Client Book

Coolify is cheap, fast, and self-hosted. It’s also the deploy environment where we’ve hit the most breakage. Here’s the posture that works.

John Cravey with AIFounder5 min readUpdated Sep 11, 2026

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.

Free estimate · 2 minutes

Read the playbook. Now see it for your business.

The posts are the mechanism. The estimate below sketches the version we'd actually ship for your business, at your scale. About a minute, no opt-in.

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 base

FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=optional

FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NODE_OPTIONS="--max-old-space-size=4096"
RUN npm run build

FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["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.

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.ts
export 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.

The pre-deploy checklist

  1. `output: "standalone"` in next.config.ts.
  2. `next` pinned to exact version (no caret).
  3. No `puppeteer` in dependencies (move to devDeps).
  4. Exactly one lockfile.
  5. `reactCompiler` correctly placed (root for Next 16, `experimental` for Next 15).
  6. `NODE_OPTIONS=--max-old-space-size=4096` set if project has 50+ components.
  7. Subdirectory projects have a `nixpacks.toml` at repo root.
  8. 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.

Answers

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.

Question we did not answer? Ask us directly and we will answer it here.

John Cravey, Founder
Written by
John Cravey
Founder

Founder of Frontend Horizon. Writes most of the long-form work on the FH blog.

Newer post
Reading Supabase Logs: The Five Queries That Catch 80% of Production Issues
Older post
Joining GA4 with Search Console: The Reporting View That Tells You What Actually Works
Keep reading

More from the blog

Next.js·10 min

AGENTS.md and llms.txt: Making Your Next.js Project Legible to AI

AI reads your code and your site whether you help it or not. Two small files decide whether it reads them right.

Next.js·9 min

Next.js MCP Server (next-devtools-mcp): Setup and What AI Agents Can See

An AI agent guessing at your app’s state is slow and wrong. One that can read it live is a different tool entirely.

Next.js·6 min

Next.js 16.1 in Production: The Migration Playbook We Run on Every FH Site

Next 16.1 is the lean target. Here’s the exact migration we run, what breaks, and what to delete after.