SSD Nodes Learn Hosting plans →
คู่มือ Matt Connorโดย Matt Connor

ย้ายโปรเจกต์ Firebase มาอยู่บน VPS ของคุณเอง

ไล่ทีละส่วนว่าอะไรใน Firebase ย้ายลง Supabase หรือ Postgres บน VPS ได้ตรง ๆ อะไรต้องเขียนใหม่ และอะไรไม่มีของแทน พร้อมวิธีแปลง document เป็นตาราง และแปลง security rules เป็น RLS

ย้ายโปรเจกต์ Firebase มาอยู่บน VPS ได้จริงแค่ไหน

การย้ายโปรเจกต์ Firebase มาอยู่บน VPS ของตัวเอง แยกออกได้เป็นสองกองงาน คือข้อมูลและตรรกะ ข้อมูลย้ายได้เกือบครบ เพราะ Firestore, Authentication และ Cloud Storage มีคำสั่ง export ที่เป็นทางการอยู่แล้ว ส่วนตรรกะที่ Firebase รันแทนคุณอยู่นั้นต้องเขียนใหม่ เพราะ security rules, Cloud Functions และการส่ง push ไม่มีไฟล์ไหนที่ยกไปวางบน Postgres แล้วทำงานต่อได้เลย

จุดที่ทีมส่วนใหญ่ติดจริง ๆ มีสองจุด จุดแรกคือการตัดสินใจว่า document หนึ่งก้อนจะกลายเป็นตารางหน้าตาแบบไหน จุดที่สองคือการเปลี่ยน security rules ไปเป็น RLS (row level security) ของ Postgres ซึ่งคือการเขียนกฎเดิมใหม่ในอีกภาษาหนึ่ง สองหัวข้อนั้นจึงได้พื้นที่มากที่สุดในหน้านี้

ปลายทางที่ใช้แรงน้อยที่สุดคือ Supabase แบบ self-hosted เพราะชุด docker compose ของมันรวม Postgres, ระบบ auth ที่ออก JWT (JSON web token), REST API ที่สร้างจากตารางให้อัตโนมัติ และ storage ไว้ด้วยกัน ถ้าแอปของคุณใช้ Firebase เป็นที่เก็บข้อมูลกับระบบ login เป็นหลัก ทางนี้ใกล้ที่สุด ถ้าอยากเทียบทางเลือกอื่นก่อน ลองอ่าน ตัวเลือก self-hosted ที่ใช้แทน Firebase ได้ แล้วค่อยกลับมา ส่วนขั้นตอนติดตั้งบนเครื่องเปล่าอยู่ใน คู่มือติดตั้ง Supabase บน VPS ของตัวเอง

คำสั่งทุกคำสั่งในหน้านี้ต้องมีบัญชี Google ที่ล็อกอินแล้ว มีสิทธิ์ IAM (identity and access management) บนโปรเจกต์นั้น และต้องต่อออกอินเทอร์เน็ตไปหา Google ได้ จึงเป็นตัวอย่างที่คุณรันกับโปรเจกต์ของคุณเอง ผลลัพธ์ที่ขึ้นบนจอจะต่างกันไปตามข้อมูลของคุณ อ่านค่าที่ได้จริงก่อนไปขั้นถัดไปทุกครั้ง

สำรวจก่อนว่าโปรเจกต์ Firebase ของคุณมีอะไรอยู่ข้างใน

คนส่วนใหญ่วางแผนย้ายโดยนับแค่ Firestore แล้วไปเจอของที่ลืมตอนตัดสวิตช์ ลิสต์ข้างล่างคือของที่โปรเจกต์หนึ่งตัวมักมี ไล่ดูทุกบรรทัดในคอนโซลของคุณเองก่อนเริ่ม

  • Firestore หรือ Realtime Database (RTDB) คือข้อมูลหลักของแอป
  • Authentication คือบัญชีผู้ใช้ รหัสผ่านที่ hash ไว้ และ provider ที่ผูกไว้ เช่น Google หรือ LINE ผ่าน OIDC
  • Cloud Storage for Firebase คือไฟล์ทั้งหมดใน bucket เช่นรูปโปรไฟล์และสลิปโอนเงิน
  • security rules คือไฟล์ firestore.rules และ storage.rules
  • Cloud Functions คือโค้ดฝั่ง server ทั้งหมด รวม trigger ที่ยิงเองเวลามี document ใหม่
  • Firebase Cloud Messaging (FCM) คือช่องทางส่ง push notification
  • Hosting, Remote Config, Crashlytics, Analytics และ App Check คือส่วนที่มักถูกลืมตอนวางแผน แล้วมาโผล่ตอนแอปเวอร์ชันถัดไปขึ้นสโตร์

เริ่มจากดูว่ามีอะไรอยู่จริง

firebase login
firebase projects:list
firebase use <project-id>
firebase functions:list

gcloud auth login
gcloud config set project <project-id>
gcloud firestore databases list
gcloud storage ls

gcloud storage ls จะพิมพ์ชื่อ bucket ออกมา ให้จดไว้ เพราะโปรเจกต์รุ่นเก่าใช้ชื่อลงท้าย .appspot.com และโปรเจกต์รุ่นใหม่ใช้ .firebasestorage.app การเดาชื่อ bucket ทำให้คำสั่ง rsync ข้างล่างล้มด้วยข้อความว่าหา bucket ไม่เจอ ให้ก๊อปชื่อจากผลลัพธ์จริงเสมอ

ดึงข้อมูลออกมา: Firestore, Auth และ Storage

การ export Firestore แบบที่ Google รองรับต้องใช้โปรเจกต์ที่อยู่บนแผน Blaze และบัญชีของคุณต้องมีบทบาท Owner, Cloud Datastore Owner หรือ Cloud Datastore Import Export Admin ตามที่เอกสารระบุ นอกจากนี้ service agent ของ Firestore ที่ชื่อ service-PROJECT_NUMBER@gcp-sa-firestore.iam.gserviceaccount.com ต้องมีสิทธิ์ Storage Admin บน bucket ปลายทาง ถ้าไม่ให้สิทธิ์ตัวนี้ งาน export จะล้มเพราะเขียนลง bucket ไม่ได้ ไม่ใช่เพราะข้อมูลมีปัญหา

gcloud firestore export gs://<bucket>/ff-export-2026-09-29 \
  --collection-ids=users,orders,payments \
  --database='(default)'

ใส่ --collection-ids ไว้เสมอ แม้จะอยากได้ทั้งฐาน เพราะเส้นทางแปลงข้อมูลผ่าน BigQuery ข้างล่างบังคับว่า export ต้องระบุ collection id ตอนสั่ง ถ้าลืมใส่ คุณจะต้องสั่ง export ใหม่ทั้งรอบ และจ่ายค่าอ่านเอกสารสองรอบ

ต่อมาคือผู้ใช้ คำสั่งนี้เขียนไฟล์ออกมาในเครื่องของคุณ

firebase auth:export users.json --format=json --project <project-id>

และไฟล์ใน Storage ให้ดึงลงมาด้วย rsync โดยซ้อม --dry-run ก่อนหนึ่งรอบ

gcloud storage rsync gs://<bucket> ./storage-dump --recursive --dry-run
gcloud storage rsync gs://<bucket> ./storage-dump --recursive

อย่าใส่ --delete-unmatched-destination-objects ตอนดึงลงเครื่อง เพราะแฟล็กนั้นสั่งให้ปลายทางเหมือนต้นทางเป๊ะ ไฟล์ที่คุณเผลอเก็บไว้ในโฟลเดอร์เดียวกันจะถูกลบทิ้ง ตัวแฟล็กมีประโยชน์ตอนอัปขึ้น ไม่ใช่ตอนดึงลง

สิ่งที่ยังไม่ได้ออกมากับสามคำสั่งข้างบนคือไฟล์กฎและโค้ด ให้ดึงมาจาก repo ของคุณ หรือ firebase init ในโฟลเดอร์เปล่าแล้วเลือกดาวน์โหลดกฎที่ใช้งานอยู่ ถ้ากฎที่รันบนโปรดักชันไม่ตรงกับไฟล์ใน git ให้เชื่อของที่คอนโซลแสดง เพราะนั่นคือกฎที่บังคับใช้จริง

ทำไมไฟล์ export ของ Firestore เปิดอ่านตรง ๆ ไม่ได้

เปิดโฟลเดอร์ที่ export ออกมาแล้วคุณจะไม่เจอ JSON แม้แต่ไฟล์เดียว Firestore เขียนออกมาเป็นรูปแบบเฉพาะที่ใช้ร่วมกับ Datastore ไม่ใช่ข้อความที่มนุษย์อ่านได้ มีสองทางที่จะได้ข้อมูลออกมาเป็นแถวที่ใส่ Postgres ได้

ทางแรกคือผ่าน BigQuery ซึ่งอ่านรูปแบบนี้ได้ตรง ๆ

gcloud storage ls -r gs://<bucket>/ff-export-2026-09-29

bq --location=<location> load \
  --source_format=DATASTORE_BACKUP \
  staging.orders \
  gs://<bucket>/ff-export-2026-09-29/all_namespaces/kind_orders/all_namespaces_kind_orders.export_metadata

รันคำสั่ง ls -r ก่อนเสมอ แล้วก๊อป path จริงมาใช้ เพราะเอกสารกำหนดว่า path ต้องลงท้ายด้วย KIND_COLLECTION_ID.export_metadata ใส่ wildcard ไม่ได้ และใส่ได้ครั้งละหนึ่ง URI เท่านั้น ข้อจำกัดอีกข้อคือ dataset ต้องอยู่ region เดียวกับ bucket และ append ต่อท้ายตารางเดิมไม่ได้ ทำได้แค่สร้างใหม่หรือเขียนทับ ดังนั้นสาม collection คือสามคำสั่ง จากนั้นสั่ง bq extract ออกมาเป็น newline delimited JSON แล้ว rsync ลงเครื่อง

ทางที่สองคือเขียนสคริปต์สั้น ๆ ด้วย Admin SDK อ่าน collection ทีละตัวแล้วเขียนไฟล์ JSON บรรทัดละหนึ่ง document ทางนี้ข้าม BigQuery ไปเลย และคุมรูปแบบผลลัพธ์ได้เอง ข้อแลกเปลี่ยนคือคุณจ่ายค่าอ่านหนึ่งครั้งต่อหนึ่ง document เหมือนกัน และต้องจัดการ pagination เองเวลา collection ใหญ่

ไม่ว่าเลือกทางไหน ให้โหลดไฟล์เข้า staging table ที่มีคอลัมน์ jsonb เดียวก่อน แล้วค่อยแปลงด้วย SQL ข้างใน Postgres การแปลงในภาษา SQL ตรวจง่ายกว่าการแปลงในสคริปต์ เพราะคุณ query ดูผลได้ทันทีว่ามีแถวไหนแปลงไม่ผ่าน

create schema if not exists staging;
create table staging.orders_raw (doc jsonb);
psql "$DATABASE_URL" <<'SQL'
\copy staging.orders_raw (doc) from 'orders.jsonl' with (format csv, quote e'\x01', delimiter e'\x02')
SQL

ที่ต้องใช้ format csv กับอักขระควบคุมแปลก ๆ สองตัวนั้นมีเหตุผล รูปแบบ text ที่เป็นค่าเริ่มต้นของ \copy ตีความ backslash เป็นตัวหนีอักขระ ดังนั้น JSON ที่มี \" หรือ \\ อยู่ข้างใน จะทำให้โหลดพังด้วยข้อความว่า invalid input syntax for type json ส่วน e'\x01' และ e'\x02' คืออักขระที่ไม่เคยปรากฏใน JSON ที่ SDK เขียนออกมา จึงไม่มีอะไรถูกตัดผิดที่

ดูของจริงก่อนเขียนคำสั่งแปลง เพราะโครงสร้างที่ได้จากเส้นทาง BigQuery กับเส้นทาง Admin SDK ไม่เหมือนกัน

select jsonb_pretty(doc) from staging.orders_raw limit 1;

จาก document ไปเป็นตาราง: การตัดสินใจที่ใช้เวลามากที่สุด

นี่คือส่วนที่ไม่มีเครื่องมือทำแทนได้ เพราะมันคือการออกแบบ ไม่ใช่การแปลงรูปแบบไฟล์ กับดักที่พบบ่อยที่สุดคือเทข้อมูลทั้งหมดลงตารางเดียวที่มีคอลัมน์ id กับ data jsonb วิธีนี้เสร็จในวันเดียวและเจ็บในเดือนที่สาม เพราะคุณเสีย foreign key เสีย constraint และทุก query กลายเป็นการไล่ path ใน jsonb ซึ่งอ่านยากและ index ยาก

กฎที่ใช้ตัดสินใจได้จริงมีข้อเดียว ฟิลด์ที่คุณ filter, sort หรือ join ด้วย ต้องเป็นคอลัมน์จริง ฟิลด์ที่คุณอ่านกลับไปพร้อมพ่อแม่ของมันโดยไม่เคยค้นหาข้างใน เก็บเป็น jsonb ได้สบาย เหตุผลคือ array ใน jsonb ใช้ btree index ตามปกติไม่ได้ ต้องใช้ GIN และ GIN ช่วยเรื่องการค้นสมาชิก แต่ไม่ช่วยเรื่องการเรียงลำดับ ดังนั้นฟิลด์ที่ใช้เรียงหน้ารายการจะช้าทันทีถ้าอยู่ใน jsonb

ต่อไปคือการแปลงที่พบบ่อย ไล่ทีละแบบ

  • document id ของ Firestore เป็นสตริง ให้เก็บเป็น text primary key ไปก่อนตอนย้าย เพราะลิงก์เก่า cache ในแอป และ path ใน Storage ยังอ้างค่าเดิมอยู่ ค่อยเปลี่ยนไปใช้ uuid ทีหลังถ้าอยาก
  • subcollection คือตารางลูก path แบบ users/{uid}/orders/{orderId} แปลว่าตาราง orders มีคอลัมน์ชี้ไปหาเจ้าของ ตัว path นั้นคือความสัมพันธ์อยู่แล้ว คุณแค่เขียนมันลงคอลัมน์
  • ฟิลด์ที่เป็น map เก็บเป็น jsonb ได้ ถ้าไม่เคย query ข้างใน
  • ฟิลด์ที่เป็น array ของ id ให้ทำเป็นตารางเชื่อม เพราะนั่นคือความสัมพันธ์แบบหลายต่อหลาย
  • ฟิลด์ตัวเลขที่เป็นเงิน อย่าเก็บเป็น double Firestore เก็บตัวเลขเป็น int64 หรือ double เท่านั้น การคูณภาษีกับราคาที่เป็น double ทำให้ยอดเพี้ยนระดับสตางค์ และยอดที่เพี้ยนสะสมในรายงานสิ้นเดือนคือปัญหาที่หาต้นตอยาก เก็บเป็นสตางค์ใน bigint หรือใช้ numeric
  • เวลา ให้ใช้ timestamptz เก็บเป็น UTC แล้วแปลงตอนอ่านด้วย at time zone 'Asia/Bangkok' ถ้าเก็บเวลาเป็น text ในรูปแบบไทย การเรียงลำดับจะผิดทันทีที่มีบางแถวเขียนมาด้วยรูปแบบอื่น

เรื่องที่คนมักไม่ทันคิดคือข้อมูลซ้ำ Firestore ไม่มี join ดังนั้นแนวทางที่ทุกคนถูกสอนมาคือก๊อปฟิลด์ไปไว้ในหลายที่ เช่นใส่ ownerName ลงในทุก order บน Postgres การ join ราคาถูกกว่าความเสี่ยงที่สำเนาจะค้าง ให้ลบสำเนาออกแล้ว join แทน ยกเว้นกรณีที่คุณต้องการค่า ณ เวลานั้นจริง ๆ เช่นราคาสินค้าบนใบเสร็จ อันนั้นเก็บเป็นคอลัมน์ของตัวเองต่อไป เพราะมันไม่ใช่สำเนา มันคือข้อเท็จจริงที่แช่แข็งไว้แล้ว

หน้าตาที่ได้จะประมาณนี้

create table public.orders (
  id text primary key,
  owner_id uuid not null references auth.users (id) on delete cascade,
  status text not null check (status in ('pending', 'paid', 'shipped', 'cancelled')),
  total_satang bigint not null check (total_satang >= 0),
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now(),
  extra jsonb not null default '{}'::jsonb
);

create index orders_owner_created_idx on public.orders (owner_id, created_at desc);

แล้วคำสั่งแปลงจาก staging ก็เป็น SQL ธรรมดา

insert into public.orders (id, owner_id, status, total_satang, created_at)
select
  doc->>'id',
  m.supabase_uid,
  coalesce(doc->>'status', 'pending'),
  round((doc->>'total')::numeric * 100)::bigint,
  to_timestamp((doc->'createdAt'->>'_seconds')::bigint)
from staging.orders_raw r
join public.uid_map m on m.firebase_uid = r.doc->>'ownerId'
on conflict (id) do nothing;

ค่า _seconds มาจากวิธีที่ Admin SDK แปลง Timestamp ของ Firestore เป็น JSON ถ้าคุณมาทางเส้น BigQuery ชื่อฟิลด์จะไม่ใช่อันนี้ ให้เปิดดูด้วย jsonb_pretty ที่แสดงไว้ข้างบนก่อนเขียนคำสั่ง อย่าคัดลอกไปวางแล้วหวังว่าตรง ส่วนตาราง uid_map คือสิ่งที่หัวข้อถัดไปอธิบาย และต้องมีอยู่ก่อนคำสั่งนี้จะรันผ่าน

security rules กับ RLS ต่างกันอย่างไร และเขียนใหม่อย่างไร

กฎของ Firestore ทำงานที่ฝั่งหน้าบ้านของ Google ก่อนที่ query จะแตะข้อมูล โดยอ่าน request.auth จาก ID token ที่แอปส่งมา ส่วน RLS ทำงานข้างใน Postgres ระดับ "แถว" ตอนประมวลผล query โดยอ่าน claim จาก JWT ผ่านฟังก์ชัน auth.uid() ของ Supabase หน่วยที่คุณเขียนกฎจึงเปลี่ยนจาก path ของเอกสาร ไปเป็นแถวในตาราง และเงื่อนไขของคุณกลายเป็นส่วนหนึ่งของ query plan

กฎเดิมหน้าตาแบบนี้

match /orders/{orderId} {
  allow read: if request.auth.uid == resource.data.ownerId;
  allow create: if request.auth.uid == request.resource.data.ownerId
                && request.resource.data.status == 'pending';
}

แปลงเป็นนโยบายของ Postgres ได้แบบนี้

alter table public.orders enable row level security;

create policy "owner reads own orders"
on public.orders for select
to authenticated
using (owner_id = (select auth.uid()));

create policy "owner creates own pending orders"
on public.orders for insert
to authenticated
with check (owner_id = (select auth.uid()) and status = 'pending');

create policy "owner updates own orders"
on public.orders for update
to authenticated
using (owner_id = (select auth.uid()))
with check (owner_id = (select auth.uid()));

การจับคู่จำง่าย resource.data คือแถวที่มีอยู่แล้ว ตรงกับ using ส่วน request.resource.data คือค่าที่กำลังจะเขียน ตรงกับ with check และคำสั่ง update ต้องมีทั้งสองอัน ถ้าใส่แค่ using ผู้ใช้จะอ่านและแก้ได้แถวของตัวเอง แล้วเปลี่ยน owner_id เป็นของคนอื่นได้ในคำสั่งเดียว เพราะไม่มีกฎไหนตรวจค่าหลังแก้ นี่คือช่องที่เจอบ่อยที่สุดตอนย้ายกฎมา

ความต่างที่อันตรายที่สุดอยู่ที่ค่าตั้งต้น Firestore ในโหมด locked ปฏิเสธทุกอย่างจนกว่าคุณจะเขียนกฎ ส่วน Postgres ไม่ปฏิเสธอะไรเลยจนกว่าคุณจะสั่ง alter table ... enable row level security ตารางที่คุณลืมสั่ง จะอ่านและเขียนได้ผ่าน anon key ที่ฝังอยู่ในแอปมือถือของคุณ ตรวจให้ครบด้วย query นี้ทุกครั้งก่อนขึ้นโปรดักชัน

select relname
from pg_class
where relnamespace = 'public'::regnamespace
  and relkind = 'r'
  and relrowsecurity = false;

ผลลัพธ์ต้องเป็นศูนย์แถว ถ้ามีชื่อตารางโผล่มา ตารางนั้นเปิดโล่งอยู่ตอนนี้

กฎที่เรียก get() หรือ exists() เพื่อเช็คสิทธิ์จากเอกสารอื่น เช่นเช็คว่าเป็นสมาชิกทีม แปลงเป็นฟังก์ชันแทน

create or replace function public.is_team_member(p_team uuid)
returns boolean
language sql
stable
security definer
set search_path = public
as $$
  select exists (
    select 1 from public.team_members
    where team_id = p_team and user_id = auth.uid()
  );
$$;

ที่ต้องเป็น security definer มีเหตุผลที่ทดลองได้ ถ้าเขียนเงื่อนไขนี้ตรง ๆ ในนโยบายของ team_members โดยอ้างตัวเอง Postgres จะตอบกลับมาว่า infinite recursion detected in policy for relation "team_members" เพราะการอ่านตารางนั้นทำให้นโยบายของตารางนั้นถูกประเมินอีกรอบ ฟังก์ชันแบบ definer รันด้วยสิทธิ์ของเจ้าของฟังก์ชัน จึงข้ามนโยบายและตัดวงจรนั้นทิ้ง

เรื่องความเร็วก็เปลี่ยนไปด้วย กฎของ Firestore ไม่มีต้นทุนที่คุณเห็นใน query plan แต่ RLS มี นโยบายที่เทียบ owner_id = auth.uid() บนตารางที่ไม่มี index ที่ owner_id ทำให้ทุกคำสั่ง select กลายเป็น sequential scan ดูได้เองด้วย explain analyze บน query ที่แอปยิงจริง อีกข้อคือให้ห่อเป็น (select auth.uid()) อย่างในตัวอย่าง ตัววงเล็บนี้ทำให้ planner ถือว่าเป็นค่าคงที่และเรียกครั้งเดียว ไม่ใช่เรียกซ้ำทุกแถว

สุดท้าย เงื่อนไขตรวจค่าที่เคยอยู่ในกฎ เช่น status ต้องเป็นค่าใดค่าหนึ่ง ให้ย้ายไปเป็น check constraint และ trigger ด้วย ไม่ใช่อยู่แค่ในนโยบาย เพราะ constraint บังคับใช้กับทุกการเขียน รวมถึงตอนคุณต่อ psql เข้าไปแก้ข้อมูลด้วยมือ ซึ่งเป็นช่องทางที่ RLS ของ role authenticated ไม่ได้ครอบ

ผู้ใช้และรหัสผ่าน: hash ย้ายตามไปไม่ได้

ไฟล์ users.json ที่ export มามี uid, อีเมล, provider ที่ผูกไว้ และสำหรับผู้ใช้ที่ล็อกอินด้วยรหัสผ่านจะมี passwordHash กับ salt มาด้วย พารามิเตอร์ที่ใช้ตรวจ hash เหล่านั้น เช่น algorithm, base64_signer_key, base64_salt_separator, rounds และ mem_cost ดูได้ในคอนโซล Firebase ที่หน้า Authentication แล้วกดเมนูจุดสามจุดเหนือตารางผู้ใช้ โปรเจกต์ส่วนใหญ่ใช้ scrypt เวอร์ชันที่ Firebase แก้เอง

ปัญหาคือ Supabase Auth เก็บรหัสผ่านเป็น bcrypt ในคอลัมน์ auth.users.encrypted_password และไม่มีที่ให้ใส่ signer key กับ salt separator ของ Firebase ตรวจของจริงได้ด้วยการดูว่า hash ในตารางของคุณขึ้นต้นด้วย $2a$ หรือไม่ ผลคือ hash จาก Firebase ใส่ลงไปแล้วจะตรวจไม่ผ่าน ผู้ใช้ล็อกอินไม่ได้ทั้งระบบ มีสองทางที่ตรงไปตรงมา

ทางแรกคือนำเข้าผู้ใช้โดยไม่เอารหัสผ่าน แล้วบังคับตั้งรหัสใหม่ทุกคน ทางนี้สะอาดที่สุดและต้องมี SMTP ที่ส่งออกได้จริงบน VPS ก่อนวันตัดสวิตช์ ถ้าเมลรีเซ็ตไม่ถึงกล่องขาเข้า คุณจะได้ผู้ใช้ที่เข้าระบบไม่ได้และไม่มีทางแก้ด้วยตัวเอง ทดสอบส่งเมลจริงไปยัง Gmail และ Hotmail ก่อน เพราะสองเจ้านี้ตีตกเมลจาก IP ใหม่บ่อยที่สุด

ทางที่สองคือทำ shim ไว้ช่วงเปลี่ยนผ่าน ตอนผู้ใช้ล็อกอินครั้งแรกบนระบบใหม่ ให้ backend ส่งอีเมลกับรหัสผ่านไปตรวจกับ Identity Toolkit REST ของ Firebase ถ้าผ่าน ก็ตั้งรหัสผ่านนั้นใน Supabase แล้วจบ ผู้ใช้ไม่รู้สึกอะไรเลย ข้อแลกเปลี่ยนคือคุณยังพึ่ง Firebase อยู่ และ backend เห็นรหัสผ่านแบบ plaintext ชั่วขณะ ให้กำหนดวันปิด shim ไว้ล่วงหน้า แล้วบังคับรีเซ็ตกับคนที่เหลือ

อีกเรื่องที่ต้องทำก่อนย้ายข้อมูลตารางอื่นคือเรื่องชนิดของ uid ของ Firebase เป็นสตริง ส่วน auth.users.id ของ Supabase เป็น uuid สองอย่างนี้แปลงกันตรง ๆ ไม่ได้ ให้สร้างตารางแผนที่ไว้ก่อน แล้วทุกคำสั่ง backfill ค่อย join ผ่านมัน

create table public.uid_map (
  firebase_uid text primary key,
  supabase_uid uuid not null unique references auth.users (id) on delete cascade,
  migrated_at timestamptz not null default now()
);

เก็บ uid_map ไว้ต่อหลังย้ายเสร็จ เพราะ path ของไฟล์ใน Storage เดิมมักมี uid ของ Firebase อยู่ในชื่อ และลิงก์เก่าที่ลูกค้าเซฟไว้จะยังยิงมาด้วย uid ชุดนั้นอีกหลายเดือน

Cloud Functions, FCM และของที่ไม่มีของแทน

โค้ดใน Cloud Functions ไม่ได้ย้าย มันถูกเขียนใหม่ แบ่งตามชนิดของ trigger จะเห็นทางออกชัดขึ้น

ฟังก์ชันแบบ HTTPS callable กลายเป็น endpoint ธรรมดา รันเป็นคอนเทนเนอร์หลัง reverse proxy หรือใช้ Edge Functions ที่มาในชุด self-hosted ของ Supabase ซึ่งเขียนด้วย Deno ฟังก์ชันแบบตั้งเวลากลายเป็น pg_cron ในฐานข้อมูล หรือ systemd timer บนเครื่อง ส่วนฟังก์ชันที่ยิงเมื่อมี document ใหม่ ให้ใช้ trigger ของ Postgres เขียนงานลงตารางคิว แล้วมี worker อ่านคิวไปทำ เหตุผลที่ไม่ควรทำงานหนักใน trigger เองคือ trigger รันอยู่ในทรานแซกชันเดียวกับคำสั่ง insert ดังนั้นการเรียก HTTP ข้างในนั้นทำให้คำสั่งของผู้ใช้ค้างรอ และถ้าปลายทางล่ม คำสั่ง insert ก็ล้มไปด้วย

สำหรับ push notification คำตอบที่ตรงไปตรงมาคือไม่มีของแทนบน VPS การส่งข้อความเข้าเครื่อง iOS ต้องผ่าน APNs (Apple push notification service) และ Android ต้องผ่าน FCM เพราะสองช่องนี้เป็นช่องเดียวที่ระบบปฏิบัติการรับฟังอยู่ ข่าวดีคือ FCM เป็นบริการแยกจาก Firestore คุณจึงย้ายข้อมูลออกแล้วใช้ FCM ส่ง push ต่อได้ โดย backend ตัวใหม่บน VPS เป็นฝ่ายเรียก API ส่วนบนเว็บ คุณส่ง web push ด้วย VAPID จากเซิร์ฟเวอร์ตัวเองได้เต็มที่

ของอื่นที่ต้องหาที่ลง Hosting กลายเป็น nginx หรือ Caddy เสิร์ฟไฟล์ static บนเครื่องเดียวกัน Remote Config กลายเป็น endpoint ที่คืน JSON ก้อนเดียวและ cache ได้ Crashlytics มักไปลงที่ Sentry ส่วน Analytics ไปที่ตัวเก็บสถิติแบบ self-hosted ที่คุณเลือก สำหรับ App Check ยอมรับตรง ๆ ว่าไม่มีของแทน สิ่งที่ทำได้คือ rate limit ที่ reverse proxy และตรวจสิทธิ์ที่ฝั่ง server ให้แน่น

อีกสองอย่างที่ต้องพูดถึงเพราะแอปมือถือพึ่งมันอยู่ การฟังข้อมูลแบบเรียลไทม์มีของแทนคือ Realtime ของ Supabase ซึ่งอ่านจาก logical replication ของ Postgres แต่ offline persistence ที่ Firestore ให้มาฟรี คือการที่แอปอ่านเขียนต่อได้ตอนเน็ตหลุดแล้วค่อย sync ทีหลัง ไม่มีของแทนแบบเสียบแทนได้ ถ้าแอปของคุณพึ่งความสามารถนี้จริง ให้ประเมินงานส่วนนี้แยกตั้งแต่ต้น เพราะมันคืองานเขียนระบบ sync ไม่ใช่งานย้ายข้อมูล

ค่าใช้จ่ายเปลี่ยนรูปอย่างไรเมื่อทีมอยู่ในไทย

ณ กันยายน 2026 แผน Blaze ยังคิดเงินตามการใช้งานและออกบิลเป็นสกุล USD หน่วยที่ถูกนับคือจำนวนครั้งที่อ่านเอกสาร จำนวนครั้งที่เขียน พื้นที่เก็บ ข้อมูลขาออก และจำนวนครั้งที่ฟังก์ชันถูกเรียก บิลจึงขึ้นลงตามทราฟฟิกโดยธรรมชาติ ตัวเลขจริงดูได้ในคอนโซลของคุณเท่านั้น

สำหรับคนจ่ายด้วยบัตรไทย มีตัวแปรตัวที่สองซ้อนเข้ามา ธนาคารผู้ออกบัตรเป็นฝ่ายแปลง USD เป็นบาทและคิดค่าดำเนินการเรื่องอัตราแลกเปลี่ยนเพิ่มจากยอดบิล ผลคือบิลที่เป็น USD เท่ากันสองเดือนติด กลายเป็นยอดบาทที่ไม่เท่ากัน การประมาณค่าใช้จ่ายล่วงหน้าจึงยากขึ้นเป็นสองเท่า เพราะคุณเดาทั้งการใช้งานและอัตราแลกเปลี่ยน ดูใบแจ้งยอดบัตรของคุณเองเทียบกับใบเสร็จของ Google ย้อนหลังสามเดือน แล้วคุณจะเห็นช่องว่างนั้นชัด

ตัวการย้ายเองก็มีค่าใช้จ่าย เอกสารของ Google ระบุว่า export คิดค่าอ่านหนึ่งครั้งต่อหนึ่งเอกสารที่ถูก export และค่าอ่านชุดนี้ไม่ไปโผล่ในหน้าสรุปการใช้งานของคอนโซล ฐานข้อมูลที่มีเอกสารหลายล้านชิ้นจึงมีบิลก้อนหนึ่งรออยู่ บวกกับค่าข้อมูลขาออกตอนคุณดึงไฟล์จาก bucket ลงเครื่อง วิธีลดคือ export ลง bucket ที่อยู่ region เดียวกับฐานข้อมูล แล้วดาวน์โหลดรอบเดียว อย่าลองผิดลองถูกด้วยการ export ซ้ำหลายรอบกับข้อมูลทั้งชุด ให้ซ้อมด้วย --collection-ids ที่ชี้ไป collection เล็กที่สุดก่อน

ฝั่ง VPS รูปของค่าใช้จ่ายเปลี่ยนไปคนละแบบ มันเป็นบรรทัดเดียวต่อเดือนที่รู้ยอดล่วงหน้า และหลายเจ้ารับชำระเป็นบาทได้ ยอดนั้นไม่ขยับตอนแอปของคุณถูกแชร์แล้วคนเข้าพร้อมกัน สิ่งที่มาแทนความผันผวนคือเวลาคนของคุณ ได้แก่การสำรองข้อมูลและซ้อมกู้คืน การอัปเกรด การต่ออายุใบรับรอง TLS และการเฝ้าดูว่าเครื่องยังไหว งานพวกนี้ไม่ได้อยู่ในใบแจ้งหนี้ แต่อยู่ในสัปดาห์ของทีมคุณจริง ถ้ายังลังเลว่าจะดูแล Postgres เองหรือไม่ อ่าน ข้อแลกเปลี่ยนระหว่าง Postgres แบบ managed กับแบบดูแลเองบน VPS และถ้ากำลังเทียบกับการอยู่บน Supabase cloud ต่อ ลองดู ว่าแผนฟรีของ Supabase คุ้มกว่าการรันเองตรงไหน

เรื่องความเร็วก็เป็นเหตุผลย้ายที่จับต้องได้สำหรับผู้ใช้ในไทย เพราะคุณเลือก region ของ VPS ได้เอง ในขณะที่ฐานข้อมูลเดิมอาจถูกสร้างไว้ที่ region ที่ค่าเริ่มต้นเลือกให้ตอนกดสร้างโปรเจกต์เมื่อสามปีก่อน วัดเองด้วย curl -w จากเครือข่ายที่ลูกค้าของคุณใช้จริง เทียบก่อนกับหลัง อย่าเชื่อตัวเลขที่ใครโพสต์ไว้ เพราะเส้นทางเน็ตของแต่ละผู้ให้บริการในไทยไม่เหมือนกัน

ย้ายโปรเจกต์ไปอีกบัญชี Google กับการออกจาก Firebase ไปเลย

คำถามที่คนค้นบ่อยที่สุดคือย้ายโปรเจกต์ Firebase ไปอีกบัญชีหรือเปลี่ยนบัญชีที่จ่ายเงินได้ไหม คำตอบคือได้ และมันไม่ใช่การ export อะไรเลย โปรเจกต์ Firebase คือโปรเจกต์ Google Cloud ดังนั้นการเปลี่ยนเจ้าของคือการแก้ IAM เพิ่มบัญชีใหม่เข้าไปด้วยบทบาท Owner ระดับโปรเจกต์ แล้วบัญชีนั้นจะเห็นโปรเจกต์ในคอนโซล Firebase ของตัวเองทันที

การเปลี่ยนบัญชีที่รับผิดชอบค่าใช้จ่ายเป็นอีกเรื่องหนึ่ง คุณต้องมีสิทธิ์ระดับผู้ดูแลบน billing account ปลายทาง พร้อมสิทธิ์ผูกบัญชีนั้นกับโปรเจกต์ ข่าวดีคือ project ID และ project number ไม่เปลี่ยน ดังนั้น API key, ชื่อ service และค่าที่ hardcode ไว้ในแอปยังใช้ได้ต่อ ไม่ต้องปล่อยแอปเวอร์ชันใหม่

ถ้าปลายทางคือย้ายโปรเจกต์เข้าไปอยู่ใต้ organization ของบริษัท ให้ดูจุดยืนปัจจุบันก่อน

gcloud projects get-ancestors <project-id>

งานนี้ต้องมีสิทธิ์ผู้ดูแล organization และบทบาท roles/resourcemanager.projectMover และมีของที่ไม่ตามไปด้วย บทบาทที่โปรเจกต์เคยได้รับสืบทอดมาจาก organization เดิมจะหายไป นโยบายขององค์กรปลายทางมาแทนของเดิมทั้งชุด บทบาทแบบ custom ต้องสร้างใหม่ที่ปลายทาง และโควตาระดับ organization ไม่ตามมา เหลือแต่โควตาระดับโปรเจกต์ อ่านรายการนี้ให้ครบก่อนกด เพราะผลกระทบโผล่หลังย้ายเสร็จ ไม่ใช่ตอนย้าย

พูดกันตรง ๆ อีกข้อ ถ้าเป้าหมายของคุณคือให้คนละคนหรือคนละบริษัทเป็นคนจ่ายบิล การแก้ IAM กับการเปลี่ยน billing account ใช้แรงน้อยกว่าการย้ายไป VPS มาก แต่ถ้าเป้าหมายคือให้บิลหยุดเป็นยอดที่ผันผวนตามการใช้งานในสกุล USD การเปลี่ยนบัญชีไม่ช่วยอะไรเลย มีแต่การย้ายข้อมูลออกที่ทำให้เกิดขึ้นได้ บางทีการออกไปเลยง่ายกว่าการโอนย้ายภายใน

ลำดับการตัดสวิตช์ที่ไม่ทำให้แอปล่มยาว

อย่าย้ายแบบปิดแอปหนึ่งคืนแล้วหวังว่าเช้ามาทุกอย่างเรียบร้อย ลำดับข้างล่างทำให้คุณถอยกลับได้ทุกขั้น

  1. สร้าง schema และนโยบาย RLS บน VPS ให้เสร็จก่อน แล้วรัน query ตรวจว่าทุกตารางเปิด RLS แล้ว
  2. นำเข้าผู้ใช้และสร้าง uid_map ให้ครบ ก่อนแตะข้อมูลตารางอื่น
  3. backfill จากไฟล์ export ลง staging แล้วแปลงด้วย SQL
  4. เปิด dual write ในแอป คือเขียนลงทั้ง Firestore และ Postgres โดยยังอ่านจาก Firestore
  5. รันรอบเก็บตกด้วย updatedAt เพราะงาน export ใช้เวลาเป็นนาทีถึงชั่วโมง เอกสารที่ถูกเขียนระหว่างนั้นอาจไม่ติดมาในไฟล์
  6. เทียบจำนวนแถวและสุ่มตรวจข้อมูลจริงสักร้อยแถว
  7. สลับการอ่านมาที่ Postgres ด้วย feature flag ที่ปิดกลับได้ในนาทีเดียว โดยยังเขียนลงทั้งสองที่อีกหนึ่งสัปดาห์
  8. หยุดเขียน Firestore แล้วตั้งกฎเป็น allow read, write: if false; เพื่อให้แน่ใจว่าไม่มีไคลเอนต์เก่าแอบเขียนอยู่

ขั้นที่ห้าคือขั้นที่คนข้ามบ่อยและเป็นต้นเหตุของข้อมูลหาย ถ้า collection ของคุณยังไม่มีฟิลด์ updatedAt ให้เพิ่มก่อนเริ่ม export เพราะไม่มีทางอื่นที่จะรู้ว่าเอกสารไหนเปลี่ยนไปหลังจากนั้น

ตรวจผลด้วยคำสั่งที่อ่านง่าย นับแถวเทียบกับจำนวนที่ Firestore รายงาน แล้วมองหาค่าที่ไม่ควรว่าง

select count(*) as total,
       count(*) filter (where owner_id is null) as orphan,
       min(created_at) as oldest,
       max(created_at) as newest
from public.orders;

แถว orphan ที่มากกว่าศูนย์แปลว่ามี order ที่หาเจ้าของใน uid_map ไม่เจอ สาเหตุที่พบบ่อยคือผู้ใช้ถูกลบออกจาก Auth ไปแล้วแต่เอกสารยังอยู่ ตัดสินใจให้ชัดว่าจะทิ้งหรือจะเก็บไว้ใต้บัญชีระบบ อย่าปล่อยให้ค้าง เพราะ constraint จะบล็อกงานขั้นถัดไป

อีกข้อที่ต้องทดสอบเองคือสิทธิ์ ใช้ anon key ยิง REST API ด้วย JWT ของผู้ใช้ A แล้วขอข้อมูลของผู้ใช้ B ผลที่ถูกต้องคือได้อาร์เรย์ว่าง ไม่ใช่ error เพราะ RLS กรองแถวออก ไม่ได้ปฏิเสธคำขอ ถ้าคุณได้ข้อมูลของ B กลับมา แปลว่ามีนโยบายไหนกว้างเกินไป หรือคุณเผลอยิงด้วย service role key ซึ่งข้าม RLS ทั้งหมดโดยการออกแบบ

หลังย้ายเสร็จ งานดูแลที่เพิ่มขึ้นมา

ตอนนี้ข้อมูลของลูกค้าอยู่บนเครื่องที่คุณรับผิดชอบคนเดียว ตั้ง pg_dump ให้รันอัตโนมัติและส่งไฟล์ออกไปเก็บนอกเครื่อง แล้วซ้อมกู้คืนลงเครื่องเปล่าอย่างน้อยหนึ่งครั้ง ไฟล์สำรองที่ยังไม่เคยกู้คืนสำเร็จ ยังไม่นับว่าเป็นไฟล์สำรอง ให้ตรวจ healthcheck ของคอนเทนเนอร์ฐานข้อมูลด้วย เพราะบริการอื่นในชุดจะสตาร์ตก่อนที่ Postgres พร้อมรับการเชื่อมต่อ แล้วล้มด้วย connection refused ตอนบูตเครื่อง วิธีตั้งค่าให้รอถูกจุดอยู่ใน การตั้ง healthcheck ให้ Postgres ใน docker compose

สุดท้ายคือเรื่องการเปิดให้เข้าถึงจากอินเทอร์เน็ต ชุด self-hosted ของ Supabase เปิดหลายพอร์ตบนเครื่อง และหน้า dashboard ของมันไม่ควรอยู่บน IP สาธารณะโดยไม่มีอะไรกั้น ถ้าไม่อยากเปิดพอร์ตออกไปตรง ๆ เลย การต่อออกด้วย Cloudflare Tunnel โดยไม่เปิดพอร์ตบนไฟร์วอลล์ เป็นทางที่เซ็ตครั้งเดียวแล้วจบ และอย่าลืมเปลี่ยนค่าใน .env ให้หมดก่อนเปิดเครื่องรับทราฟฟิกจริง เอกสารของ Supabase เขียนไว้ชัดว่าห้ามสตาร์ตด้วยค่าเริ่มต้น ค่าที่ต้องเปลี่ยนได้แก่ POSTGRES_PASSWORD, DASHBOARD_PASSWORD, SITE_URL, API_EXTERNAL_URL และ SUPABASE_PUBLIC_URL พร้อมกับสร้างคีย์ใหม่ด้วยสคริปต์ใน utils/ ของ repo ที่คุณ clone มา

FAQ

ย้ายโปรเจกต์ Firebase ไปอีกบัญชี Google ทำอย่างไร

โปรเจกต์ Firebase คือโปรเจกต์ Google Cloud ดังนั้นการเปลี่ยนเจ้าของคือการแก้ IAM ไม่ใช่การ export เพิ่มบัญชีใหม่ด้วยบทบาท Owner ระดับโปรเจกต์ แล้วบัญชีนั้นจะเห็นโปรเจกต์ในคอนโซล Firebase ของตัวเอง ส่วนการเปลี่ยนบัญชีที่จ่ายเงินต้องมีสิทธิ์ผู้ดูแลบน billing account ปลายทาง project ID และ project number ไม่เปลี่ยนตลอดกระบวนการ ดังนั้น API key และค่าที่ฝังในแอปยังใช้ได้ ถ้าจะย้ายเข้า organization ให้ดูจุดยืนปัจจุบันด้วย gcloud projects get-ancestors <project-id> ก่อน และเตรียมสิทธิ์ roles/resourcemanager.projectMover ไว้

ไฟล์ export ของ Firestore เอาไปใส่ Postgres ตรง ๆ ได้ไหม

ไม่ได้ เพราะ gcloud firestore export เขียนออกมาเป็นรูปแบบเฉพาะของ Firestore ที่ใช้ร่วมกับ Datastore ไม่ใช่ JSON ทางแรกคือโหลดเข้า BigQuery ด้วย bq load --source_format=DATASTORE_BACKUP โดย path ต้องลงท้ายด้วย .export_metadata และ export รอบนั้นต้องสั่งมาพร้อม --collection-ids แล้วค่อย extract ออกมาเป็น newline delimited JSON ทางที่สองคือเขียนสคริปต์ด้วย Admin SDK อ่าน collection แล้วเขียนไฟล์ JSON เอง ไม่ว่าทางไหน ให้โหลดเข้า staging table ที่มีคอลัมน์ jsonb ก่อน แล้วแปลงด้วย SQL

รหัสผ่านผู้ใช้จาก Firebase Auth ย้ายไป Supabase ได้ไหม

ย้าย hash ตรง ๆ ไม่ได้ Firebase ใช้ scrypt เวอร์ชันที่แก้เองพร้อมพารามิเตอร์เฉพาะโปรเจกต์ ส่วน Supabase Auth เก็บ bcrypt ในคอลัมน์ auth.users.encrypted_password และไม่มีที่ให้ใส่พารามิเตอร์ชุดนั้น ทางเลือกแรกคือนำเข้าผู้ใช้โดยไม่เอารหัสผ่านแล้วบังคับตั้งใหม่ ซึ่งต้องมี SMTP ที่ส่งเมลถึงกล่องขาเข้าได้จริงก่อนวันตัดสวิตช์ ทางเลือกที่สองคือตรวจรหัสผ่านกับ Firebase ที่การล็อกอินครั้งแรก แล้วบันทึกรหัสนั้นลงระบบใหม่ และตั้งวันปิดกลไกนี้ไว้ล่วงหน้า

ย้ายออกจาก Firestore แล้วยังส่ง push notification ได้ไหม

ได้ และคุณควรใช้ FCM ต่อไป เพราะการส่งข้อความเข้าเครื่อง Android ต้องผ่าน FCM และ iOS ต้องผ่าน APNs ซึ่งเป็นช่องทางเดียวที่ระบบปฏิบัติการรับฟัง ไม่มีบริการ self-hosted ตัวไหนแทนสองช่องนี้ได้ FCM แยกจาก Firestore อยู่แล้ว ดังนั้นหลังย้ายข้อมูลลง VPS ให้ backend ตัวใหม่เป็นฝ่ายเรียก FCM ส่วนบนเว็บคุณส่ง web push ด้วย VAPID จากเซิร์ฟเวอร์ตัวเองได้เต็มที่

ต้องแก้ security rules ใหม่ทั้งหมดหรือแปลงอัตโนมัติได้

ต้องเขียนใหม่ เพราะกฎของ Firestore ทำงานที่ฝั่งหน้าบ้านของ Google ระดับ path เอกสาร ส่วน RLS ทำงานข้างใน Postgres ระดับแถวและเป็นส่วนหนึ่งของ query plan การจับคู่ทำได้ตรงพอสมควร resource.data ไปเป็น using และ request.resource.data ไปเป็น with check โดยคำสั่ง update ต้องมีทั้งสองอัน จุดที่ต้องระวังที่สุดคือ Postgres ไม่ปฏิเสธอะไรจนกว่าคุณจะสั่ง alter table ... enable row level security ตารางที่ลืมสั่งจะอ่านและเขียนได้ผ่าน anon key ที่ฝังอยู่ในแอป