Skip to main content

Railpack Is Railway's Default Builder: What the Nixpacks-to-BuildKit Rewrite Teaches Self-Hosted PaaS Builders

12 min readDora NodaDora Noda
Share
On this page

Railway spent three years and 14 million app builds learning exactly where auto-detected builders break — then threw the builder away and rewrote it from scratch. The replacement, Railpack, is now the default for every new Railway project, with Nixpacks in maintenance mode. Base Node images shrank 38%, base Python images 77%, and one independent self-hoster measured the same app dropping from 76 seconds and 337 MB on Nixpacks to 42 seconds and 152 MB on Railpack.

That is the sentence this post exists to unpack: the builder is not plumbing, it is the product. Every deploy pays for your builder's worst decision in minutes of build time and megabytes of image pull, and Railway's rewrite is the most expensive public case study ever published on which decisions those are. If you run a git-push PaaS on machines you own — especially one built on Cloud Native Buildpacks — here is what broke, what fixed it, and what you should borrow versus leave alone.

The numbers first: what the rewrite actually bought

Railpack was announced in March 2025 as a from-scratch Go rewrite that talks to BuildKit directly instead of generating Dockerfiles around a Nix store. A year later the migration is effectively complete: new Railway services default to builder = "RAILPACK", Nixpacks is in maintenance mode, and the ecosystem is following — Coolify added Railpack as a beta build pack in v4.1.0, and self-hosted platforms like Pier and Sevalla now offer it as their auto-detect path.

Railway's headline claims come from its own announcement: 38% smaller base Node images, 77% smaller base Python images, exact major.minor.patch runtime versions, and cache hits shareable across environments. Vendor numbers deserve independent confirmation, and the self-hosting community has supplied some. One operator benchmarking the same application on both builders reported:

BuilderBuild timeImage size
Nixpacks76s337 MB
Railpack (cold)42s152 MB
Railpack (warm cache)37s152 MB

A 45% faster cold build and a 55% smaller image, on a workload Railway didn't choose. The gap between "vendor's best case" and "a stranger's Tuesday deploy" is small enough to take the architecture seriously. So what was wrong with the old architecture?

The honest caveat belongs here too, not in a footnote: Railpack still trails Nixpacks on language breadth, it demands a privileged BuildKit daemon your Nixpacks setup never needed, and operators have already found sharp edges in cache growth and failure behavior. Section 5 covers each with receipts. Readiness is real but bounded — which is exactly why the lessons transfer even if you never run Railpack itself.

What actually broke in Nixpacks

Nixpacks did something remarkable: it turned "push code, get a container" into a commodity across a dozen languages. But Railway's own retrospective names three failure mechanisms, and each one is a lesson about auto-detected builders generally.

Commit-pinned versioning broke previously green builds. Nix ties every package version to a commit SHA in the nixpkgs repo, and only the latest major version of each package is reliably available. Supporting patch versions meant maintaining lookup tables mapping version strings to commit hashes — dozens of entries per language, opaque to any contributor who didn't already understand Nix's version model.

Worse, bumping the pinned commit to pick up one package's new version silently moved every other package too. Railway's retrospective is blunt about the asymmetry: "We feel bad when users can't access the latest packages, but feel worse when previously functional builds suddenly fail." For languages like Node and Python, the team gave up and shipped only the latest major. An auto-detect builder that can't pin what it detected isn't reproducible; it's a slot machine that pays out most days.

The single /nix/store layer defeated the entire point of layers. Nixpacks pulled build-time and runtime dependencies alike into one giant Nix store layer. No splitting, no granularity — every dependency, compiler toolchain included, shipped in the final image whether the running app needed it or not. Railway notes this wasn't Nix's fault so much as how Nixpacks used it, but the effect was the same: images carried hundreds of megabytes of build tooling into production, and every deploy pulled it all again. When your image has one meaningful layer, you have reverted to shipping tarballs with extra steps.

Caching missed where it mattered. Because the dependency set resolved through Nix evaluation into that monolithic store, incremental builds rarely reused prior work efficiently, and caches couldn't be shared across environments. Nixpacks worked great for 80% of users — but at Railway's scale, the remaining 20% was 200,000 users hitting these limits daily. That is the number that forced the rewrite: not a corner case, a population.

How Railpack fixes it: LLB, Mise, and layer control

Each fix in Railpack maps one-to-one onto a Nixpacks failure, which is what makes this rewrite such a clean case study.

Custom BuildKit LLB replaces generated Dockerfiles. Instead of emitting a Dockerfile and shelling out, Railpack generates BuildKit's low-level build graph (LLB) through its own frontend and solves it against a BuildKit daemon. This is the single most consequential decision: controlling the build graph directly means controlling exactly which filesystem operations become which layers, which operations run in parallel, and which cache keys attach to what.

The 38–77% image reduction is mostly this — fine-grained layers where build tooling and runtime artifacts finally live in different places, plus graph-level parallelism Dockerfiles can't express. It also explains the Rust-to-Go rewrite: BuildKit's client libraries are Go, and Railway chose to live where the ecosystem lives rather than maintain foreign-language bindings for its most critical path.

Mise replaces Nix for runtime versioning. Railpack resolves language runtimes through Mise (the polyglot version manager), which supports exact major.minor.patch versions instead of Nix's approximate, commit-coupled ones. One operator's migration report captures the practical difference: a Nixpacks workaround config pinning Node 24 through nixpkgs gymnastics collapsed to Railpack simply honoring the repo's existing engines.node field. Version precision moved from "the builder's lookup table" to "the developer's manifest" — where it belonged all along.

Direct cache control replaces hope. Because Railpack owns the LLB graph, it owns cache keys: more granular invalidation, more hits on rebuilds, and caches shareable across environments instead of trapped per-service. The warm-cache number in the table above (37s vs 42s cold) understates this on purpose — it's one app, not a fleet. At fleet scale, shareable caches are the difference between every service rebuilding the same Node 22 layer and building it once.

Head-to-head: does Cloud Native Buildpacks inherit the same ceiling?

The question every Paketo-based platform should be asking: is Nixpacks' ceiling our ceiling too? The honest answer is no — it's a different ceiling, with different trade-offs. Here's the shape of it:

DimensionNixpacksRailpackPaketo / CNBHand-written Dockerfile
Image sizeBloated (monolithic Nix store)38–77% smallerLean (slim run images, rebasing)As lean as your discipline
Rebuild speedSlow (coarse cache)Fast (fine-grained BuildKit cache)Slow-to-medium (lifecycle overhead, builder weight)Fast once tuned
Caching modelPer-build, unsharedShared BuildKit cacheLayer reuse + registry/volume cacheLayer cache, manual ordering
Version precisionApproximate (Nix majors)Exact semver via MiseExact (buildpack dependency mappings)Exact (you pin it)
Base-image updatesFull rebuildFull rebuildRebase without rebuildFull rebuild
Language breadthWide (12+ providers)Narrower, still catching upWide (Paketo suites)Unlimited
Operator burdenLow until it breaks obscurelyNeeds privileged BuildKit daemonNeeds builder/run-image lifecycleYou own everything

Two rows matter most. First, CNB never had the single-store problem: its whole design is fine-grained, reusable layers, and rebasing — swapping the underlying OS image without rebuilding the app — is something neither Nixpacks nor Railpack can do. A CVE in the base image is a rebase for Paketo and a full rebuild queue for everyone else. That is a genuine architectural advantage the rewrite doesn't erase.

Second, CNB pays for it in build latency and builder weight. Cloud Run users report 4-minute-plus CNB builds on simple services, and the builder images themselves are heavyweight artifacts your build fleet must store, update, and schedule around. Railpack's lesson for CNB shops isn't "switch builders" — it's "your caching model is the next bottleneck." Fine-grained BuildKit cache mounts, registry-backed shared caches, and layer discipline borrowed from Railpack's playbook attack CNB's weakest row without surrendering rebasing.

What the rewrite didn't fix

No rewrite this ambitious lands without new sharp edges, and the early operator reports are specific enough to plan around.

Railpack requires a BuildKit daemon, and that daemon is privileged. Nixpacks generated a Dockerfile you could build anywhere; Railpack needs a running BuildKit instance speaking its frontend protocol. For managed Railway this is invisible. For a self-hoster it means a privileged container in your build fleet, with everything that implies for multi-tenant isolation on shared builders. One self-hosted project's issue tracker puts the dilemma plainly: Railpack can't become the default fallback because instances without the BuildKit profile would break entirely. Budget the daemon, the privilege boundary, and the fallback path before you budget the migration.

The cache can grow without bound — invisibly. A Coolify operator watched the Railpack BuildKit cache volume reach 62 GB in under a week, because the buildx builder ran inside an ephemeral helper container whose metadata never landed on the host, where host-side pruning could see it. The same reporter confirmed Nixpacks had a variant of the problem, so this is a builder-cache-hygiene lesson, not a Railpack-specific bug — but Railpack's better caching makes bigger caches, faster. Any BuildKit-backed builder on owned disks needs scheduled prune with a retention policy from day one, verified against the actual volume, not assumed from the host.

Language parity still trails. At beta, Railpack supported Node, Python, Go, PHP, Java, Deno, and static sites — well short of Nixpacks' dozen-plus providers. Parity has been improving, but if your tenants deploy Elixir, Rust, or Ruby, verify your languages before promising a migration. An auto-detect builder that doesn't detect your stack is just an error message with documentation.

Failed builds can silently serve stale images. At least one operator traced a production incident to Railpack builds failing silently while the platform fell back to a months-old cached image — TypeScript annotations and all. Whether you read that as a builder bug or a platform fallback-policy bug, the operational takeaway is the same: alert on build failure distinctly from deploy success, and never let "serve the last good image" happen without a visible signal. Stale-image fallback is a reasonable degradation policy and a terrible silent default.

Borrow this, keep that: a self-hoster's decision list

If you run a CNB-based git-push PaaS on owned builders — Hetzner machines, a Cluster API fleet, a rack you can touch — here is the concrete borrow-vs-keep list this case study earns:

Borrow: fine-grained, shared BuildKit layer caching. This is the highest-value transfer. Registry-backed or shared-volume BuildKit caches, content-aware cache keys, and build/runtime layer separation apply to any builder, CNB included. Measure your fleet's cache-hit rate per language before and after; if fewer than half your rebuilds hit warm caches, this is your biggest deploy-latency lever.

Borrow: version resolution from the developer's manifest. Railpack honoring engines.node and .python-version instead of its own lookup tables is a principle, not a feature: the repo is the source of truth for what it needs. Audit every place your build pipeline overrides, ignores, or approximates a version the developer already declared.

Keep: the Dockerfile escape hatch — and widen it, don't narrow it. The un-cacheable build is not going away. A Python service with CUDA torch dependencies went from 8.5 GB to under 4 GB only through a custom build command installing CPU-only torch ahead of the dependency file — the kind of workload-specific surgery no auto-detection will ever perform. Browser-bearing images, GPU images, monorepos with custom toolchains: these need an escape hatch that is documented, supported, and first-class, not a grudging fallback. Railpack's existence makes the hatch more important, not less — auto-detection covering 95% gracefully is what makes the remaining 5% survivable.

Keep: CNB rebasing for base-image CVEs. Until something in the Railpack lineage offers rebase-equivalent updates, Paketo's ability to patch the OS layer without rebuilding every tenant app remains a unique operational superpower. Don't trade it for smaller cold-build images; fix your cache instead.

Verify before adopting: daemon topology and cache pruning. If you do trial Railpack (or any BuildKit-native builder) on shared self-hosted builders, solve privileged-daemon isolation and automated cache pruning in the pilot, not after the 62 GB surprise. These are the two line items that separate "worked in staging" from "works on a fleet."

Conclusion: the builder compounds

Railway's rewrite numbers — 38%, 77%, half the build time — are impressive, but the durable lesson is structural. A builder's decisions compound on every push, for every tenant, forever: minutes become hours of fleet build time, megabytes become terabytes of registry egress and node pull latency. Nixpacks compounded three bad decisions (approximate versions, one giant layer, unshared caches) until 200,000 daily users felt them. Railpack's fixes compound in the other direction, and most of them are portable ideas, not Railway-only infrastructure.

For self-hosted PaaS builders, the assignment is narrower than "migrate to Railpack" and more valuable: steal the caching model, respect the manifest, keep the escape hatch, and treat builder cache hygiene as production operations from day one. Your tenants will never compliment your builder. They'll just stop noticing deploys — which is the entire job.

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