Skip to main content

When One Builder Bug Breaks Every Modern Laravel Deploy: Lessons From Nixpacks' Duplicate Nginx Block

10 min readDora NodaDora Noda
Share
On this page

The build was green. The container never stayed up. On January 4, 2026, a self-hosted Coolify user deployed a stock Laravel application — built from the official Laravel React Starter Kit, no exotic config — and watched it enter a restart loop seconds after launch. The build log showed success. The runtime showed a crash. The culprit was neither the app nor the server: it was two location / blocks in a generated nginx config, emitted by the platform's default builder, Nixpacks, because the repo contained both a composer.json and a package.json at its root. One builder bug, and every modern Laravel deploy through that path broke the same way.

That incident — Coolify issue #7867 — is worth revisiting not because it was catastrophic (it wasn't; there was a workaround within hours) but because of what it reveals about where git-push platforms actually fail. The builder is the least-tested dependency in most self-hosted PaaS stacks: a shared, auto-detecting, upstream-moving code path that every tenant's deploy flows through, usually pinned to whatever the platform ships and exercised against zero representative fixtures before rollout. Below is the incident anatomy, why auto-detect merged two providers into one broken config, how the 2026 builder shakeout changed the defaults, and the two defenses — per-app builder pinning plus a build-matrix CI check — that keep one upstream bug from breaking a whole tenant class at once.

The January morning modern Laravel deploys broke

The facts of the report are crisp. Environment: self-hosted Coolify v4.0.0-beta.460 on Ubuntu 24, build pack set to Nixpacks, start command left empty. Repro: create an app from the official Laravel React Starter Kit — or any standard Laravel + Inertia + React project — and deploy with defaults. Result: the image builds fine, the container starts, nginx refuses to serve, the container restarts, repeat.

Two details make this incident representative rather than exotic. First, the trigger is the default shape of a modern Laravel app. Laravel's own starter kits ship a root package.json for the Vite/Inertia frontend next to the root composer.json for the PHP backend. This isn't a weird monorepo; it's what laravel new --react produces. Second, the failure mode is the cruelest kind: green build, dead runtime. Nothing in the build log hinted at the breakage, because the bug wasn't in compiling anything — it was in the config the builder generated for serving. Every tenant deploying that app shape through the Nixpacks default hit the identical restart loop, with identical (empty) logs pointing nowhere.

The reporter's workaround tells the rest of the story: they abandoned the auto-detect builder entirely and wrote a custom Dockerfile on serversideup/php:8.2-fpm-nginx. That fixed their app. It also meant the platform's zero-config promise — push a repo, get a running service — silently didn't hold for one of the most common PHP app shapes in existence, until someone read a GitHub issue.

Why auto-detect merged two providers into one broken nginx.conf

Nixpacks works by inspecting a repo, selecting one or more language providers, and merging their build plans into a single OCI image. PHP is detected when a composer.json or an index.php is found; Node when a package.json shows up. A Laravel + Inertia app matches both, so both providers engaged — and both had opinions about the nginx server block.

The PHP provider generated the correct Laravel block: location / with an index.php fallback so every route hits the framework front controller. The Node/static detection then generated a second location / block aimed at serving static frontend files. Two identical-location blocks in one server context is not a warning nginx tolerates — it's a fatal emerg at config load, which is exactly why the container died on start instead of serving anything. The merge logic treated "two providers matched" as "concatenate both configs" with no precedence rule for which one owns the root path.

This failure pattern isn't a one-off; it's the characteristic shape of multi-provider auto-detect bugs. The same confusion class shows up across Nixpacks deployments: a root package.json pulling the Node provider into what should have been a Python-primary build (fixed by forcing providers = ["python"] in nixpacks.toml), Python-plus-Node repos needing both toolchains force-declared because auto-detect picked the wrong primary, services failing with "unable to generate build plan" until a nixpacks.toml pinned the provider explicitly. Every one of these is the same lesson wearing different clothes: auto-detection is a guess, and a shared default guess with no per-app override story is a single point of failure for every tenant whose repo doesn't match the guess.

The fix wasn't a patch — it was a per-app config file

Here's the part operators should sit with: the issue was closed the same day it was filed, not by a Nixpacks code change but by a maintainer reply — "in cases like this you should add a nixpacks.toml to your repository" — pointing at Coolify's own Laravel + Inertia docs. The platform's answer to a broken shared default was per-app configuration: pin your providers, set your PHP root dir, stop letting the guesser guess.

That's the right answer for one user and an indictment as a platform posture. nixpacks.toml gives you the levers — declare providers, set NIXPACKS_PHP_ROOT_DIR to /app/public, drop in your own nginx.template.conf — but every lever requires the tenant to already know the default is dangerous for their app shape. The tenants who hit the restart loop were definitionally the ones who didn't. And because the fix lived in tenant repos rather than in the builder default, the next Laravel + React deploy from a user who hadn't read the docs would break identically.

There is a deeper reason the default stayed dangerous: by January 2026, Nixpacks was already on its way to maintenance mode. Railway — Nixpacks' creator — had announced Railpack as its successor and, in its own words, moved on from Nix because Nixpacks "works great for 80% of users" while the other 200,000 hit limitations daily, a ceiling the team needed to break to scale from 1M toward 100M users. Railway's docs have since been purged of Nixpacks references entirely (deleted pages redirect to /builds/railpack), and railway.json now recognizes only RAILPACK and DOCKERFILE as builder values. An upstream in maintenance mode still receives the bugs its downstream fleet finds; it just doesn't fix them with the urgency of an active project. Coolify and Dokploy kept shipping Nixpacks as a default long after its author moved on — which is precisely how a January provider-merge bug becomes every Laravel tenant's problem instead of a patch release.

2026's builder shakeout in one table

The incident landed in the middle of a genuine changing of the guard. Where each platform's build layer stood by mid-2026:

PlatformBuilders offeredDefault direction
RailwayRailpack, DockerfileRailpack only; Nixpacks deprecated, docs removed
DokployNixpacks, Dockerfile, Railpack, Heroku buildpacks, Paketo buildpacks, staticMulti-builder menu (six build types in the API schema)
Coolify v4Nixpacks, Dockerfile, Docker Compose, staticStill Nixpacks-first; Railpack support an open community request (discussion #5282)
Community (Coolify forks)Backfilling Railpack + morecoolify-enhanced PRD explicitly adds the build types v4 lacks

Two numbers explain Railway's side of the table: Railpack produces images roughly 38% smaller for Node and 77% smaller for Python than Nixpacks, via a BuildKit-graph approach instead of Nix package assembly. The Dokploy side shows the opposite strategy — don't pick a winner, offer the menu — with Heroku and Paketo buildpacks sitting next to both Nixpacks and Railpack so a tenant can route around a broken default without leaving the platform. Coolify's row is the cautionary one: four build types, Nixpacks still first, the Railpack request living in a discussion thread while a community fork does the backfill.

The through-line: the single shared auto-detect default is the fading model. Every platform is converging, by different routes, on "the tenant picks (or pins) a builder, and the platform runs a choice of them." The January incident is what the old model costs when the guess is wrong.

Two defenses so one upstream bug can't break every tenant at once

So here are the two defenses, specified tightly enough to implement — with what each would have caught in January.

1. Per-app builder pinning as a first-class setting, not tribal docs knowledge. The nixpacks.toml-in-the-repo answer works, but only for tenants who know to add it. A platform should expose builder choice the way it exposes environment variables: a per-app setting (builder: nixpacks | railpack | dockerfile | heroku | paketo, plus a pinned builder version), defaulted sensibly, changeable without redeploying the platform, and — critically — visible in the deploy log ("built with nixpacks 1.x, providers: php, node").

Had Coolify's Laravel path shipped with a pinned, known-good builder config for the composer.json + package.json shape — or even surfaced "two providers matched" as a warning at build time — the restart loop would have been a support ticket about a warning, not a mystery outage. The rule: any auto-detect decision that can pick wrong must be overridable per app from the dashboard, and the override must be settable before the first deploy, not discovered after the first crash.

2. A build-matrix CI check over representative fixtures, run before every builder bump. The platform pins a builder version; CI verifies the pin against a fixture matrix before it rolls to tenants. The fixtures must include the shapes real tenants push:

  • Laravel + Inertia/React (the January repro)
  • A monorepo with a root package.json over a Python backend (the providers = ["python"] confusion case)
  • Plain PHP, plain Node, and one Dockerfile app as the control

Each fixture deploys end-to-end and must serve traffic, not merely build — January's bug passed the build and died at boot, so a matrix that only checks exit codes would have waved it through. Assert on the generated artifacts too: nginx -t the emitted config, fail on duplicate location blocks, diff the provider list against the previous builder version and flag newly matched providers as a breaking-change candidate. Run it on every builder version bump and nightly against upstream latest so maintenance-mode drift still gets noticed.

Either defense alone would have contained January: pinning lets the Laravel tenant route around the bad default in one click; the matrix catches the duplicate block before the builder version carrying it reaches any tenant. Together they're the actual fix for the class, not the instance — because the next provider-merge bug won't be PHP-plus-Node, and the tenants it hits won't have read this post either.

Treat the builder as a dependency with a blast radius

Zoom out and the January incident is a supply-chain story wearing a framework costume. Your builder is a dependency every tenant implicitly takes: it moves on upstream's schedule, it guesses on every deploy, and when it guesses wrong the blast radius is "every app of that shape on your platform." Most self-hosted PaaS operators pin their base images, their Postgres versions, their Traefik releases — and then let the builder float at platform default with no fixture coverage. That asymmetry is the bug behind the bug.

The 2026 shakeout is quietly fixing the defaults — Railpack's rise, Dokploy's menu, even the community forks dragging Coolify forward. But defaults only protect you if you adopt them deliberately: pick your builders, pin them per app, and prove every bump against fixtures shaped like your tenants' real repos. The green build that never serves traffic is the most expensive kind of passing test. Make your platform unable to ship one.

Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own. Star the repo on GitHub or deploy your first app today.

Related articles

Run this on infrastructure you own

bex is the open-source, AI-native Render alternative — push a git repo and get a running HTTPS service on your own machines.

Get started with bex