This week I moved four static Astro sites off Vercel and onto a plain container host. The builds were the easy part: npm run build, copy dist/ into a web server image, done. What almost went wrong is everything Vercel had been doing around the build without my thinking about it.
The failure that does not show up
A static site on Vercel is not only a folder of HTML. vercel.json holds redirects, headers, cleanUrls and trailingSlash, and the platform applies them at the edge. Copy the same dist/ behind a default file server and:
- the home page answers 200,
- the build is green,
- the deployment is marked healthy,
- and every inherited 301, every
/go/...short link and every extensionless URL returns 404.
Nothing reports it. No build step knows those rules existed. You find out weeks later in Search Console, or you never do. Between them the four sites carry 64 redirects, most inherited from older URL structures, plus header rules. Retyping them by hand is how one gets dropped.
What had to be rebuilt in the web server
I went with Caddy, and ended up translating five things:
-
Redirects, in file order. Vercel evaluates them top to bottom, so the Caddyfile wraps them in a
routeblock to keep that order. Patterns like/old/:slugand/docs/:path*becomepath_regexpmatchers, and:slugor$1in the destination become capture group placeholders. -
Host conditions.
has: [{ "type": "host", "value": "www.example.com" }]becomes ahostmatcher on the same rule. -
Headers. The catch-all
/(.*)block becomes one globalheaderdirective, path-specific ones get a named matcher. -
cleanUrlsandtrailingSlash: false. A redirect from/page.htmlto/page, a redirect away from the trailing slash, thentry_files {path} {path}/index.html {path}.html. -
Language negotiation on
/. One site had a smallmiddleware.tssending visitors to/fror/esfromAccept-Language. In Caddy that is aheader_regexpmatcher and a 302, withVary: Accept-LanguageandCache-Control: no-storeso no cache pins one visitor's language for everyone. Crawlers send noAccept-Language, so they keep the default root.
One detail I would not have guessed: short links. A rule on /go/docs does not match /go/docs/, and links copied around the web come in both forms. The generated matcher lists both.
Refuse, do not skip
The part I care most about is what the script does with a rule it cannot translate. rewrites, routes, missing, and has conditions other than host stop it with an error naming the rule. A warning would scroll past, and a skipped redirect is exactly the silent 404 the whole exercise is meant to avoid.
Two things that bit me anyway
-
npm cifailed in the container on one site because the lockfile had drifted frompackage.json. Vercel had been tolerating it. Decide whether you want to fix the lockfile or usenpm installin the build stage, but find out before the cutover. -
Verify on the served site, not the config. After each deploy I requested a sample of old URLs against the container's public address and checked the status code and the
Locationheader. A Caddyfile that parses says nothing about whether your patterns mean what you think.
The script
It is one Python file, standard library only: it reads vercel.json and writes a Caddyfile, a two-stage Dockerfile (Node build, then caddy:2-alpine) and a .dockerignore.
python3 vercel-to-caddy.py <repo> --lang-root fr,es --out dist
Source, a worked example and the list of what is and is not handled: gitlab.com/ler.eric/vercel-to-caddy. It only covers static output; anything that needs functions or server rendering is out of scope.
I do this kind of migration and site work for clients at Z-AX. If you have a vercel.json pattern the script refuses, open an issue with the rule: the refusal list is where it should grow next.
Top comments (0)