The Redirects That Vanished: vercel.json, Prebuilt Deploys, and 308s Rebuilt as Routes
vercel deploy --prebuilt reads .vercel/output/config.json, not vercel.json — so six redirect rules silently stopped firing and /syslog answered 404. Commit 4e238be re-emitted them as routes[] entries, and 19d1b23 taught the probes about trailing slashes. Real code, real ordering constraints.
For an unknown number of days, /syslog, /case-studies, and /projects answered 404. The redirects were right there in vercel.json: six rules, permanent: true, nothing exotic. The problem was that nothing in production was reading vercel.json at all. Two commits (4e238be and 19d1b23) rebuilt Vercel redirects as prebuilt routes, and the ordering constraint is what makes the whole thing work.
The setup: vercel.json is documentation under —prebuilt
This repo deploys with vercel deploy --prebuilt, which uploads .vercel/output/ wholesale, and the edge reads .vercel/output/config.json, not vercel.json. Build Output API v3 has neither a top-level headers key nor a top-level redirects key. A normal (non-prebuilt) vercel deploy translates vercel.json server-side; a prebuilt one never looks at it.
The redirect set itself is unremarkable: legacy path aliases kept for SEO:
{
"redirects": [
{ "source": "/syslog/:path*", "destination": "/blog/:path*", "permanent": true },
{ "source": "/syslog", "destination": "/blog", "permanent": true },
{ "source": "/case-studies/:path*", "destination": "/work/:path*", "permanent": true },
{ "source": "/case-studies", "destination": "/work", "permanent": true },
{ "source": "/projects/:path*", "destination": "/work/:path*", "permanent": true },
{ "source": "/projects", "destination": "/work", "permanent": true }
]
}
The failure mode was silent: after the old stub pages were deleted there was no static file to serve either, so /syslog didn’t fall back to anything. It answered 404 instead of 308. No error, no warning, just dead legacy URLs. If you’ve read the CSP outage postmortem, you’ll recognize the shape: a config the edge ignores fails exactly like a config that doesn’t exist.
Commit 4e238be: emit redirects as routes[]
The fix (commit 4e238be, fix(deploy): emit vercel redirects into prebuilt config + verify live) extends the existing header-emission machinery in scripts/finalize-csp.mjs. That is the same script that already translated vercel.json headers into routes[] entries; it now also translates redirects[]. The core translator lives in scripts/lib/vercel-headers.mjs:
export function toRedirectRoute(entry) {
// ...validates has/missing, maps :params to $N captures...
let status;
if (typeof entry.permanent === 'boolean') {
status = entry.permanent ? 308 : 307;
} else if (entry.statusCode !== undefined) {
status = entry.statusCode;
} else {
status = 308;
}
// No `continue`: like the canonical converter, a redirect route terminates
// matching. Header routes placed AHEAD of it still apply — their
// `continue: true` headers merge into the redirect response.
return { src, headers: { Location: location }, status };
}
Three deliberate choices here. First, permanent: true becomes 308, false becomes 307: byte-identical to what non-prebuilt vercel deploy derives server-side, so the two deploy modes can’t drift. Second, entries using has/missing conditions are rejected with an error rather than approximated, refusing to ship a route that matches more broadly than intended. Third, redirect routes carry no continue: they terminate matching, exactly like the canonical converter.
The merge order is the whole game, and the comment in mergePrebuiltRoutes states it outright:
export function mergePrebuiltRoutes(config, headerRoutes, redirectRoutes) {
// ...
return { ...config, routes: [...headerRoutes, ...redirectRoutes, ...kept] };
}
Headers first, redirects second, filesystem handler last. Headers ahead so redirect responses keep the security headers (their continue: true merges into the terminating redirect response, verified live by verify-prod-csp.mjs, which asserts security headers on redirect responses too). Redirects before the filesystem handler so they fire instead of falling through to static files. The merge is idempotent. Re-emitted srcs are dropped first so re-running never duplicates routes, and finalize-csp.mjs asserts contiguity, count parity with vercel.json, handler position, and no unmapped :param leakage into Location targets. Any violation fails the build loudly rather than shipping a silent SEO regression.
Commit 19d1b23: the trailing-slash probe fix
With redirects emitting, the live verifier (scripts/verify-prod-csp.mjs) probes each rule with redirect: 'manual' so the 308 itself is observed rather than followed. Wildcard :path* sources get exercised with a sample /probe-depth sub-path. That verifier immediately caught a real edge behavior: Vercel normalizes bare-destination Locations with a trailing slash, so /syslog answers Location: /blog/ even though vercel.json says /blog. Commit 19d1b23 (fix(deploy): trailing-slash-tolerant redirect probes) made the comparison slash-insensitive:
// The edge normalizes bare-destination Locations with a trailing slash
// (/syslog -> /blog/ even though vercel.json says /blog), so compare
// slash-insensitively: strip one trailing "/" from both sides, except
// the root "/" itself.
const normalizeLocation = (value) =>
value.length > 1 ? value.replace(/\/$/, '') : value;
This is a probe fix, not a behavior fix. The redirect was correct all along; the assertion was too strict. Distinguishing those two cases is the entire value of a live verifier: without it, you’d “fix” a working redirect to satisfy a wrong test.
Honest limitations
- Unknown dark window. I don’t know how long the redirects were dead. There was no alerting on 404s for legacy paths, only the post-fix verifier. The slim CI rebuild added pipeline efficiency but no synthetic monitoring; a scheduled probe of
/syslogwould have caught this on day one. - Emitter covers a subset of vercel.json.
has/missingconditions, locale-aware redirects, and anything beyondsource/destination/permanent/statusCodethrows rather than translates. Today that’s correct (the six rules use none of it), but the next person to add a conditional redirect gets a build failure with no migration path. The error message explains what but not how to extend the emitter. - Probe depth is one. Wildcards are exercised with a single
/probe-depthsample; a regex regression that breaks only multi-segment paths (/syslog/a/b) would pass verification. Cheap to extend, not yet extended. - No measured SEO impact. I have no Search Console data quantifying lost crawl equity during the outage. Only the mechanism (404 where 308 belonged) and the fix. Like the KV teardown, the dollar figure I’d want doesn’t exist; I’m reporting the diff, not the damage.
- vercel.json is still the source of truth by convention only. Nothing stops a future edit from adding headers or rewrites that the emitter doesn’t translate. The count-parity assertion covers redirects; headers have their own assertions; rewrites aren’t used at all. But the coupling is maintained by discipline plus assertions, not by a single code path.
Related logs
What Prometheus Sees: Monitoring 10 Branches from One Grafana
The unified-infrastructure case claims Prometheus and Grafana observability — this is the architecture behind the claim: what gets scraped, what the dashboard shows, what fires alerts, and the one blind spot the design still carries.
Tally Prime on RDS RemoteApp: The Day-2 Shape (Sessions, Licenses, Certificates)
The RDS cutover posts cover the migration night — this is what operating it looks like after: session-broker license management, timeout policies at peak hours, the 60-day certificate cycle, and the connector you still have to watch.