01/22/26

How to Migrate from Convex to GCP

Move from Convex to your own Google Cloud account

8 Min Read

Convex provides automatic reactivity and strong TypeScript integration. The developer experience for real-time apps is smooth, and the type safety is solid.

The trade-offs become clear at scale, where Convex only runs in US regions with no multi-region option, and the proprietary document database means no SQL, no JOINs, and queries written in Convex-specific syntax that doesn't transfer anywhere else. If you outgrow it or need to integrate with existing infrastructure, you're looking at a full rewrite.

With GCP, Cloud SQL runs PostgreSQL in any region you need, and your data is portable so you can export it, query it with psql, or connect any tool that speaks Postgres. Your code uses standard patterns, so if you move providers again someday, it's a migration, not a rebuild.

The traditional path to GCP means learning Terraform and building your own backend from scratch. That's a big jump when you're used to Convex's developer experience.

This guide takes a different approach: migrating to your own GCP project using Encore and Encore Cloud. Encore is an open-source TypeScript backend framework (11k+ GitHub stars) where you define infrastructure as type-safe objects in your code: databases, Pub/Sub, cron jobs, object storage. Encore Cloud then provisions these resources in your GCP project using managed services like Cloud SQL, GCP Pub/Sub, and Cloud Storage.

Infrastructure from Code: define resources in TypeScript, deploy to AWS or GCP

The result is GCP infrastructure you own and control, with TypeScript ergonomics similar to what you're used to. You'll trade Convex's automatic reactivity for infrastructure ownership and SQL flexibility. Companies like Groupon already use this approach to power their backends at scale.

What You're Migrating

Convex ComponentGCP Equivalent (via Encore)
Convex DatabaseCloud SQL PostgreSQL
Convex FunctionsCloud Run
File StorageGoogle Cloud Storage
Scheduled FunctionsCloud Scheduler

The Reactivity Tradeoff

Convex's main feature is automatic reactivity. Queries re-run when data changes. With Encore (and most backends), you use request-response. For real-time updates, you need WebSockets, polling, or Pub/Sub.

If your app relies heavily on real-time updates, plan for this change. If you mainly use Convex as a typed database with occasional real-time features, the migration is simpler.

Why GCP?

Cloud Run performance: Fast cold starts, automatic scaling, and scale-to-zero.

PostgreSQL: SQL gives you joins, aggregations, and the full relational toolset. Queries that require multiple Convex reads become single SQL statements.

Infrastructure ownership: You own the Cloud SQL instance, GCS bucket, and Cloud Run services.

GCP ecosystem: BigQuery, Vertex AI, and other Google services.

What Encore Handles For You

When you deploy to GCP through Encore Cloud, every resource gets production defaults: VPC placement, least-privilege IAM service accounts, encryption at rest, automated backups where applicable, and Cloud Logging. You don't configure this per resource. It's automatic.

Encore follows GCP best practices and gives you guardrails. You can review infrastructure changes before they're applied, and everything runs in your own GCP project so you maintain full control.

Here's what that looks like in practice:

import { SQLDatabase } from "encore.dev/storage/sqldb"; import { Bucket } from "encore.dev/storage/objects"; import { Topic } from "encore.dev/pubsub"; import { CronJob } from "encore.dev/cron"; const db = new SQLDatabase("main", { migrations: "./migrations" }); const uploads = new Bucket("uploads", { versioned: false }); const events = new Topic<OrderEvent>("events", { deliveryGuarantee: "at-least-once" }); const _ = new CronJob("daily-cleanup", { schedule: "0 0 * * *", endpoint: cleanup });

This provisions Cloud SQL, GCS, Pub/Sub, and Cloud Scheduler with proper networking, IAM, and monitoring. You write TypeScript or Go, Encore handles the Terraform. The only Encore-specific parts are the import statements. Your business logic is standard TypeScript, so you're not locked in.

See the infrastructure primitives docs for the full list of supported resources.

Step 1: Migrate Your Database

Export from Convex

npx convex export --path ./convex-backup

Design PostgreSQL Schema

Map Convex schema to relational tables:

Convex schema:

// convex/schema.ts import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; export default defineSchema({ tasks: defineTable({ text: v.string(), isCompleted: v.boolean(), userId: v.id("users"), }).index("by_user", ["userId"]), users: defineTable({ email: v.string(), name: v.string(), }).index("by_email", ["email"]), });

PostgreSQL schema:

-- migrations/001_initial.up.sql CREATE TABLE users ( id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, email TEXT UNIQUE NOT NULL, name TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE TABLE tasks ( id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, text TEXT NOT NULL, is_completed BOOLEAN DEFAULT false, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX tasks_user_idx ON tasks(user_id);

Set Up the Encore Database

import { SQLDatabase } from "encore.dev/storage/sqldb"; const db = new SQLDatabase("main", { migrations: "./migrations" }); // That's it. Encore provisions Cloud SQL PostgreSQL based on this declaration.

Step 2: Convert Queries to APIs

Convex queries become Encore GET endpoints:

Before (Convex query):

// convex/tasks.ts export const list = query({ args: {}, handler: async (ctx) => { const identity = await ctx.auth.getUserIdentity(); if (!identity) throw new Error("Not authenticated"); return await ctx.db .query("tasks") .withIndex("by_user", (q) => q.eq("userId", identity.subject)) .collect(); }, });

After (Encore API):

import { api } from "encore.dev/api"; import { getAuthData } from "~encore/auth"; interface Task { id: string; text: string; isCompleted: boolean; createdAt: Date; } export const listTasks = api( { method: "GET", path: "/tasks", expose: true, auth: true }, async (): Promise<{ tasks: Task[] }> => { const auth = getAuthData()!; const rows = await db.query<Task>` SELECT id, text, is_completed as "isCompleted", created_at as "createdAt" FROM tasks WHERE user_id = ${auth.userID} ORDER BY created_at DESC `; const tasks: Task[] = []; for await (const task of rows) { tasks.push(task); } return { tasks }; } );

Step 3: Convert Mutations to APIs

Convex mutations become POST/PATCH/DELETE endpoints:

Before (Convex mutation):

export const create = mutation({ args: { text: v.string() }, handler: async (ctx, args) => { const identity = await ctx.auth.getUserIdentity(); if (!identity) throw new Error("Not authenticated"); return await ctx.db.insert("tasks", { text: args.text, isCompleted: false, userId: identity.subject, }); }, });

After (Encore API):

export const createTask = api( { method: "POST", path: "/tasks", expose: true, auth: true }, async (req: { text: string }): Promise<Task> => { const auth = getAuthData()!; const task = await db.queryRow<Task>` INSERT INTO tasks (text, is_completed, user_id) VALUES (${req.text}, false, ${auth.userID}) RETURNING id, text, is_completed as "isCompleted", created_at as "createdAt" `; return task!; } ); export const completeTask = api( { method: "PATCH", path: "/tasks/:id/complete", expose: true, auth: true }, async ({ id }: { id: string }): Promise<Task> => { const auth = getAuthData()!; const task = await db.queryRow<Task>` UPDATE tasks SET is_completed = true WHERE id = ${id} AND user_id = ${auth.userID} RETURNING id, text, is_completed as "isCompleted", created_at as "createdAt" `; if (!task) throw new Error("Task not found"); return task; } ); export const deleteTask = api( { method: "DELETE", path: "/tasks/:id", expose: true, auth: true }, async ({ id }: { id: string }): Promise<{ deleted: boolean }> => { const auth = getAuthData()!; await db.exec`DELETE FROM tasks WHERE id = ${id} AND user_id = ${auth.userID}`; return { deleted: true }; } );

Step 4: Migrate Authentication

If you use Clerk with Convex, keep using Clerk:

import { authHandler, Gateway } from "encore.dev/auth"; import { createRemoteJWKSet, jwtVerify } from "jose"; const JWKS = createRemoteJWKSet( new URL("https://your-clerk-instance.clerk.accounts.dev/.well-known/jwks.json") ); export const auth = authHandler( async (params: { authorization: string }) => { const token = params.authorization.replace("Bearer ", ""); const { payload } = await jwtVerify(token, JWKS); return { userID: payload.sub as string, email: (payload.email as string) || "", }; } ); export const gateway = new Gateway({ authHandler: auth });

Step 5: Migrate File Storage

import { Bucket } from "encore.dev/storage/objects"; const files = new Bucket("files", { versioned: false }); export const uploadFile = api( { method: "POST", path: "/files", expose: true, auth: true }, async (req: { filename: string; data: Buffer }): Promise<{ url: string }> => { await files.upload(req.filename, req.data); return { url: files.publicUrl(req.filename) }; } );

Step 6: Migrate Scheduled Functions

Before (Convex crons):

// convex/crons.ts const crons = cronJobs(); crons.daily("cleanup", { hourUTC: 2, minuteUTC: 0 }, "tasks:cleanup");

After (Encore CronJob):

import { CronJob } from "encore.dev/cron"; export const cleanup = api( { method: "POST", path: "/internal/cleanup" }, async (): Promise<{ deleted: number }> => { const result = await db.exec` DELETE FROM tasks WHERE is_completed = true AND created_at < NOW() - INTERVAL '30 days' `; return { deleted: result.rowsAffected }; } ); const _ = new CronJob("cleanup", { title: "Daily cleanup of old completed tasks", schedule: "0 2 * * *", endpoint: cleanup, });

Step 7: Handle Real-time (if needed)

For real-time updates, options include:

Polling: Simple approach for occasional updates.

Pub/Sub for backend events:

import { Topic, Subscription } from "encore.dev/pubsub"; interface TaskEvent { taskId: string; userId: string; action: "created" | "completed" | "deleted"; } export const taskEvents = new Topic<TaskEvent>("task-events", { deliveryGuarantee: "at-least-once", }); // Publish in mutations await taskEvents.publish({ taskId: task.id, userId: auth.userID, action: "created" }); // Subscribe for backend reactions const _ = new Subscription(taskEvents, "notify", { handler: async (event) => { await notifyCollaborators(event.userId, event.taskId); }, });

WebSocket streaming: Encore supports streaming APIs for client connections.

Step 8: Deploy to GCP

  1. Connect GCP project in Encore Cloud. See the GCP setup guide for details.
  2. Push your code:
    git push encore main
  3. Run data migration against Cloud SQL
  4. Update frontend to call REST API instead of Convex client

What Gets Provisioned

  • Cloud SQL PostgreSQL for your database
  • Google Cloud Storage for files
  • Cloud Run for your APIs
  • Cloud Scheduler for cron jobs
  • GCP Pub/Sub for messaging (if used)

Migration Checklist

  • Export Convex data
  • Design PostgreSQL schema
  • Write data migration scripts
  • Convert queries to GET endpoints
  • Convert mutations to POST/PATCH/DELETE endpoints
  • Set up auth handler with existing provider
  • Migrate file storage
  • Convert scheduled functions to cron
  • Plan for real-time updates
  • Update frontend to REST API
  • Test in preview environment
  • Deploy and monitor

Wrapping Up

Migrating from Convex to GCP trades automatic reactivity for infrastructure ownership and SQL flexibility. Cloud Run provides similar performance characteristics, and PostgreSQL gives you more query power. Encore maintains good TypeScript ergonomics while deploying to infrastructure you control.

Ready to escape the maze of complexity?

Encore Cloud is the development platform for building robust type-safe distributed systems with declarative infrastructure.