Di chuyển khỏi Supabase không mất dữ liệu: quy trình thực chiến cho đội dev
Supabase giúp đội dev đi nhanh: Postgres managed, Auth, Storage, Realtime, Edge Functions, API tự sinh. Nhưng đến lúc sản phẩm lớn hơn, yêu cầu khác hơn: cần kiểm soát hạ tầng sâu hơn, chi phí khó đoán, lock-in tính năng, compliance, latency vùng riêng, hoặc muốn tự vận hành trên AWS/GCP/Hetzner/Kubernetes. Khi đó, câu hỏi lớn không phải “có migrate được không”, mà là migrate không mất dữ liệu, không vỡ auth, không downtime dài bằng cách nào.
Bài này đưa quy trình thực chiến. Trọng tâm: Postgres, Auth, Storage, Realtime, API, cutover. Mục tiêu: dữ liệu toàn vẹn, rollback được, đội dev ngủ được.
1. Kiểm kê Supabase trước khi động vào dữ liệu
Đừng dump database ngay. Trước hết, lập bản đồ phụ thuộc.
– version Postgres tương thích
– extension hỗ trợ: uuid-ossp, pgcrypto, postgis, pg_trgm, vector
– backup PITR
– replica
– network private
– monitoring
– quyền superuser hạn chế hay không
Auth đích
Các lựa chọn:
– tự viết auth bằng Postgres + JWT
– Keycloak
– Auth0
– Clerk
– Ory
– Firebase Auth
Điểm đau lớn: password hash. Supabase Auth dùng GoTrue. Không phải lúc nào chuyển password sang hệ khác cũng đăng nhập được ngay. Cần kiểm tra thuật toán hash, format, provider identity.
Cách an toàn:
– nếu hệ đích hỗ trợ import hash: import trực tiếp
– nếu không: dùng “lazy migration”
– lần đăng nhập đầu, xác thực qua Supabase cũ
– đúng password thì tạo user trong hệ mới
– sau giai đoạn chuyển, tắt Supabase auth
psql "$SUPABASE_DB_URL" -c "copy public.orders TO 'orders.csv' CSV HEADER"
psql "$SUPABASE_DB_URL" -c "copy public.customers TO 'customers.csv' CSV HEADER"
Backup Storage
Nếu chuyển sang S3/R2, dùng script liệt kê object qua Supabase Storage API rồi tải về hoặc stream sang bucket mới. Không chỉ copy file. Cần copy metadata.
Checklist:
– tổng số object mỗi bucket
– tổng bytes
– random sample hash
– private object test
– public URL mapping
Backup secret
Xuất:
– service role key
– anon key
– JWT secret nếu tự quản
– OAuth client secret
– SMTP config
– webhook secret
– Edge Function env
Lưu trong secret manager, không commit Git.
4. Khôi phục sang Postgres mới
Tạo database đích cùng version hoặc version cao hơn đã test.
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
Với auth schema, cân nhắc kỹ. Nếu còn dùng Supabase Auth tạm thời, đừng sửa. Nếu chuyển auth, cần mapping user ID ổn định.
Kiểm tra sau restore
Chạy count:
SELECT schemaname, relname, n_live_tup
FROM pg_stat_user_tables
ORDER BY n_live_tup DESC;
So sánh count nguồn/đích bằng script. Không tin cảm giác.
Kiểm tra constraint:
SELECT conname, conrelid::regclass
FROM pg_constraint
WHERE contype = 'f';
Kiểm tra sequence:
SELECT sequence_schema, sequence_name
FROM information_schema.sequences;
Sau restore, sequence có thể lệch nếu import không đúng. Fix:
SELECT setval('public.orders_id_seq', (SELECT MAX(id) FROM public.orders));
5. Xử lý RLS và quyền truy cập
Supabase dùng RLS nhiều. Khi rời Supabase, có hai hướng.
Giữ RLS
Nếu app vẫn query trực tiếp Postgres qua API layer tự viết, giữ RLS có lợi. Nhưng cần thay auth.uid() và auth.jwt() vì Supabase cung cấp helper này.
Có thể tạo function tương đương dựa trên session variable:
CREATE SCHEMA IF NOT EXISTS auth;
CREATE OR REPLACE FUNCTION auth.uid()
RETURNS uuid
LANGUAGE sql STABLE
AS $$
SELECT nullif(current_setting('request.jwt.claim.sub', true), '')::uuid;
$$;
API layer set biến:
SET LOCAL request.jwt.claim.sub = 'user-id';
Bỏ RLS, chuyển logic lên backend
Hướng này dễ debug hơn, nhưng tăng trách nhiệm backend. Cần audit toàn bộ query. Không được bỏ RLS rồi để client gọi database trực tiếp.
Quy tắc: nếu bỏ RLS, phải có backend gatekeeper.
6. Di chuyển Auth không khóa user ngoài cửa
Auth là phần dễ làm khách hàng tức giận nhất.
Giữ user ID
Nếu bảng nghiệp vụ tham chiếu auth.users.id, phải giữ ID. Khi import sang hệ auth mới, dùng cùng UUID nếu hệ đó cho phép. Nếu không, tạo bảng mapping:
CREATE TABLE auth_user_mapping (
old_user_id uuid PRIMARY KEY,
new_user_id text NOT NULL UNIQUE,
migrated_at timestamptz DEFAULT now()
);
Backend phải resolve mapping trong mọi request. Cồng kềnh nhưng an toàn.
Chiến lược password
Ba hướng:
1. Import hash trực tiếp nếu tương thích.
2. Lazy migration qua đăng nhập thật.
3. Forced reset password cho toàn bộ user.
Hướng 3 đơn giản nhưng trải nghiệm xấu. Chỉ dùng khi user ít, B2B kiểm soát được, hoặc yêu cầu bảo mật bắt buộc.
OAuth identity
Với Google/GitHub/Apple, cần giữ provider subject. Email không đủ. Email có thể đổi. Provider ID ổn hơn.
7. Đồng bộ dữ liệu trong giai đoạn chuyển
Nếu downtime chấp nhận được, quy trình đơn giản:
1. bật maintenance mode
2. chặn write
3. dump lần cuối
4. restore
5. verify
6. đổi traffic
Nếu không muốn downtime dài, cần sync incremental.
Cách thực tế
– dùng logical replication nếu Postgres đích hỗ trợ
– dùng Debezium nếu có Kafka/CDC stack
– dùng trigger ghi changelog rồi replay
– dùng dual-write tạm thời trong app
Dual-write dễ nghe, khó đúng. Lỗi một bên gây lệch. Nếu dùng, cần outbox pattern.
Bảng outbox:
CREATE TABLE outbox_events (
id bigserial PRIMARY KEY,
aggregate_type text NOT NULL,
aggregate_id text NOT NULL,
event_type text NOT NULL,
payload jsonb NOT NULL,
created_at timestamptz DEFAULT now(),
processed_at timestamptz
);
Worker đọc outbox, ghi sang hệ mới, retry idempotent.
8. Cutover: đổi hệ thống mà không đánh cược
Trước cutover, chạy rehearsal ít nhất một lần trên staging với production dump đã ẩn dữ liệu nhạy cảm nếu cần.
Checklist cutover
1. thông báo nội bộ
2. bật maintenance mode nếu cần
3. tắt worker ghi dữ liệu
4. chạy backup cuối
5. restore hoặc apply delta cuối
6. verify count, checksum, sample business flow
7. đổi env backend sang database mới
8. đổi storage endpoint
9. đổi auth issuer/JWKS
10. deploy app
11. monitor lỗi 5xx, auth fail, DB latency
12. giữ Supabase read-only trong thời gian rollback window
Verify dữ liệu
Không chỉ count. Dùng checksum theo bảng quan trọng:
SELECT md5(string_agg(id::text || updated_at::text, ',' ORDER BY id))
FROM public.orders;
Với bảng lớn, checksum theo batch:
SELECT min(id), max(id), count(*)
FROM public.orders
GROUP BY id / 10000
ORDER BY min(id);
9. Rollback plan: phải viết trước, không viết khi cháy
Rollback cần rõ:
– khi nào rollback
– ai quyết định
– rollback mất bao lâu
– dữ liệu ghi vào hệ mới xử lý sao
– DNS/env revert thế nào
Nếu sau cutover vẫn cho user ghi vào hệ mới, rollback về Supabase cũ sẽ mất phần ghi mới nếu không replay ngược. Vì vậy, trong vài giờ đầu, có thể:
– giữ maintenance ngắn
– bật write ở hệ mới
– ghi outbox để có thể replay ngược
– hoặc chấp nhận forward-only, không rollback database, chỉ rollback app
Rollback không miễn phí. Không thiết kế trước là không có rollback.
10. Dọn lock-in trong code
Sau khi chạy ổn, thay Supabase SDK bằng layer trung lập.
Trước:
const { data } = await supabase.from('orders').select('*')
Sau:
const orders = await orderRepository.findMany()
Lợi ích:
– đổi database dễ hơn
– test dễ hơn
– không rò query logic ra UI
– kiểm soát auth tốt hơn
Đừng để migration kết thúc bằng hệ mới nhưng lock-in kiểu mới.
Kết luận: migrate khỏi Supabase là dự án dữ liệu, không phải đổi connection string
Di chuyển khỏi Supabase không mất dữ liệu cần kỷ luật: kiểm kê kỹ, backup nhiều lớp, restore có kiểm chứng, auth có chiến lược, storage có đối soát, cutover có rehearsal, rollback có văn bản. Đội dev nên xem đây là dự án hạ tầng quan trọng, không phải task phụ cuối sprint.
Công thức thực tế:
– Inventory trước
– Backup trước khi sửa
– Restore trên staging
– Verify bằng script
– Cutover có checklist
– Giữ đường rollback
– Dọn code khỏi phụ thuộc SDK
Làm đúng, rời Supabase không drama. Làm vội, lỗi không nằm ở Supabase hay Postgres. Lỗi nằm ở quy trình.