Skip to main content

Railway Skips the Post-Merge Rebuild: Promote the Exact Preview Image to Production With BuildKit Digests

10 min readDora NodaDora Noda
Share
On this page

You merge the pull request at 4:59. It is live in production at 5:00 — not because the build was fast, but because there was no build at all. That is the promise of Railway's Skipped Builds: when the merged commit is identical to code already built in a PR environment, Railway deploys the cached image directly instead of running a second build. The feature launched as an experimental per-service flag in March 2026 and went live for all users that April, and it quietly fixes one of the oldest sins in deployment pipelines: rebuilding "the same" source after merge and hoping the bytes come out identical.

The self-hosted recipe up front: record a commit-to-digest provenance entry every time a PR build finishes, and on merge, deploy image@sha256:<digest> for the merged commit instead of triggering a fresh build. Back the PR builds with BuildKit registry caching, pin production deploys to immutable digests rather than mutable tags, and keep preview images retained until the merge lands. The section below gives you each piece in buildable detail; the rest of the post covers why the second build was always the risk, the environment-variable gotcha Railway documents honestly, and where Coolify and Dokploy stand today.

What Railway actually shipped

The announcement came from Railway engineer timomeh on Central Station in March 2026: Skipped Builds, an experimental feature that "skips the build step when the same source code was already built in a previous deployment." A month later, the same thread carried the update teams were waiting for: "Shipped! This is live for all users now." The full behavior is documented at docs.railway.com/builds/skipped-builds.

The flow is deliberately narrow. You open a pull request, Railway builds it in a PR environment, you iterate, and then you merge. If your PR was up to date and no other commits landed on the target branch in the meantime, the merged commit matches code that was already built — so Railway deploys the cached image to production with the new environment's variables applied at runtime. No repo clone, no dependency install, no build steps. Bugfixes go live in seconds.

Railway is explicit that this is not layer caching with better marketing. Build layer caching speeds up a build by reusing intermediate layers; a build still runs. With skipped builds, no build happens at all — the previously built image is deployed directly. The opt-out surface is equally explicit: redeploying from the Deployments tab, deploying the latest commit from the command palette, and railway up from the CLI always rebuild, even with the flag on.

Two details in the docs deserve attention because they shape any self-hosted reimplementation. First, the merge-time condition: the trick only works when the merged result is byte-identical source to something already built, which in practice means an up-to-date PR and a quiet target branch. Second, only source code is compared — environment variables are not factored in. That second point is the gotcha the whole feature pivots on, and Railway deserves credit for documenting it instead of burying it. More on that below.

The self-hosted recipe: promote by digest

Here is the part you came for: everything Railway's flag does can be rebuilt on your own infrastructure from three boring primitives — a provenance record, a merge-time lookup, and a cache policy. No new control plane required.

Step 1: Record provenance at PR-build time. Every time your pipeline builds a PR head, write one row mapping what was built to what came out: the commit SHA, the resulting OCI image digest, the repository, and a timestamp. A minimal record looks like this:

pr_head_shatree_hashimagedigestbuilt_at
9f3a1c24bd2…e91aregistry.internal/shop/apisha256:7c4e…b2f02026-09-22T14:02:11Z

Include the git tree hash (git rev-parse HEAD^{tree}), not just the commit SHA. This matters because squash merges create a brand-new commit object: the merged commit's SHA will never equal the PR head SHA, but the tree hash matches when the merged content is identical to what you tested. Comparing tree hashes is what makes the lookup work under every merge strategy, not just fast-forwards.

Step 2: Look up, don't rebuild, at merge time. On push to your production branch, compute the tree hash of the merged commit and check the provenance table before triggering any build. On a hit, deploy the recorded digest directly — registry.internal/shop/api@sha256:7c4e…b2f0 — with production variables applied at runtime. On a miss (stale PR, target branch moved, first-ever deploy), fall through to a normal build and record its provenance too. The decision is five lines of glue in whatever receives your merge webhook:

bash
tree=$(git rev-parse "$MERGED_SHA^{tree}")
digest=$(provenance_lookup "$tree")  # empty on miss
if [ -n "$digest" ]; then
  deploy --image "registry.internal/shop/api@${digest}" --env production
else
  build_and_record "$MERGED_SHA"
fi

Step 3: Back PR builds with BuildKit cache policy. The promotion path only helps when the merge-time lookup hits; the fallback build path should still be fast. Export PR build cache to the registry so every build — preview or production — shares one cache namespace:

dockerfile
# syntax=docker/dockerfile:1
RUN --mount=type=cache,target=/root/.npm npm ci
bash
docker buildx build \
  --cache-from type=registry,ref=registry.internal/shop/api:buildcache \
  --cache-to type=registry,ref=registry.internal/shop/api:buildcache,mode=max \
  --push -t "registry.internal/shop/api:pr-${PR_NUMBER}" .

Registry-backed cache (or inline --cache-to type=inline if you prefer cache riding along on the image itself) means the layers from the PR build are exactly the layers a fallback production build reuses. Combined with cache mounts for package managers, the miss path degrades to a fast incremental build instead of a cold one.

Step 4: Pin production deploys to digests, never mutable tags. The whole scheme collapses if production tracks :latest or a branch tag, because a mutable tag can be re-pointed between the test and the deploy. Your deploy manifests, compose files, or GitOps overlays should reference the immutable sha256: digest from the provenance record. Tags are fine as human-readable aliases; the digest is what the scheduler pulls.

Step 5: Retain preview images until the merge lands. A provenance record pointing at a garbage-collected image is a miss with extra steps. Exempt digests referenced by open-PR provenance rows from registry retention and garbage collection, and keep merged digests for a grace window (a week is a sane default) so rollbacks promote the same way deploys do. If you run Harbor, Nexus, or zot, this is a retention rule plus a "do not delete referenced manifests" policy — not a new system.

That is the complete loop: build once per unique tree, record the digest, promote the tested bytes. Everything else in this post is the argument for why it matters and the guardrails for running it safely.

The gotcha Railway documents honestly

Skipped builds compare source code, not environment variables — and that distinction has teeth. If your build inlines variable values into its output bundle, the promoted image carries the values from the environment where it was built, not the one where it lands. The two classic offenders are NEXT_PUBLIC_* variables in Next.js and VITE_* variables in Vite, both baked into client bundles at build time. Promote a PR-environment image carrying preview API keys and preview endpoints into production, and production quietly talks to preview infrastructure.

Railway's rule is blunt and correct: only enable skipped builds when your build output is independent of environment variables. If your build inlines variables, you have three options. First, keep the skip disabled for that service — the honest default. Second, restructure the app so configuration resolves at runtime: server-injected config endpoints, runtime process.env reads on the server side, or build args explicitly excluded from the skip comparison. Third, move anything that must execute before every deployment — database migrations, cache warming — out of the build and into a pre-deploy command, which Railway runs after the build step and before the application starts, so it executes on every promotion whether or not a build ran.

The self-hosted recipe inherits all of this unchanged. Audit every service for build-time variable inlining before you turn the merge-time lookup on, and treat "migrations run in the Dockerfile" as a bug the promotion path will expose: a skipped build means a skipped migration unless the migration lives in a release phase that runs on every deploy. This is the one section of the post you should not skim. The digest guarantees the bytes are identical; only your build hygiene guarantees the bytes are environment-independent.

Why the second build was always the risk, not just the wait

Faster deploys are the headline, but identical deploys are the point. Rebuilding after merge reintroduces every nondeterminism source your lockfiles and Dockerfiles failed to pin: floating dependency ranges resolving to a version published between the PR build and the merge build, apt-get install pulling a newer package, timestamps and build IDs embedded in artifacts, and a floating base image tag (node:22, python:3.12-slim) resolving to a different digest than it did an hour ago. Same source, different bytes — and the bytes running in production are ones no preview environment ever exercised.

Promoting the tested digest collapses that gap to zero. It is the oldest advice in container delivery — build once, promote the artifact through environments — finally applied to the PR-to-production seam that most PaaS workflows quietly rebuilt across. The seconds-instead-of-minutes deploy time is real, and for a hotfix at an awkward hour it is the part you feel. But the supply-chain property is the part your future incident review cares about: the image in production is content-addressed proof of what you tested, not a rebuild's promise that it came out the same.

Where Coolify and Dokploy stand

The self-hosted single-box platforms both cover the preview half of this story. Coolify (around 54k GitHub stars as of spring 2026) spins up isolated preview deployments per pull request with unique URLs and tears them down on merge or close; Dokploy (around 34k stars) ships its own preview-deployments flow with the same lifecycle. What neither does today is the second half: the merge still triggers a fresh production build rather than promoting the preview artifact by digest.

There are even small steps backward to be aware of. Coolify v4.0 has a known quirk where certain configuration changes — environment variable edits in some deployment types — trigger a full rebuild from scratch, adding minutes to what should be a restart. Dokploy at least restarts the existing image on container restart without rebuilding. Neither behavior is a digest-promotion pipeline; both are reminders that the self-hosted world still treats "deploy" as "build, then run" in one fused step.

The recipe above slots into exactly that seam. You do not need to replace your platform to get it: the merge-time lookup lives between "merge webhook received" and "build triggered," wherever your pipeline draws that line today. Record provenance in the PR build your platform already runs, and promote the digest on merge before the platform's default rebuild path fires.

The adoption checklist

If you are bringing digest promotion to your own pipeline this week, work the list in order:

  1. Provenance record — write commit SHA plus tree hash to image digest on every PR build; the tree hash is what survives squash merges.
  2. Merge-time lookup — check the table before building on production-branch pushes; deploy image@digest on hit, build-and-record on miss.
  3. BuildKit cache policy — registry-backed cache shared across PR and production builds so the miss path stays incremental.
  4. Retention guardrails — exempt open-PR and recently merged digests from registry garbage collection.
  5. Environment-inline audit — verify no service bakes NEXT_PUBLIC_*, VITE_*, or equivalent values into its bundle; move migrations to a pre-deploy/release phase.

Railway's version of this is a feature flag. Yours can be a lookup table, a cache flag, and a retention rule — the same guarantee, on machines you own, with the tested bytes and the shipped bytes finally being the same thing.

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