Migrating Off Convex to Kubernetes: How to Move to Postgres and Run It in Your Own Cloud
A phase-by-phase plan for leaving Convex when you need SQL, relational integrity, or infrastructure control: export your documents, map them to PostgreSQL, rebuild the function and reactivity layers, and run the result on Kubernetes in your own AWS, GCP, Azure, or Scaleway account.
Migrating off Convex is three migrations at once: the document store to SQL (usually PostgreSQL), the Convex function layer to your own backend service, and the reactive subscriptions to WebSockets, SSE, Postgres LISTEN/NOTIFY, or change data capture (CDC). Run them as three workstreams, never as one cutover weekend.
Export first. Convex produces a snapshot ZIP with one <table>/documents.jsonl file per table, each document carrying its _id and _creationTime system fields, plus a _storage folder for files when you pass --include-file-storage. Design your relational schema from the real shape of those documents.
Schema rule of thumb: one Convex table to one Postgres table, every v.id("table") to a real foreign key, every array of embedded objects to a child table, every union to JSONB plus a discriminator, and a convex_id TEXT UNIQUE column on every table so the loader is idempotent and references resolve in a second pass.
Zero downtime is realistic in five phases: backfill from the snapshot, dual-write, shadow-read and diff, flip reads feature by feature behind a flag, then stop dual-writing. Duration depends on the number of read paths and live subscriptions, not on data volume.
Reactivity is the part teams underestimate. Convex keeps a query result and its subscription consistent for free. On Postgres you rebuild that with WebSockets or SSE from your write path, LISTEN/NOTIFY (payload under 8000 bytes, no replay), CDC on logical replication (Debezium, Electric SQL), or a managed layer such as Supabase Realtime.
You do not need a Kubernetes team to land on Kubernetes. Qovery deploys your API, WebSocket service, workers, CronJobs, and a managed PostgreSQL (Amazon RDS, Google Cloud SQL, Azure Database for PostgreSQL, or Scaleway Managed Database) into your own AWS, GCP, Azure, or Scaleway account - or your existing cluster - with git-push deployments and a preview environment per pull request.
Leaving Convex is three migrations wearing one ticket: the data, the functions, and the reactivity. Teams plan the first, estimate the second, and get ambushed by the third.
I like Convex. It gives small teams a reactive backend with almost zero operations, and that is hard to build. This guide is for teams who hit a real wall (SQL, compliance, control) and need the mechanics, not a pep talk.
When should you actually migrate off Convex, and when should you stay?
Migrate off Convex when you hit one of five walls: you need real SQL, other tools must query the database directly, data must live in your own cloud account, you need Postgres extensions, or usage-based pricing stops matching your unit economics. If none of those apply, stay, and consider self-hosting the open-source Convex backend before any rewrite.
Convex is a document database with a TypeScript query, mutation, and action layer and built-in end-to-end reactivity, served from Convex Cloud. That design is the reason people choose it and the reason some outgrow it.
The five walls I see in practice:
Joins in application code. You are hand-rolling joins, aggregates, and window functions in TypeScript because the document model cannot express them.
External SQL access. Analytics, BI, or a data team needs SQL or warehouse access to the same records.
Compliance and residency. An enterprise customer contractually requires data in your own cloud account, in a specific region, inside your VPC.
Extensions and query plans. You need PostGIS, pgvector, pg_trgm, or precise control over indexes and EXPLAIN output.
Cost shape. A usage-priced backend scales with activity, and at some point that curve stops matching how your customers pay you.
The SQL argument is also a hiring argument. PostgreSQL is the most-used database among professional developers at 58.2% (Stack Overflow Developer Survey 2025).
Do not migrate if your product is early, your team is small, reactivity is your core value, and nobody is asking for SQL.
The middle path is self-hosting. The convex-backend repository is licensed under FSL-1.1-Apache-2.0, which allows any use except a competing product and converts each release to Apache 2.0 after two years. Its self-hosting guide says it "supports all the free-tier features of the cloud-hosted product", stores state in SQLite by default, and can point at Postgres or MySQL. You get your own infrastructure, but you still have a document model and you now operate the backend yourself.
Signal you are hitting
Stay on Convex Cloud
Self-host the Convex backend
Migrate to Postgres + Kubernetes
Rough effort of migrating
You need SQL joins, aggregates, window functions
Workarounds in TypeScript
Same document model, no SQL
Yes, native SQL
High
BI or a data team needs direct SQL access
Export or Fivetran sync (Pro plan)
Backing SQL tables are Convex internals, not your schema
Yes, query the system of record
High
Data residency or VPC isolation in your own account
Limited to Convex Cloud regions
Yes, runs in your infrastructure
Yes, runs in your account
Medium to high
You need PostGIS, pgvector, pg_trgm
Convex has its own search and vector indexes
No Postgres extensions in your app's queries
Yes
Medium
Cost predictability at scale
Usage-based pricing
You pay infrastructure plus your ops time
Infrastructure plus engineering time
High
Team of fewer than 5 engineers
Usually the right call
Only with ops capacity
Hard to justify
Very high relative to team size
Real-time reactivity is the product
Best fit
Keeps reactivity
You rebuild it
High
What exactly do you have to replace when you leave Convex?
Leaving Convex means replacing seven things, not one database: the document store, the query/mutation/action layer, real-time subscriptions, scheduled functions and crons, file storage, auth, and the hosted dev and prod deployments. Write the list down before phase 0, because teams that skip it stall on reactivity with everything else done.
One guarantee needs its own line. Convex mutations use optimistic concurrency control that "provides true serializability" and retries conflicting transactions automatically (Convex docs: OCC and atomicity). PostgreSQL defaults to Read Committed, and at Serializable "applications must be prepared to retry transactions due to serialization failures" with SQLSTATE 40001 (PostgreSQL docs: transaction isolation). Code that relied on Convex's isolation needs explicit SERIALIZABLE plus a retry loop, or row locks.
Scheduled functions deserve the same care. A Kubernetes CronJob defaults to concurrencyPolicy: Allow, so overlapping runs happen unless you set Forbid or Replace, and without startingDeadlineSeconds a missed run has no deadline (Kubernetes docs: CronJob). Make every job idempotent.
Convex capability
What it gives you today
Postgres / Kubernetes equivalent
Who owns it after migration
Migration effort
Document store
Schemaless-to-typed documents, managed
PostgreSQL with schema, foreign keys, constraints
Your team plus a managed database service
High
Queries, mutations, actions
Typed functions with serializable transactions
Your API: tRPC, Hono, NestJS, FastAPI, Rails
Your backend team
High
Reactive subscriptions
Live query results pushed to clients
WebSockets/SSE, LISTEN/NOTIFY, Debezium, Electric SQL, Supabase Realtime
Per-developer dev deployments, prod with one command
Kubernetes environments plus a preview environment per pull request
Your platform owner
Medium
How do you export all your data from Convex?
Convex exports a full snapshot as a ZIP from the dashboard (Settings, Backups, "Backup Now", then download) or with npx convex export, with one <table_name>/documents.jsonl file per table and an optional _storage folder for files. Pull it on day one, because the real shape of your documents dictates the SQL schema.
The file is named snapshot_{ts}.zip, where ts is a UNIX timestamp in nanoseconds.
Each line in documents.jsonl is one document, including its _id and _creationTime system fields.
--include-file-storage adds _storage/documents.jsonl (IDs and checksums) and one _storage/<id> file per blob.
--prod targets production; with no flag the CLI exports your dev deployment.
Manual backups are stored for 7 days, and the Free/Starter plan keeps at most two backups per deployment.
How values serialise is what breaks naive importers (Convex docs: data types). Int64 is exported as a base-10 string, Bytes as base64, and Float64 as a JSON number except Inf and NaN, which become strings. undefined is not a stored value, so {a: undefined} is saved as {}, and a missing field is different from null. The current types page lists no Set or Map type.
For continuously changing data, Convex offers a Data Sync API and a Fivetran connector. Per the streaming export docs, streaming export requires a Convex Pro plan, is in beta, and is not supported through Airbyte.
Size the job before you plan anything:
BASH
npx convex export --prod --include-file-storage --path ./snapshots/
unzip -q ./snapshots/snapshot_*.zip -d ./snapshots/latest
# documents per table, largest first
for f in ./snapshots/latest/*/documents.jsonl; do
printf '%10d %s\n' "$(wc -l < "$f")" "$f"
done | sort -rn
# largest single document (bytes) in a table
awk '{ print length($0) }' ./snapshots/latest/messages/documents.jsonl | sort -rn | head -1
Treat each dated snapshot as immutable input to a repeatable loader. Re-run it every day against staging until the run is boring.
How do you map Convex documents to a relational PostgreSQL schema?
Map from convex/schema.ts, not the raw JSONL: each Convex table becomes a Postgres table, each v.id("table") a foreign key, each array of embedded objects a child table, and each union JSONB with a discriminator. Add convex_id TEXT UNIQUE to every table so INSERT ... ON CONFLICT (convex_id) DO UPDATE makes the loader idempotent.
Three rules carry most of the work:
Timestamps._creationTime is milliseconds since the epoch as a float, so created_at TIMESTAMPTZ comes from to_timestamp(_creationTime / 1000.0).
References. Load in two passes. Insert every row keyed by convex_id, then resolve foreign keys with an UPDATE ... FROM join on convex_id. No ordering problems, no circular-dependency puzzles.
Optional fields. Pick one rule for missing versus null and apply it in every table. I map both to SQL NULL unless the product actually treats them differently.
The two-pass load, end to end:
SQL
CREATE TABLE users (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
convex_id TEXT UNIQUE NOT NULL,
email TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE messages (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
convex_id TEXT UNIQUE NOT NULL,
author_convex_id TEXT NOT NULL,
author_id BIGINT REFERENCES users(id),
body TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);
-- Pass 1: upsert rows from a staging table holding one JSONB doc per line
INSERT INTO messages (convex_id, author_convex_id, body, created_at)
SELECT doc->>'_id', doc->>'author', doc->>'body',
to_timestamp((doc->>'_creationTime')::double precision / 1000.0)
FROM staging_messages
ON CONFLICT (convex_id) DO UPDATE
SET author_convex_id = EXCLUDED.author_convex_id,
body = EXCLUDED.body;
-- Pass 2: resolve references through convex_id
UPDATE messages m
SET author_id = u.id
FROM users u
WHERE u.convex_id = m.author_convex_id
AND m.author_id IS DISTINCT FROM u.id;
-- Gate: must return 0 before you enforce the constraint
SELECT count(*) FROM messages WHERE author_id IS NULL;
ALTER TABLE messages ALTER COLUMN author_id SET NOT NULL;
Convex type or construct
PostgreSQL column type or structure
Notes and gotchas
_id
convex_id TEXT UNIQUE NOT NULL plus a new BIGINT or UUID primary key
Keep convex_id until the migration's quiet period ends
_creationTime
created_at TIMESTAMPTZ
to_timestamp(_creationTime / 1000.0); value is float milliseconds
v.id("users")
BIGINT REFERENCES users(id)
Resolve in a second pass joined on convex_id
v.int64()
BIGINT
Exported as a base-10 string; cast explicitly
v.float64() / v.number()
DOUBLE PRECISION
Inf and NaN arrive as strings
v.bytes()
BYTEA
Exported as base64; decode(value, 'base64')
v.boolean()
BOOLEAN
Direct
v.string()
TEXT
Add CHECK or length limits you want enforced
v.optional(...)
Nullable column
One rule for missing versus null, applied everywhere
v.union(...)
JSONB plus a kind TEXT column with a CHECK
Or separate nullable columns when variants are few and stable
Trust the schema only after four gates pass: row counts per table match the JSONL line counts, zero orphan foreign keys, a sampled field-by-field diff against the live Convex API, and matching checksums on key aggregates.
Ship faster on infrastructure you control.
Qovery gives your team self-service deployments and managed databases on your own AWS, GCP, Azure, or Scaleway account - or your existing Kubernetes cluster. Start deploying in under 10 minutes.
What replaces Convex's real-time reactivity once you are on Postgres?
Four credible options replace Convex subscriptions: emit events from your API write path over WebSockets or SSE, use Postgres LISTEN/NOTIFY with triggers, stream CDC from logical replication with Debezium or Electric SQL, or buy a managed layer such as Supabase Realtime. Start with the write path; NOTIFY payloads must be under 8000 bytes and have no replay.
Convex's reactivity is the thing teams miss most after they leave. Budget for it first, not last.
Option A: emit from the API write path. Simple and database-agnostic. Every writer must go through the API, or clients go stale.
Option B: LISTEN/NOTIFY. Triggers fire on commit and need no app coupling. The payload "must be shorter than 8000 bytes" in the default configuration, notifications only reach sessions already listening, and nothing is replayed after a disconnect (PostgreSQL docs: NOTIFY). Send the row key, then re-read.
Option C: CDC on logical replication. Debezium and Electric SQL read the WAL through a replication slot, so they catch every change and resume after a disconnect. An unread slot retains WAL and can fill your disk, as the AWS RDS docs warn.
Option D: Supabase Realtime. Managed, but Postgres Changes processes events on a single thread, and Supabase recommends Broadcast above roughly 3,000 concurrent subscribers on the same changes (Supabase docs).
The trap nobody plans for is fan-out. The moment you run a second WebSocket replica, a write handled by pod A must reach clients connected to pod B, so you need Redis pub/sub or NATS between pods, plus sticky sessions or shared session state.
Be explicit about what you lose. Convex guarantees the subscription reflects the same consistent snapshot as the query. None of these options gives you that for free; you design for eventual consistency and re-fetch on reconnect.
Approach
Typical latency
Operational complexity
Delivery guarantee
Payload limits
Works on managed Postgres (RDS, Cloud SQL, Azure, Scaleway)
Can you migrate from Convex to PostgreSQL without downtime?
Yes, in five phases with both systems running in parallel for weeks: backfill from the snapshot, dual-write, shadow-read and diff, flip reads per feature behind a flag, then stop dual-writing. A single migration weekend is where these projects break, and rollback criteria must exist before phase 1.
Write the rollback criteria down first: an acceptable diff rate, a p99 latency ceiling, an error budget, and the one named person who makes the call. Deciding those during an incident means nobody decides.
Every phase needs a disposable full-stack environment with its own database. A preview environment per pull request lets you test the loader, the dual-write path, and each read flip against a fresh copy without touching staging.
Phase
Reads served from
Writes going to
Exit criteria
Rollback action
Typical blockers
0. Schema and loader
Convex
Convex
Daily staging load with zero diff
None needed
Union types, missing vs null fields
1. Backfill
Convex
Convex
Row counts, zero orphan FKs, aggregate checksums match
Truncate and reload
Large tables, file storage volume
2. Dual-write
Convex
Convex and Postgres, idempotency key on every write
Reconciliation job finds no drift
Disable Postgres writes by flag
Writes from actions and scheduled functions you forgot
3. Shadow reads
Convex, Postgres read in parallel
Both
Diff rate under your threshold
Stop shadow reads
Ordering and pagination differences
4. Flip reads per feature
Postgres for flipped features
Both
p99 and error budget hold per feature
One-line flag change back to Convex
Live subscriptions on hot screens
5. Stop dual-writing
Postgres
Postgres
Quiet period with no rollback
Restore from the final Convex snapshot
Dropping convex_id too early
Duration depends on the number of read paths and live subscriptions you have to flip, not on how many gigabytes you move. A small app with three reactive screens moves faster than a large dataset behind fifty queries.
Where should the new stack run, and do you need a Kubernetes team for it?
The new stack needs an orchestrator for the API, WebSocket service, workers, and CronJobs, and Kubernetes is the default answer, but you do not need to hand-write manifests or hire a platform team. Run it in your own AWS, GCP, Azure, or Scaleway account, or an existing cluster, with an internal developer platform on top.
The target architecture is plain: an API Deployment, a WebSocket service, background workers, CronJobs, managed PostgreSQL, and object storage, all in your VPC.
Use a managed database for the system of record. Amazon RDS, Google Cloud SQL, Azure Database for PostgreSQL, and Scaleway Managed Database handle backups, point-in-time recovery, failover, and version upgrades. Running Postgres in-cluster makes all of that your pager.
Raw Kubernetes means owning Helm or Kustomize, ingress, TLS, secrets, autoscaling, cluster upgrades, RBAC, and observability. Adoption is no longer the question, with 80% of organisations running Kubernetes in production (CNCF Annual Survey 2024). Operating it well without a platform team is.
This is where Qovery fits. Qovery deploys and operates your workloads inside your own AWS, GCP, Azure, or Scaleway account, or your existing Kubernetes cluster. The cloud bill stays in your name, so your AWS Savings Plans and Google Cloud committed use discounts keep applying. You get git-push deployments, a preview environment per pull request, auto-stop for non-production environments, managed cluster upgrades, per-environment RBAC, and databases backed by managed cloud services, without a dedicated DevOps engineer.
The alternatives are good at different things. Northflank and Porter also deploy into your own cloud account, so compare them on your workload. Render, Railway, and Fly.io are faster to start but run on their own infrastructure, which defeats the residency reason many teams leave Convex for.
Option
Managed SQL database
Runs in your own cloud account (BYOC)
Kubernetes expertise needed
Preview environment per PR
Who handles cluster upgrades
Best for
Raw Kubernetes + Helm + Argo CD
You wire RDS, Cloud SQL, Azure, or Scaleway yourself
Yes
High
You build it
You
Teams with a platform team
Qovery
Yes, backed by your cloud's managed service
Yes: AWS, GCP, Azure, Scaleway, or your existing cluster
Low
Yes
Qovery
Teams without a dedicated DevOps engineer that need their own account
Render
Render Postgres
No, Render's infrastructure
None
Yes
Render
Fast start, no residency requirement
Railway
Postgres on Railway
No, Railway-managed infrastructure or dedicated VMs (Railway docs)
None
Yes
Railway
Prototypes and small teams
Fly.io
Fly Managed Postgres
No, Fly.io's infrastructure
Low
Through your CI
Fly.io
Global edge deployment
Northflank
Yes, addons
Yes: AWS, GCP, Azure, Oracle, CoreWeave, or BYOK (Northflank)
The honest trade: you swap Convex's zero-ops reactivity for SQL, portability, and control. An internal developer platform is how you avoid swapping away developer velocity too.
How do I run this migration with Qovery from Claude Code or Cursor?
Install the Qovery Agent Skills once with curl -fsSL https://skill.qovery.com/install.sh | bash, and your coding agent can scaffold the backend, provision a managed PostgreSQL, and deploy both into your own cloud account while you approve each step. The Qovery MCP Server operates what already exists and is read-only by default.
Install globally for Claude Code, Cursor, Codex, OpenCode, and Gemini CLI, or scope it to one project:
Add the Qovery MCP Server to Claude Code (OAuth, read-only by default):
BASH
claude mcp add --transport http qovery https://mcp.qovery.com/mcp --callback-port 4242
Enable write access only when you want it. This also requires turning on write access in the Qovery Console:
BASH
claude mcp add --transport http qovery "https://mcp.qovery.com/mcp?read_write=true" --callback-port 4242
The skills include qovery-onboard, qovery-deploy, qovery-troubleshoot, qovery-optimize, qovery-speedup, qovery-preview, and qovery-terraform. Then ask: "deploy this API and a managed PostgreSQL to my AWS account with Qovery, and create a preview environment per pull request." The agent asks you to approve every action. Swap AWS for GCP, Azure, Scaleway, or your existing Kubernetes cluster; nothing in these commands is AWS-specific.
Run npx convex export --prod --include-file-storage --path ./snapshots/, or click "Backup Now" under Settings, Backups in the Convex dashboard and download the ZIP. The snapshot contains one <table_name>/documents.jsonl file per table, each document with its _id and _creationTime fields, plus a _storage folder for files. Manual backups are kept for 7 days.
How do I map Convex document types to a PostgreSQL schema?
Map each Convex table to a Postgres table, each v.id() to a foreign key, v.int64 to BIGINT, v.bytes to BYTEA, v.float64 to DOUBLE PRECISION, arrays of objects to child tables, and unions to JSONB plus a discriminator. Convert _creationTime with to_timestamp(_creationTime / 1000.0). Keep a convex_id TEXT UNIQUE column and resolve foreign keys in a second pass joined on it.
Can I migrate from Convex to PostgreSQL without downtime?
Yes, with five phases: backfill from a snapshot, dual-write to Convex and Postgres, shadow-read and diff, flip reads per feature behind a flag, then stop dual-writing. Both systems run in parallel for weeks. Define rollback criteria (diff rate, p99 ceiling, decision owner) before the backfill starts.
What replaces Convex's real-time subscriptions after moving to Kubernetes?
Four options replace Convex subscriptions: WebSockets or SSE emitted from your API write path, Postgres LISTEN/NOTIFY (payloads under 8000 bytes, no replay), CDC on logical replication with Debezium or Electric SQL, or Supabase Realtime. With more than one WebSocket pod you also need Redis pub/sub or NATS for fan-out. None of them reproduces Convex's query-to-subscription consistency for free.
Can I self-host Convex instead of migrating to Postgres?
Yes. The open-source convex-backend is licensed under FSL-1.1-Apache-2.0, supports all the free-tier features of Convex Cloud, and stores data in SQLite by default or in Postgres or MySQL. Self-hosting gives you infrastructure control and data residency, but keeps the document model, so it does not solve the need for SQL.
How long does a Convex to PostgreSQL migration take?
A Convex to PostgreSQL migration takes as long as it takes to flip every read path and live subscription, because that is the driver, not data volume. The five phases (backfill, dual-write, shadow reads, per-feature read flips, stop dual-writing) typically run over several weeks. An app with a few reactive screens moves faster than one with dozens of subscribed queries.
Do I need a DevOps engineer to run my app on Kubernetes after leaving Convex?
Not if a platform operates the cluster for you. Qovery deploys your API, WebSocket service, workers, CronJobs, and managed PostgreSQL into your own AWS, GCP, Azure, or Scaleway account, or your existing Kubernetes cluster, with managed cluster upgrades and git-push deployments. Raw Kubernetes with Helm and Argo CD is the alternative if you have a platform team.
Convex earns its place for small reactive apps. When you outgrow it, move the data in phases, budget for reactivity first, and keep the new stack in your own account. If you want to talk through your migration, join us on Discord or try Qovery.
Romaric founded Qovery to make Kubernetes accessible to every engineering team. He writes about platform strategy, developer experience, and the future of cloud infrastructure.
Next step
Ship faster on infrastructure you control.
Qovery gives your team self-service deployments and managed databases on your own AWS, GCP, Azure, or Scaleway account - or your existing Kubernetes cluster. Start deploying in under 10 minutes.