M
ost teams don't plan to leave Vercel. They end up there after the bill stops making sense, or after hitting a scaling ceiling they didn't expect, or because someone finally asked why the same compute that costs $8/month on a VPS costs $80/month on a managed platform.The decision to migrate is usually straightforward. The migration itself is where teams get into trouble.
Next.js has deep integration with Vercel's infrastructure. Image optimisation, Incremental Static Regeneration, edge middleware parts of your app may be silently depending on Vercel-specific behaviour that doesn't exist anywhere else. Most teams discover this mid-migration, not before.
This is a guide for doing it in the right order. We'll cover what actually changes when you self-host Next.js, how to containerise the app correctly, which Vercel features need attention before you cut traffic, and how to run both environments in parallel so you don't take the business down during the switch.
What actually changes when you self-host Next.js
Vercel does several things automatically that you'll now own yourself.
Builds. Vercel builds your app on every push. Self-hosted, you need a CI pipeline that runs next build and ships the output somewhere. GitHub Actions is the standard choice. This is straightforward it's just a pipeline step.
Routing. Vercel handles routing at the edge, including rewrites, redirects, and headers defined in next.config.js. Self-hosted, your reverse proxy (nginx or an AWS ALB) handles this. Rewrites and redirects still work because Next.js handles them at the application layer but edge-level rewrites, if you're using them, need to move to your proxy config.
Environment variables. Vercel manages environment variables per deployment environment (preview, production). Self-hosted, you manage them yourself. That means .env files for local development, secrets management for production. AWS Secrets Manager, Doppler, or plain environment variables injected at container start are all valid depending on your team's existing tooling.
SSL and domain. Vercel provisions and renews certificates automatically. Self-hosted, Certbot handles this if you're running nginx directly, or your cloud provider handles it if you're behind a load balancer. It's a one-time setup, not ongoing work.
Logs and observability. Vercel surfaces logs in its dashboard. Self-hosted, logs go where you send them. This is a forcing function to actually set up proper observability which you probably should have had anyway.
The one Vercel-managed thing that doesn't move cleanly is edge middleware. We'll come back to that because it's where teams get hurt.
How to containerise a Next.js app correctly
The key configuration change is one line in next.config.js:
Untitled1module.exports = {2 output: 'standalone',3}
With output: 'standalone', Next.js produces a self-contained build in .next/standalone that includes only the Node.js server and the dependencies it needs to run. No node_modules at the project root. The resulting Docker image is typically 80-120MB rather than the several hundred MB you'd get from a naive COPY . . approach.
Your Dockerfile looks like this:
Untitled1FROM node:20-alpine AS base23FROM base AS deps4WORKDIR /app5COPY package.json package-lock.json ./6RUN npm ci78FROM base AS builder9WORKDIR /app10COPY --from=deps /app/node_modules ./node_modules11COPY . .12RUN npm run build1314FROM base AS runner15WORKDIR /app16ENV NODE_ENV=production17ENV PORT=30001819COPY --from=builder /app/public ./public20COPY --from=builder /app/.next/standalone ./21COPY --from=builder /app/.next/static ./.next/static2223EXPOSE 300024CMD ["node", "server.js"]
The server.js in .next/standalone is the Next.js production server. It handles SSR, API routes, and static serving. You don't need a separate Node.js server process.
One thing to confirm before you build: your public/ directory and .next/static/ need to be copied separately into the runner stage. The standalone output doesn't include them automatically. Miss this and your fonts, images, and client-side JavaScript won't be served.
Choosing where to run the container
The container runs the same regardless of where you deploy it. The choice is about operational tradeoffs.
AWS ECS (Fargate). If you're already in AWS or plan to be and especially if your users are in Indonesia and you want the AWS Jakarta (ap-southeast-3) region ECS with Fargate is the standard production path. You don't manage servers. You define a task definition, point it at your container image in ECR, attach an Application Load Balancer, and let Fargate handle the rest.
Fly.io. Add a fly.toml, run fly deploy. Fly handles container orchestration and gives you multi-region presence if you need it. Simpler than ECS for teams that don't have AWS experience yet.
Raw VPS with docker-compose. The simplest operational model. Run docker-compose up -d on a server, put nginx in front of it. Hetzner or DigitalOcean. Entirely appropriate for production at many traffic levels, and far cheaper than managed container services.
The decision framework from [→ Vercel Is Great Until It Isn't] applies here: pick based on your team's ops maturity and your traffic requirements, not based on which option looks most serious.
The part most people get wrong
There are four things that silently break during a Vercel migration if you don't check for them first.
next/image optimisation. Next.js uses Vercel's image optimisation service when deployed on Vercel. Self-hosted, it falls back to a local optimisation server using sharp. The sharp package needs to be installed explicitly it's an optional peer dependency. If it's missing, your app will throw 500 errors on any page with next/image components in production. Add sharp to your production dependencies before you migrate.
If you're serving images from an external domain (S3, Cloudinary, a CDN), verify those domains are listed in the remotePatterns config in next.config.js. Vercel is permissive about this during development. Your self-hosted server is not.
Edge middleware. This is the most painful one. Next.js edge middleware runs on Vercel's edge network it's not standard Node.js code. If you have middleware doing geolocation, A/B testing, or authentication token validation at the edge, that code will either not work at all or behave differently self-hosted.
Audit your middleware.ts file before migration. If it's doing simple redirects or header manipulation, it'll work fine in Node.js mode. If it's using Vercel-specific APIs or the geo request object, you'll need to rewrite that logic and move it into the application layer or your reverse proxy.
Incremental Static Regeneration with multiple instances. ISR works fine self-hosted on a single instance. The cache lives on the filesystem. With multiple container instances which you'll need for any serious production setup each instance has its own cache and they diverge. Requests get stale pages from whichever instance handles them.
The standard fix is to use on-demand ISR (revalidatePath / revalidateTag) and put a Redis-backed cache in front, or to use a CDN layer (CloudFront, Cloudflare) that serves cached pages and invalidates on demand. Neither is complicated, but neither is zero-config the way Vercel's ISR is.
Preview deployments. Vercel generates a unique URL for every branch deployment automatically. If your team relies on preview URLs for QA or stakeholder review, you'll need to replicate this in your CI pipeline. GitHub Actions can spin up ephemeral environments, but it's work you weren't doing before.
Real-world example
A fintech startup I worked with was running a Next.js dashboard on Vercel at around $320/month. Their app had heavy SSR on the main dashboard views, a handful of API routes, and next/image serving user-uploaded documents from S3. No edge middleware. No preview deployment requirement.
We migrated in three stages over two weeks, keeping Vercel live throughout.
Stage 1: Build and validate the container. We added output: 'standalone' to next.config.js, wrote the Dockerfile above, and built locally. We found two issues: sharp wasn't in their production dependencies, and two remotePatterns entries were missing for their S3 bucket URL. Both fixed in under an hour. The container ran locally and matched the Vercel behaviour.
Stage 2: Deploy the container alongside Vercel. We pushed the image to AWS ECR, stood up an ECS service in ap-southeast-3 (Jakarta), and pointed a staging domain at it. Their QA team ran the full test suite against the new URL for three days. One issue surfaced: a rewrite rule in next.config.js was being handled at Vercel's edge and wasn't firing in the self-hosted build. We moved it to the nginx config. Everything else was clean.
Stage 3: Traffic cutover. We shifted DNS to the new ALB endpoint, kept Vercel on standby for 48 hours, then shut it down. Total migration time: eleven days elapsed, maybe twelve hours of actual engineer work.
Monthly hosting cost went from $320 to around $80 ECS Fargate, an ALB, and ECR storage. The [→ approach we used to run both environments in parallel] is the same principle you'd use for any system migration: don't cut over until you've validated on real traffic.
FAQ
Q: Do I need to rewrite my Next.js app to self-host it?
A: No. The app code doesn't change. What changes is the build output configuration (output: 'standalone'), the deployment target, and how you manage environment variables and infrastructure. Most migrations involve zero application code changes.
Q: What happens to my Vercel environment variables when I migrate? A: You copy them to wherever your self-hosted environment manages secrets. For AWS, that typically means Secrets Manager or Parameter Store for sensitive values, and container environment variables for non-sensitive config. Make sure you account for all three environments development, staging, and production before you cut over.
Q: Can I keep using next/image after migrating?
A: Yes. Install sharp as a production dependency, confirm your remotePatterns config covers any external image domains, and it works identically to Vercel. The only thing you lose is Vercel's global CDN serving the optimised images. Replace that with CloudFront or Cloudflare in front of your origin.
Q: How do I handle Vercel preview deployments once I leave? A: If your team actively uses preview URLs for review, the simplest replacement is a GitHub Actions workflow that deploys branches to a staging environment with a dynamic subdomain. Tools like Coolify or Dokku can automate this on self-hosted infrastructure. It's not as seamless as Vercel, but it's workable.
Q: How long does a Next.js migration off Vercel typically take? A: For a straightforward app with no edge middleware and no complex ISR setup, a competent engineer can migrate in two to five days including validation. Add a week of parallel running time before cutting DNS. The migrations that take longer usually involve undocumented Vercel-specific behaviour that surfaces during QA audit your middleware and ISR setup before you start.
Leaving Vercel is not a complicated migration. It's a careful one. Most of the work happens in the week before you cut traffic: auditing what's actually using Vercel's infrastructure and testing the container against your real workload before anyone is depending on it.
If your app is straightforward, it's an afternoon of engineering work and a week of parallel validation. If you have edge middleware or distributed ISR, budget more time. Either way, it's not a reason to stay on a platform that no longer fits your cost or scaling requirements.