Migrating From Vercel and Supabase to Ankra: What Moves and What Changes
On this page
Plays from YouTube. Open on YouTube instead
Vercel and Supabase are a very good way to start. Push to GitHub and you have a URL. Click a button and you have Postgres with sign-in, file storage and an API on top. For a new product that is hard to beat.
The reasons to move come later. Usage-based bills get harder to predict as traffic grows. A customer asks which country their data lives in, and you want to give a specific answer. Or the app has grown a background worker and a second service, and now it lives on three platforms with three dashboards and three sets of secrets.
On the other side of the move, the app, its database, its workers and its scheduled jobs run on one Kubernetes cluster, in a region and on a cloud you pick.
This post walks through that move for a common setup, a Next.js app on Vercel with Supabase for the database, sign-in and file uploads. Most of it is a straight copy. One part, the way your app talks to Supabase, is a real decision, and it is better made before you start than halfway through.
One thing to know up front. The build, deploy and preview features used below belong to Ankra Applications, which is in closed beta and switched on per organisation when you ask. Clusters, domains and certificates are available to every account.
Where each piece goes
| Today | On Ankra | Effort |
|---|---|---|
| Vercel builds on every push | An Ankra Application builds the image and deploys it | Small |
| Preview deployments | PR previews on a staging cluster | Small |
| Environment variables | Application secrets, plus build-time values for NEXT_PUBLIC_ | Small, with one trap |
| Domains and HTTPS | A cluster domain with certificates, then your own domain | Small |
| API routes, Server Actions, middleware | The same Next.js server, in a container | None |
| Vercel Cron | A Kubernetes CronJob that calls the same route | Small |
| Supabase Postgres | Postgres on your cluster, run by CloudNativePG | Medium |
| Supabase Auth | Keep it, replace it, or run it yourself | The real decision |
| Supabase Storage | Any S3-compatible bucket | Medium |
| Realtime and Edge Functions | Moved into the app, or run yourself | Depends on use |
Step one, the Next.js app
From a checkout of the repository, register it with Ankra.
ankra application add . --waitAnkra reads the repository, recognises Next.js, and opens a pull request with what it needs to build and run the app. That is a Dockerfile, the Kubernetes manifests under .ankra/manifests/, and a build workflow.
Read that pull request properly. The base image, the port, the health check path and the resource requests are all in there, and this is the moment to change them.
Merging it runs the first build, which publishes the image to your organisation’s private registry. Then deploy it.
ankra application deploy <app> --cluster <cluster> --set image_tag=sha-<short>Vercel made a few choices on your behalf that you now make yourself. Three are worth knowing before the first deploy.
Public variables are baked in at build time
Anything prefixed NEXT_PUBLIC_ is written into the JavaScript bundle during next build. With Supabase that usually means NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY.
On Vercel the build could see them. In a container, runtime secrets arrive after the build has finished, which is too late for these.
So public values go where the build can read them, in the build workflow and the Dockerfile. Server-only values, like a database password or an API key, go in as application secrets.
printf '%s' "$DATABASE_URL" | ankra application env-secrets set <app> DATABASE_URLankra application env-secrets apply <app>Setting a secret stores it. apply is the step that puts it into the running app and restarts it. Pipe values in rather than typing them on the command line, so they stay out of your shell history.
More than one copy means shared state
Vercel keeps one cache for your whole site. On a cluster you will usually run two or three copies of the app, and by default each copy keeps its own cache of pages and fetched data. Revalidate a page on one copy and the others keep serving the old version until they notice.
If you use incremental static regeneration or revalidateTag, give the app a shared cache handler. Redis is the usual choice.
Also set NEXT_SERVER_ACTIONS_ENCRYPTION_KEY to the same value for every copy. Otherwise a Server Action rendered by one copy fails when another copy receives it. The Next.js self-hosting guide covers both settings.
Scheduled jobs become CronJobs
Middleware (called proxy since Next.js 16), image optimisation and streaming all work on a self-hosted Next.js server, and your API routes and Server Actions are the same code they always were. The one Vercel feature with no direct twin is Vercel Cron.
Vercel Cron calls a route on a schedule. A Kubernetes CronJob can do exactly that. If your route checks the CRON_SECRET bearer token Vercel sends, keep the check and send the same token.
apiVersion: batch/v1kind: CronJobmetadata: name: nightly-cleanup namespace: shopspec: schedule: "0 3 * * *" concurrencyPolicy: Forbid jobTemplate: spec: template: spec: restartPolicy: Never containers: - name: call image: curlimages/curl:8.10.1 args: - "-fsS" - "-H" - "Authorization: Bearer $(CRON_SECRET)" - "http://web.shop.svc.cluster.local/api/cron/cleanup" env: - name: CRON_SECRET valueFrom: secretKeyRef: name: web-cron key: CRON_SECRETReplace web, shop and the path with your own service, namespace and route. Add the file to a stack on the cluster as a manifest, and Ankra deploys it and keeps it in sync with everything else.
Previews and deploys on merge
Previews are the Vercel feature people worry about losing. Ankra has them for GitHub repositories.
Open a pull request and Ankra deploys that branch’s build to your organisation’s staging cluster, then posts a comment with a public link. Push again and it redeploys. Merge or close the pull request and it is torn down. A preview lives for 24 hours by default.
An admin switches PR previews on once and picks the staging cluster. The PR preview guide walks through it.
If you use Supabase branching to give each preview its own database, the closest match is a throwaway Postgres per preview. It starts empty on every deploy, runs your migration command before the first request, and is deleted along with the preview. It is not a copy of production, which for a preview is usually what you want.
For production, auto-deploy rolls out every build from your main branch.
ankra application auto-deploy set <app> --enabledWith it off, a push still builds, and the new version waits for you to deploy it. Turn it on for production once the branch is protected and your tests gate the build.
Domains and certificates
A cluster Ankra builds comes with its own subdomain, an ingress controller (Traefik) and automatic certificates from Let’s Encrypt. ankra cluster domain <cluster> shows the domain, and any hostname under it resolves on its own, over HTTPS.
That gives the app a real address from its first deploy, well before you touch your own domain. Test everything there.
When you are ready, you can delegate a domain to Ankra or keep it at your current DNS provider and give Ankra a credential for it. Either way, the cutover is the moment your domain’s record moves from Vercel to the cluster.
Step two, the database
Supabase’s database is ordinary Postgres with a few extra parts. Your tables live in the public schema. Next to them sit the auth and storage schemas, which Supabase runs, and three roles (anon, authenticated and service_role) that its API uses to decide who can see what.
On the cluster, Postgres is run by CloudNativePG, an open source operator that handles replicas, failover and backups the way a database administrator would. When Ankra sees that your app needs Postgres, the deploy creates a CloudNativePG database and wires its credentials into the app. We wrote about why a cluster is a sensible home for a database now.
Find what points at Supabase
Your tables usually refer to Supabase’s own parts in two ways. A profiles table often has a foreign key to auth.users, and row level security policies often call auth.uid(). Neither exists in a plain Postgres, so find them first. Run this against Supabase.
-- foreign keys from your tables into Supabase's auth schemaselect conrelid::regclass as from_table, connamefrom pg_constraintwhere contype = 'f' and confrelid = 'auth.users'::regclass;
-- row level security policies that depend on Supabase's auth functionsselect schemaname, tablename, policynamefrom pg_policieswhere qual ilike '%auth.%' or with_check ilike '%auth.%';Also note which extensions you use, with select extname from pg_extension. Supabase installs them in a schema called extensions, and column defaults refer to them by that name, for example extensions.uuid_generate_v4(). Create that schema and the same extensions in the new database before you restore.
Dump, restore, repeat
Use a pg_dump at least as new as the Postgres version Supabase runs. Connect with the session pooler connection string from the Supabase dashboard, or the direct connection if your network has IPv6.
pg_dump "$SUPABASE_DB_URL" --schema=public --no-owner --no-privileges -Fc -f app.dumppg_restore -l app.dump > app.list--no-owner and --no-privileges leave out Supabase’s roles and grants, which the new database does not have. The second command writes the dump’s table of contents to a file.
Open that file and put a ; in front of each POLICY line and each foreign key the query found. The rules those policies enforced now belong in your server code, which the next section is about.
To reach the database on the cluster, add the cluster to your kubeconfig and forward a local port to it.
ankra cluster kubeconfig add <cluster> --usekubectl port-forward -n <namespace> svc/<database>-rw 5432:5432CloudNativePG names the service that points at the current primary <database>-rw, and keeps the app’s user and password in a secret called <database>-app. In a second terminal, restore with the edited list, with $CLUSTER_DB_URL pointing at localhost:5432 using that user and password.
pg_restore --no-owner --no-privileges -L app.list -d "$CLUSTER_DB_URL" app.dumpDo this as a rehearsal first, while Supabase keeps serving. Point a staging copy of the app at the restored database and click through it. Count the rows in the tables that matter.
When you do it for real, the only extra step is stopping writes before the final dump. The downtime is roughly as long as that dump and restore take.
If your database is too large for that window, Postgres logical replication can keep the new database in step with the old one until the moment you switch. It takes more setup, so time the plain dump first.
If you have read about ankra migrate up, you might wonder why this part is by hand. That command dumps databases from containers running on a Docker host you control. A hosted Supabase database is not one of those, so here you run pg_dump and pg_restore yourself.
Step three, sign-in, files and realtime
This is the part that is not a copy, and it starts with one question. Does your browser talk to Supabase directly?
If client components call supabase.from('orders').select(), those requests go to Supabase’s API, which turns them into SQL and relies on row level security to decide what each user may see. Once the database moves, those calls need a new home.
You can move them into server code, meaning Server Components, Route Handlers and Server Actions that query Postgres with a normal client and check the user themselves. Or you can run the same API layer, PostgREST, yourself. For most Next.js apps the first is less to operate.
Sign-in
There are three honest options.
Keep Supabase Auth for now. It works as a separate service, and your app keeps checking its sessions as it does today. The foreign key to auth.users becomes a plain user id column. This lets you move the database first and sign-in later, or never.
Move to a library that runs inside your app, such as Auth.js or Better Auth. Supabase stores password hashes with bcrypt, and both libraries let you supply your own password check, so you can carry the hashes across and spare your users a reset. Accounts that sign in with Google or GitHub can be matched by email address.
Run Supabase’s services yourself. Supabase publishes a self-hosting setup, and its services run on a cluster like any other containers. It keeps your code unchanged, and it makes you the operator of sign-in, storage, realtime and the API gateway. Choose it knowing that.
Files
Supabase Storage speaks the S3 protocol once you switch that on in its storage settings, so any S3 tool can copy your files out. rclone works well, and running it twice is fine, since the second run only copies what changed. Put the files in an S3-compatible bucket from your cloud provider.
Two things to check. Public files in Supabase have URLs containing supabase.co, and those URLs are often saved in your own tables, so they need rewriting. And access rules for private files, which Supabase kept as policies, move into your server, usually as short-lived signed links.
Realtime and Edge Functions
If you use Realtime to push database changes to the browser, you can run Supabase’s Realtime service yourself, or replace it with a small streaming route in your app that listens for Postgres notifications.
Edge Functions are small TypeScript handlers. They usually move into Next.js Route Handlers, or into a container of their own if they do heavy work.
If you use neither, skip this.
The cutover, in order
- Deploy the app on the cluster’s own domain and get previews working.
- Rehearse the database restore and test the app against it.
- Copy the files once, early.
- A day before, shorten how long your DNS records may be cached (their TTL), so the switch takes effect quickly.
- Stop writes. A maintenance page is enough.
- Take the final dump and restore it into a fresh database. Run rclone again for the last files.
- Update the app’s secrets for the new database and bucket, then run
env-secrets apply. - Point your domain at the cluster.
- Keep the Supabase project and the Vercel deployment for a week in case you need to go back. Then delete them, and delete the dump files on your laptop, which are a full copy of production.
When to stay
Moving is work, and sometimes it is not worth it.
If the project is small and fits comfortably inside free tiers, stay.
If you use Realtime, Supabase Auth and Edge Functions everywhere and nobody on the team wants to own them, stay. Or move only the app and the database, and leave Supabase Auth where it is.
If your users are spread across the world and you rely on Vercel’s edge network, remember that a cluster lives in one region. You can put a CDN in front of it, but measure before you promise anyone the same speed.
And if nobody should own infrastructure at all, be honest about that. Ankra does much of the running for you, from certificates to upgrades, but the cluster is still yours.
Try it
Ankra can build the cluster for you on Hetzner, UpCloud, OVHcloud, DigitalOcean and others, or import one you already run on EKS, GKE or AKS. Applications and PR previews are in closed beta, so ask support or write to [email protected] to have them switched on. The Applications docs cover everything above in more depth.
Get started: Create a free account on Ankra and provision your first cluster inside the free 30 vCPU allowance.
Join our community: Slack
Follow us on: LinkedIn | GitHub
Contact us: [email protected]
Get the next post in your inbox
Related Posts
One Command From Docker Compose to Kubernetes, Data Included
A compose stack on a VM, a Kubernetes cluster on Ankra, and the databases in between. ankra migrate up plans, converts, deploys, dumps and restores, then does it once more for the cutover. Here is what one command has to promise, and how the CLI keeps that promise.
One Prompt to a Live URL: The Anatomy of an Agent-Shipped Product
We gave an AI agent one paragraph: build an animated deep sea facts page, wire it to our private GPU, ship it. It came back with a live URL. Here is how.