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.
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.
| Convex Component | GCP Equivalent (via Encore) |
|---|---|
| Convex Database | Cloud SQL PostgreSQL |
| Convex Functions | Cloud Run |
| File Storage | Google Cloud Storage |
| Scheduled Functions | Cloud Scheduler |
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.
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.
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.
npx convex export --path ./convex-backup
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);
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.
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 };
}
);
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 };
}
);
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 });
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) };
}
);
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,
});
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.
git push encore main
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.