The deploy was green. The site was up. And the app was completely, silently wrong.
A public 2026 deploy postmortem tells the story in one commit message: Railway's default Railpack builder classified an Angular SSR app as a static site, served its dist/.../browser output through Caddy, and never started the Node SSR server at all. No failed build, no error page, no alert. Just a production URL quietly serving the wrong architecture — static files where a server should have been. The fix was an explicit multi-stage Dockerfile and a railway.json pointed at the DOCKERFILE builder.
That incident is worth more than a bug report. It is the clearest recent demonstration of a rule every platform team eventually learns: a zero-config builder that can misclassify your app without telling you is not a feature, it is a liability. What follows is the postmortem in detail — what Railpack saw, why the app looked static, the exact before-and-after fix — plus the detect-time logging checklist a self-hosted PaaS should implement so a misclassification pages as a build event instead of shipping a silently wrong artifact.
What Railpack saw: an app that looks static
Some background first. In 2026 Railway renamed its default auto-builder from Nixpacks to Railpack, putting Nixpacks into maintenance mode. The config-as-code spec now recognizes two builder values, RAILPACK (the default for new services) and DOCKERFILE, with NIXPACKS carried as deprecated. Railway will always build with a Dockerfile when it finds one — a sentence that turns out to be the load-bearing escape hatch of this whole story.
Railpack's Node provider includes an SPA mode: it sniffs the project for single-page-app signals, runs the build script, and serves the static output with the Caddy web server from a templated Caddyfile. The detection surface keeps growing — recent Railpack releases added SPA detection for Next.js static exports (output: 'export' served from out/) and Expo Web static output, alongside existing Vite, Astro, and React Router heuristics. Operators can force SPA mode with RAILPACK_SPA_OUTPUT_DIR, or opt out with RAILPACK_NO_SPA=1 and a custom start command.
Now look at an Angular SSR project through that lens. The build emits a dist/<project>/browser directory full of static-looking output. The package.json start script is ng serve — a development server, useless in production, carrying no signal about the real production entry point. There is no production start command for Railpack to find, and there is a directory full of servable files. Static classification wins, Caddy starts serving browser/, and the actual server entry — node dist/greenbin-front/server/server.mjs in the postmortem case — is never executed.
Note what did not happen: nothing failed. The build succeeded, the deploy went green, the URL responded. Server-side rendering was simply gone — along with runtime HTML injection of values like API_URL and every SEO and first-paint benefit SSR existed to provide. The failure mode of builder autodetect is not a red build. It is a green build of the wrong thing, discovered from user reports or a search-ranking collapse weeks later.
The fix, concretely: before vs after
The postmortem's before state is the trap in miniature. The project's railway.json targeted the NIXPACKS builder — a value now deprecated and on its way to being ignored — while the platform's actual default, Railpack, autodetected the app as a static site. Two layers of "the config you think governs this build does not govern this build," stacked.
The after state removes autodetect from the loop entirely:
# Stage 1: build the Angular SSR app
FROM node:22-slim AS build
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@latest --activate
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm run build
# Stage 2: run only the SSR server
FROM node:22-slim AS runtime
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/package.json ./package.json
EXPOSE 3000
CMD ["node", "dist/greenbin-front/server/server.mjs"]Paired with a railway.json that pins the builder:
{
"$schema": "https://railway.com/railway.schema.json",
"build": {
"builder": "DOCKERFILE"
}
}(Railway uses a Dockerfile whenever one is present at the root, so the file alone changes the outcome — but the explicit builder value documents the decision and survives the next default-builder migration.)
There are lighter-weight fixes worth naming, because they draw the boundary of when the Dockerfile is genuinely required. A Dokploy community guide shows the no-Dockerfile path: give package.json a production start script that launches the SSR server instead of ng serve, and Railpack's Node provider picks it up in two steps. Alternatively, RAILPACK_NO_SPA=1 forces the provider off the static path. Both work when the only missing signal is the start command.
The Dockerfile earns its keep when you need more than a correct guess: a pinned toolchain (Node version, pnpm version) that survives builder-image updates, multi-stage pruning so dev dependencies never reach runtime, runtime environment injection into served HTML, and — most importantly — decoupling from the next heuristic change. Railpack's SPA detection is actively expanding with every release; each new sniffing rule is another chance for your app to match a pattern you never asked about. The Dockerfile is the way to say "stop detecting, I told you what this is."
The failure runs both directions
If this were one bad guess about one Angular app, it would be a bug. It is a class.
The same detect-time ambiguity fails in the opposite direction, with worse blast radius. A Railway skills issue documents a production outage where a Node project set only RAILPACK_STATIC_FILE_ROOT — the static-file provider's variable — following docs that blurred it with RAILPACK_SPA_OUTPUT_DIR, the Node SPA variable. The Node provider ran, found no SPA output variable, lost static-site detection, and went hunting for a Node start command instead. The build failed at config detection. One confused variable name, one outage.
And the deprecated-value trap from the postmortem's before state is its own incident cluster. Since Railway flipped the default to Railpack, the failure reports share a shape: config that used to govern the build is now silently ignored.
- A
nixpacks.tomlspecifying system packages for PDF generation gets ignored because Railpack readsrailpack.json— and the deploy fails with the exact error the config existed to prevent. - A uv-based Python project fails with "No start command detected" because Railpack can't infer one and the
nixpacks.tomlthat knew how is dead config. - A
builder: NIXPACKSvalue inrailway.jsonis ignored while the service quietly builds the wrong image — flagged, in that case, only because a code reviewer noticed the invalid enum.
The common thread is fail-open semantics. Unknown config, deprecated values, and ambiguous detection all resolve to something the operator didn't ask for — a guess, a default, silence — instead of an error that names the replacement. Every zero-config builder accumulates these sharp edges as it grows; the question is never whether autodetect can be perfect. It is whether the platform treats "I don't understand your config" as an error with a pointer or as permission to guess.
What a self-hosted PaaS should log at detect time
Here is where running your own platform turns from a burden into an advantage. On a hosted PaaS, a misdetection is a support ticket and a forum thread. On a fleet you own, the builder is your code, and detect time is just another event you can make observable. Five lines in the build log would have turned every incident above from a mystery into a routine build event:
- Detected stack, with confidence and evidence. Not
Detected: nodebutDetected: node-spa (confidence: high; signals: build script contains 'build', output dir dist/<project>/browser present, no production start command found). The operator should see why the builder believes what it believes, citing the exact files and scripts that fired. - Config files honored vs ignored.
Using railpack.json (root). Ignoring nixpacks.toml (builder is railpack; this file is only read by the deprecated nixpacks builder).Every ignored config file is a future incident; naming it at build time converts it into a warning the author sees on the first deploy, not the fifth outage. - The resolved plan on one screen. Builder, install command, build command, start command, output directory, serving process. If the plan says
serve: caddy → dist/greenbin-front/browserfor an app the team knows has a server, the mismatch is visible before the URL goes live. - The exact override point. Every detected decision should print its pin:
to pin this decision, set RAILPACK_NO_SPA=1 or add a Dockerfile (Dockerfile always wins). Autodetect without a documented override is a guess you cannot argue with. - Fail closed on unknown or deprecated values. An unrecognized builder name, a deprecated enum, a config key from a retired schema — each should fail the build with the replacement spelled out, not resolve to a default.
builder "NIXPACKS" is deprecated; use "RAILPACK" or "DOCKERFILE"costs one failed build and saves every silent one after it.
With those five, a misclassification becomes what it should have been all along: a build event with a decision trail, routable to whoever owns the service, pageable before traffic moves. Without them, the platform ships its guesses with production confidence and lets the reader's SEO graph do the monitoring.
Autodetect owes you a log line and an exit
Builder autodetect is genuinely good at its job — most apps deploy with zero config, and that onboarding magic is why every PaaS invests in it. But the Angular-SSR-as-static-site incident draws the contract the magic has to honor: every guess must be logged, and every guess must be overridable. A builder that detects without evidence and offers no Dockerfile door is not zero-config. It is zero-recourse.
For teams running their own git-push platform, the takeaway is concrete and cheap: instrument detect time before you expand detection. Log the stack, the confidence, the evidence, the ignored files, and the override for every build. Fail closed on anything you don't recognize. The next framework heuristic you add will misfire on somebody's app — the only question is whether they find out from your build log or from their users.
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.



