Cloud Service Migration Guide: How to Switch Without Downtime (2026)
Switching cloud services is mostly about managing risk, not writing code. The code change is usually small; the hard parts are keeping production up during the swap, keeping your user data intact, and having a way back if something goes wrong.
This guide covers the common migration paths — email, auth, database, and payments — with realistic difficulty ratings, timeline estimates, and the parts that typically break. Each section answers the same three questions: what changes, what breaks, and how long it takes.
Migration difficulty matrix (quick reference)
| From → To | Category | Difficulty | Realistic Timeline | Can run in parallel? | Risk |
|---|---|---|---|---|---|
| SendGrid → Resend | Low | 1-2 hours | Yes | Low | |
| SendGrid → Postmark | Low-Medium | 2-4 hours | Yes | Low | |
| Auth0 → Clerk | Auth | Medium | 1-2 days | Yes (dual-verify tokens) | Medium |
| Auth0 → Better Auth | Auth | High | 3-5 days | Partial | High |
| Render → Neon (Postgres) | Database | Low-Medium | 2-4 hours + data transfer | Read replica first | Low |
| Stripe → Lemon Squeezy | Payments | Medium | 1-2 days + 2-4 weeks parallel | Yes (new customers only) | Medium |
Low = stateless API swap. Medium = has user data to migrate. High = requires full architectural rewrite.
How to plan any service migration
Before touching code, answer these four questions:
1. Can you run both services in parallel? For stateless services (email, analytics, logging) — yes, always. Send to both during the cutover window. For stateful services (auth, database, payments) — usually partially. You can dual-write new records to both systems but the old records’ ownership becomes a migration project of its own.
2. What happens to existing user data?
- Email/SMS: no user data on the provider side — trivial.
- Auth: password hashes, sessions, and identity links. The new provider has to be able to import your existing password hash format (most support bcrypt import).
- Database:
pg_dump/pg_restoreequivalents, or the provider’s native import. Allow for a read-only window equal to dump-time × 2. - Payments: you cannot transfer subscriptions between merchants. Every existing customer has to re-authorize billing on the new platform. Plan for 2-4 weeks of parallel operation.
3. What’s your rollback plan? If the new service fails in production hour one, can you revert? For stateless migrations (email, logging) rollback is a deploy. For stateful (auth, DB) you need either (a) a snapshot of the old service state at cutover time, or (b) a dual-write window where both systems hold current data. Plan rollback before you migrate, not after something breaks.
4. Where does downtime live?
- Zero downtime: swap DNS / API endpoint after data is synced.
- Short window (seconds to minutes): DB cutover via logical replication.
- Hours: classic dump/restore on large datasets.
- Days: unacceptable unless you plan a scheduled maintenance window.
Email migrations
SendGrid → Resend
Why migrate: SendGrid trust declining post-Twilio acquisition, free tier removed March 2025, reports of intermittent deliverability issues.
Difficulty: Low (1-2 hours)
What changes:
- Package:
@sendgrid/mail→resend - API key format:
SG.*→re_* - Send call:
sgMail.send({to, from, subject, html})→resend.emails.send({to, from, subject, html}) - Webhooks: different payload format — update your webhook handler
Code change:
// Before — SendGrid
import sgMail from '@sendgrid/mail';
sgMail.setApiKey(process.env.SENDGRID_API_KEY);
await sgMail.send({
to: '[email protected]',
from: '[email protected]',
subject: 'Reset your password',
html: '<p>Click <a href="...">here</a> to reset.</p>'
});
// After — Resend
import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY);
await resend.emails.send({
to: '[email protected]',
from: '[email protected]',
subject: 'Reset your password',
html: '<p>Click <a href="...">here</a> to reset.</p>'
});
What doesn’t change: DNS records (DKIM/SPF/DMARC) work with any provider. Your sending domain stays the same — only the provider doing the actual send changes.
Migration steps:
- Sign up at resend.com, verify your sending domain (Resend uses its own DKIM/SPF — add them alongside the existing SendGrid records).
npm uninstall @sendgrid/mail && npm install resend- Replace send calls — API is simpler (fewer lines, Promise-based).
- Update webhook endpoint for Resend’s payload format (
email.sent,email.delivered,email.bounced, etc.). - Test in staging — watch the 100/day free tier cap.
- Deploy to production behind a feature flag. Switch traffic incrementally (10% → 50% → 100%) and monitor deliverability dashboards.
- Once 100% on Resend for 48 hours with no deliverability regression, remove SendGrid.
Risk: Low. Email APIs are stateless — you can dual-send during cutover. Rollback = feature flag to SendGrid.
SendGrid → Postmark
Why migrate: Postmark is singularly focused on transactional email and publishes better deliverability stats than any competitor. Pricing is simpler (per-email, no tiered MAU nonsense).
Difficulty: Low-Medium (2-4 hours)
What changes:
- Package:
@sendgrid/mail→postmark - Call signature:
sgMail.send({to, from, subject, html})→client.sendEmail({To, From, Subject, HtmlBody}) - Must configure message streams (transactional vs broadcast) — different streams have different sending limits and sender reputations.
- DNS: Postmark requires its own DKIM record alongside whatever you have for SendGrid.
Code change:
// Before — SendGrid
import sgMail from '@sendgrid/mail';
sgMail.setApiKey(process.env.SENDGRID_API_KEY);
await sgMail.send({ to, from, subject, html });
// After — Postmark
import { ServerClient } from 'postmark';
const client = new ServerClient(process.env.POSTMARK_TOKEN);
await client.sendEmail({
To: to,
From: from,
Subject: subject,
HtmlBody: html,
MessageStream: 'outbound' // or your configured transactional stream
});
Migration steps:
- Sign up at postmarkapp.com, create a server and a transactional stream.
- Add Postmark’s DKIM record to your DNS (keep the SendGrid record live for parallel sending).
npm uninstall @sendgrid/mail && npm install postmark- Replace send calls. Note the capitalized field names (
To,From,Subject). - Test deliverability — Postmark’s dashboard shows per-message status in near real time.
- Cut over behind a feature flag; same rollout pattern as SendGrid → Resend.
Auth migrations
Auth0 → Clerk
Why migrate: Better developer experience, simpler pricing at low-to-mid scale, pre-built UI components that drop in without design work.
Difficulty: Medium (1-2 days active work, plus a parallel-auth window of 1-2 weeks)
What changes:
- SDK:
@auth0/nextjs-auth0→@clerk/nextjs - Session management: Auth0 OIDC sessions → Clerk-managed sessions (different cookie format).
- UI: Auth0 Universal Login (hosted page) → Clerk’s
<SignIn />,<UserButton />components (embed in your own app). - User data: must export from Auth0 and import to Clerk — this is the actual migration work.
Code change:
// Before — Auth0 in Next.js App Router
// app/api/auth/[auth0]/route.ts
import { handleAuth } from '@auth0/nextjs-auth0';
export const GET = handleAuth();
// Client component
import { useUser } from '@auth0/nextjs-auth0/client';
const { user } = useUser();
// After — Clerk in Next.js App Router
// app/layout.tsx
import { ClerkProvider } from '@clerk/nextjs';
export default function Layout({ children }) {
return (
<ClerkProvider>
<html><body>{children}</body></html>
</ClerkProvider>
);
}
// Client component
import { useUser } from '@clerk/nextjs';
const { user } = useUser(); // same hook name, different provider
What breaks:
- Password hashes: Auth0 uses bcrypt; Clerk can import bcrypt hashes but the process requires a Clerk support ticket to enable the bulk import endpoint.
- Custom claims / roles: Auth0 Actions ↔ Clerk
publicMetadata/privateMetadata— different API shape, manual remap. - Webhooks: Different event names and payload format. Rewrite every handler.
- MFA: Auth0 MFA enrollments don’t transfer — users have to re-enroll on Clerk.
Migration steps:
- Set up Clerk project, configure OAuth providers (Google, GitHub, etc.) mirroring your Auth0 config.
- Export users from Auth0 (Management API →
GET /api/v2/users). - Import to Clerk via Backend API with bulk user creation and password hashes.
- Replace SDK calls —
useUser()(Auth0) anduseUser()(Clerk) have similar shapes, so components mostly compile with minimal edits. - Replace UI components (Auth0 Universal Login redirect →
<SignIn />embed). - Run parallel auth for 1-2 weeks: your backend accepts sessions from both providers. New signups go to Clerk; existing users still authenticated to Auth0 get migrated on their next login.
- Once the Auth0 session count drops to ~0, decommission.
Risk: Medium. The password-hash import is the highest-risk step — do a full dry-run on a staging environment with real user data.
Auth0 → Better Auth
Why migrate: Zero per-user cost (open source, self-hosted), full data ownership, GDPR compliance without the Auth0 DPF dance.
Difficulty: High (3-5 days active work + test hardening)
What changes:
- Everything. Auth0 is managed; Better Auth runs in your own process against your own database.
- You own: session storage, password hashing, token generation, session revocation, rate limiting, brute-force protection.
- UI: build login / signup / password-reset / MFA screens from scratch — Better Auth is a library, not a UI kit.
What breaks:
- All Auth0-specific features (Actions, Rules, Hooks, custom domains).
- Social login config moves from Auth0 dashboard to your code.
- Password hashes: Auth0 bcrypt → Better Auth’s built-in hashing; can be imported during auth on first login (verify against Auth0 hash, then rehash and store).
Risk: High. Full auth rewrite with direct responsibility for security hardening. Only choose this path if Auth0 costs are genuinely painful or compliance demands data sovereignty.
Database migrations
Render → Neon (Postgres)
Why migrate: Serverless scaling (no idle cost), database branching for preview environments, better integration with edge runtimes.
Difficulty: Low-Medium (2-4 hours for app changes; data transfer time depends on DB size)
What changes:
- Connection string:
postgres://[email protected]→postgres://[email protected] - Driver:
pgstill works; optionally switch to@neondatabase/serverlessfor edge runtimes. - No schema changes needed — both are standard PostgreSQL 15.
Code change:
// Before — Render, standard pg
import pg from 'pg';
const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
ssl: { rejectUnauthorized: false }
});
const { rows } = await pool.query('SELECT * FROM users WHERE id = $1', [id]);
// After — Neon serverless driver (optional, for edge runtimes)
import { neon } from '@neondatabase/serverless';
const sql = neon(process.env.DATABASE_URL);
const [user] = await sql`SELECT * FROM users WHERE id = ${id}`;
The standard
pgdriver also works with Neon — you can just swap theDATABASE_URLenvironment variable without changing any code. Switch to the serverless driver later if/when you deploy to Cloudflare Workers, Vercel Edge, or similar edge runtimes.
Migration steps:
- Create a Neon project; copy the pooled connection string.
- Take a consistent dump from Render:
pg_dump $RENDER_URL > dump.sql - Restore into Neon:
psql $NEON_URL < dump.sql - Update
DATABASE_URLin your environment. - Test with your ORM — Prisma/Drizzle migrations should replay cleanly.
- For the cutover itself, use Postgres logical replication for zero-downtime: set up Neon as a subscriber to Render, wait for lag to hit zero, flip the app, stop replication.
Risk: Low if done during low-traffic hours with a clean dump. The one real gotcha: Neon pauses inactive databases (default: 5 min idle) — if your app has long-lived connections without keepalive, you’ll see 300-500ms cold starts on first request after idle. Switch to the serverless driver or tune the connection pool.
Payment migrations
Stripe → Lemon Squeezy
Why migrate: You’re tired of handling sales tax / VAT yourself and want a merchant of record to take that complexity (and liability) off your plate.
Difficulty: Medium (1-2 days active work + 2-4 weeks of parallel operation)
What changes:
- You are no longer the merchant of record. Lemon Squeezy handles taxes, invoices, refunds, and compliance — in exchange for higher fees.
- Fees: 2.9% + $0.30 (Stripe) → 5% + $0.50 (Lemon Squeezy). Cost goes up. That’s the tradeoff.
- Checkout: Stripe Checkout → Lemon Squeezy Checkout (different JS SDK).
- Webhooks: different event names and payload format.
- Customer data: lives in Lemon Squeezy, not your database.
Code change:
// Before — Stripe Checkout
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const session = await stripe.checkout.sessions.create({
line_items: [{ price: 'price_xxx', quantity: 1 }],
mode: 'subscription',
success_url: 'https://yourapp.com/success',
cancel_url: 'https://yourapp.com/cancel',
});
// After — Lemon Squeezy Checkout
import { lemonSqueezySetup, createCheckout } from '@lemonsqueezy/lemonsqueezy.js';
lemonSqueezySetup({ apiKey: process.env.LEMON_SQUEEZY_API_KEY });
const { data } = await createCheckout(storeId, variantId, {
checkoutData: { email: '[email protected]' },
});
// Redirect the user to data.data.attributes.url
What breaks:
- All Stripe webhook handlers need rewriting for Lemon Squeezy event formats (
order_created,subscription_created,subscription_updated, etc.). - Customer portal: Stripe’s
billingPortal.sessions.createhas no direct Lemon Squeezy equivalent; you link out to Lemon Squeezy’s hosted customer portal with a different URL pattern. - Existing subscriptions cannot be transferred. Lemon Squeezy has no API to import Stripe subscriptions. Every existing customer has to complete a new checkout on Lemon Squeezy — which means they notice a billing change and some will churn.
Migration steps:
- Set up Lemon Squeezy store, create products and variants matching your Stripe catalog.
- Replace Stripe Checkout with Lemon Squeezy Checkout for new customers.
- Rewrite webhook handlers for Lemon Squeezy event payloads.
- For existing customers: send a billing-change notification and a Lemon Squeezy checkout link to re-authorize. Budget 2-4 weeks for full cutover.
- Keep Stripe subscriptions billing normally during the parallel period. Move to Lemon Squeezy only when that customer re-authorizes.
- Once Stripe subscription count drops below your churn floor, cancel the remaining Stripe subscriptions and decommission.
Risk: Medium. The existing-subscription migration is the hardest part — you will lose some customers to the billing-change friction. Plan the customer-facing messaging carefully.
Common questions
Can I migrate without downtime?
Mostly yes, with planning:
- Stateless services (email, logging, analytics) — always zero downtime. Dual-send during the cutover, then turn off the old one.
- Database — zero downtime requires logical replication. Set up the new DB as a replica of the old, wait for lag to hit zero, flip the application connection, promote the new DB.
- Auth — near-zero downtime via parallel auth: both providers accept logins, new signups go to the new one, existing users migrate on their next login.
- Payments — impossible to do with zero customer impact. Plan 2-4 weeks of parallel operation and communicate the billing change to existing subscribers.
What should I test before cutting over?
At minimum:
- Functional: every critical user flow runs end-to-end on the new service in staging.
- Load: the new service holds up to your peak traffic (not average — peak). Most deliverability / rate-limit problems show up under load, not in integration tests.
- Rollback: you’ve actually executed the rollback at least once in staging. Don’t test rollback in prod at 3am during an incident.
How do I roll back?
Different per category:
- Stateless: feature flag toggle, redeploy. Seconds.
- Database: keep the old DB running read-only for 24-48h post-cutover. If something breaks, point the app back. After 48h of clean operation, decommission.
- Auth: the parallel-auth window is the rollback — if the new provider misbehaves, route new logins back to the old one until fixed.
- Payments: you can’t really roll back subscriptions that moved to the new merchant. The only rollback is to stop moving new customers and keep charging the moved ones on the new platform.
How long does a service migration usually take?
For a single small-to-mid app with one engineer focused on it:
- Email: half-day.
- Auth: 2-5 days of coding + 1-2 weeks of parallel operation.
- Database: half-day to 2 days, plus data-transfer time for large datasets.
- Payments: 2-3 days of coding + 2-4 weeks for existing customers to cut over.
Multiply these for larger apps with more integration surface area.
Migrated a service lately? Share what broke → — the next developer avoiding this pitfall will thank you.