في مقال ما هو Backend as a Service؟ ذكرنا أن لكل خدمة بناء في Firebase بديلاً مفتوح المصدر، وأن التطبيق في هذا النموذج يكلم قاعدة البيانات مباشرة فتصبح قواعد الوصول هي الحارس الوحيد. وفي هذا الدليل سوف ننتقل من الفكرة إلى سيرفر يعمل، فنثبت Supabase بنسخته المستضافة ذاتياً Self-hosted بإصدارات مثبتة، ثم نبني عليه تطبيق ملاحظات صغيراً حقيقياً فيه تسجيل دخول لمستخدمين وجدول محمي بسياسات الصفوف Row-Level Security ومجلد ملفات خاص بكل مستخدم وتحديث فوري ودالة على السيرفر، ونرى بالمخرجات ماذا يحدث عندما يحاول مستخدم أن يقرأ بيانات غيره.
وSupabase منصة مبنية فوق PostgreSQL، فقاعدة البيانات عنده قاعدة علائقية كاملة وليست قاعدة مستندات كما في Firestore، والمصادقة والتخزين والتحديث الفوري كلها خدمات صغيرة تحفظ حالتها في القاعدة نفسها. وسوف نناقش في هذا الدليل ما يلي:
- ما الذي يعمل داخل Supabase المستضاف: أحد عشر Container خلف بوابة واحدة، وترخيص كل واحد منها.
- التثبيت من الإصدار الموسوم
self-hosted/v0.8.2، وتوليد المفاتيح، وتقييد المنافذ على 127.0.0.1، وأول خطأ ستراه عند التشغيل. - المفاتيح: أيها يوضع في التطبيق وأيها لا يخرج من السيرفر أبداً.
- قاعدة البيانات وسياسات RLS، وما يحدث لجدول نسيت أن تحميه.
- التخزين بمجلد لكل مستخدم، والتحديث الفوري Realtime، والدوال Edge Functions.
- تطبيق صغير كامل بمكتبة supabase-js ومخرجاته الحقيقية.
- ما الذي ينقص النسخة المستضافة مقارنة بالنسخة السحابية، بصراحة.
- ما تحتاجه قبل الإنتاج، ثم حذف المختبر.
ما الذي يعمل داخل Supabase المستضاف؟
قد يبدو Supabase من الخارج منتجاً واحداً، لكنه في الواقع مجموعة من المشاريع المستقلة، أغلبها مكتوب بلغات مختلفة، تجمعها بوابة API Gateway تستقبل كل الطلبات على منفذ واحد ثم توجه كل مسار إلى الخدمة المسؤولة عنه. والصورة التالية تبين ما شغلناه في المختبر:

والخدمات كما في ملف docker-compose.yml للإصدار الذي شغلناه، مع ترخيص كل مشروع كما يظهر في مستودعه:
| الخدمة | ما تفعله | Docker Image | الترخيص |
|---|---|---|---|
| api-gw | البوابة: تتحقق من وجود المفتاح وتوجه المسارات، وتحمي لوحة الإدارة بكلمة مرور | envoyproxy/envoy:v1.39.1 | Apache-2.0 |
| auth | المصادقة: التسجيل والدخول وإصدار JWT، واسمها القديم GoTrue | supabase/gotrue:v2.196.0 | MIT |
| rest | PostgREST: يحول جداول قاعدة البيانات إلى واجهة REST | postgrest/postgrest:v14.17 | MIT |
| realtime | يرسل تغييرات الجداول إلى المشتركين عبر WebSocket | supabase/realtime:v2.134.10 | Apache-2.0 |
| storage | رفع الملفات وتنزيلها، وصلاحياتها في PostgreSQL | supabase/storage-api:v1.74.0 | Apache-2.0 |
| imgproxy | تصغير الصور وتحويلها عند الطلب | darthsim/imgproxy:v3.31.4 | Apache-2.0 |
| functions | يشغل الدوال المكتوبة ب TypeScript على Deno | supabase/edge-runtime:v1.76.2 | MIT |
| studio وmeta | لوحة الإدارة، وواجهة تقرأ بنية قاعدة البيانات للوحة | supabase/studio:2026.09.07-sha-7996410 وsupabase/postgres-meta:v0.99.0 | Apache-2.0 وApache-2.0 |
| db | PostgreSQL 17 مع الإضافات التي تحتاجها بقية الخدمات | supabase/postgres:17.6.1.136 | PostgreSQL |
| supavisor | مجمع الاتصالات Connection Pooler أمام قاعدة البيانات | supabase/supavisor:2.9.12 | Apache-2.0 |
ولاحظ أن خدمتي السجلات Logflare وVector لم تعودا جزءاً من التشغيل الافتراضي، وإنما ملف إضافي اسمه docker-compose.logs.yml تفعله إذا أردت، والسبب كما يذكر دليل التثبيت الرسمي هو تقليل استهلاك الذاكرة. ومعنى المجمع Pooler وسبب وجوده شرحناه في تجميع الاتصالات Connection Pooling.
ما الذي تحتاجه قبل أن تبدأ؟
- سيرفر Linux مع Docker Engine وDocker Compose: كما في تثبيت Docker على Ubuntu. وقد شغلنا المختبر على Docker Engine 29.8.0.
- الذاكرة: قسنا الاستهلاك بعد التشغيل ودون أي حمل فكان قرابة 1.2 جيجابايت للخدمات الإحدى عشرة مجتمعة، فسيرفر بذاكرة 4 جيجابايت هو أقل ما نقترحه لمشروع صغير، والقاعدة نفسها ستكبر مع بياناتك.
- القرص: حجم ال Images بعد التنزيل قرابة 9 جيجابايت، وأكبرها Studio وقاعدة البيانات بقرابة 1.7 جيجابايت لكل منهما، ثم بياناتك وملفاتك المرفوعة.
- نطاق وReverse Proxy مع شهادة HTTPS: فالبوابة تعمل ب HTTP فقط، وسوف نجعلها تستمع على 127.0.0.1 ونضع أمامها Caddy أو Nginx، كما شرحنا في تثبيت Caddy وتطبيقك خلف Reverse Proxy.
- سيرفر بريد SMTP: لرسائل تأكيد التسجيل واستعادة كلمة المرور، فالإعداد الافتراضي يشير إلى سيرفر بريد وهمي، كما سيأتي.
التثبيت من إصدار موسوم
الحل البسيط هو أن تنسخ المستودع من الفرع الرئيسي وتشغله، وهذا غير مناسب للإنتاج، والسبب أن الفرع الرئيسي يتغير كل يوم، فقد تنسخه اليوم بإصدار وغداً بإصدار آخر دون أن تعرف ما الذي تغير. والطريقة الصحيحة هي أن تنسخ إصداراً موسوماً Tag من إصدارات النسخة المستضافة، وكل إصدار منها يثبت أرقام ال Images كلها، وآخرها عند كتابة هذا الدليل هو self-hosted/v0.8.2 بتاريخ 23 سبتمبر 2026، وسجل الأرقام في ملف versions.md. والخطوات كما في الدليل الرسمي:
git clone --depth 1 --branch self-hosted/v0.8.2 https://github.com/supabase/supabase
mkdir supabase-project
cp -rf supabase/docker/. supabase-project
cd supabase-project
cp .env.example .env
printf 'ref=self-hosted/v0.8.2\n' > .supabase-versionولاحظ ملف .supabase-version، فهو يسجل الإصدار الذي بدأت منه، ويستخدمه سكربت update.sh لاحقاً ليعرف ما الذي تغير بين إصدارك والإصدار الجديد ويدمج التغييرات مع تعديلاتك، كما في دليل التحديث.
توليد الأسرار والمفاتيح
ملف .env.example يأتي بكلمات مرور ومفاتيح تجريبية معروفة لكل من قرأ المستودع، وأول ما تفعله هو استبدالها، وفي المستودع سكربتان لذلك، الأول يولد الأسرار وكلمات المرور، والثاني يولد المفاتيح الجديدة وزوج المفاتيح غير المتماثلة Asymmetric Keys الذي توقع به التوكنات:
sh utils/generate-keys.sh --update-env
sh utils/add-new-auth-keys.sh --update-envوالمخرج سوف يكون كما يلي، وقد حذفنا القيم نفسها:
JWT_SECRET=<redacted>
ANON_KEY=<redacted>
SERVICE_ROLE_KEY=<redacted>
SECRET_KEY_BASE=<redacted>
REALTIME_DB_ENC_KEY=<redacted>
VAULT_ENC_KEY=<redacted>
PG_META_CRYPTO_KEY=<redacted>
LOGFLARE_PUBLIC_ACCESS_TOKEN=<redacted>
LOGFLARE_PRIVATE_ACCESS_TOKEN=<redacted>
S3_PROTOCOL_ACCESS_KEY_ID=<redacted>
S3_PROTOCOL_ACCESS_KEY_SECRET=<redacted>
MINIO_ROOT_PASSWORD=<redacted>
POSTGRES_PASSWORD=<redacted>
DASHBOARD_PASSWORD=<redacted>
Updating .env...والسكربت الثاني يحتاج Node.js 16 أو أحدث على السيرفر، وإذا لم يجده شغل Container مؤقتاً من node:22-alpine، ولاحظ أنه لا يعدل ملف .env فقط، وإنما يعدل docker-compose.yml أيضاً، فيفعل الأسطر التي تمرر المفاتيح العامة JWKS إلى خدمات المصادقة والتحديث الفوري والتخزين والدوال، وهذه أول تعديلاتك على ملف ال Compose، فانتبه لها عند التحديث.
الإعدادات التي غيرناها
بعد ذلك نعدل العناوين في .env، ففي المختبر تعمل البوابة على المنفذ 28000 والتطبيق على 28080، وفي الإنتاج تضع هنا نطاقك ب HTTPS:
SUPABASE_PUBLIC_URL=http://127.0.0.1:28000
API_EXTERNAL_URL=http://127.0.0.1:28000/auth/v1
SITE_URL=http://127.0.0.1:28080
API_GW_HTTP_PORT=28000
ENABLE_EMAIL_AUTOCONFIRM=true
POOLER_TENANT_ID=lab-baasفي الإعداد أعلاه لاحظ التالي:
SUPABASE_PUBLIC_URLهو العنوان الذي يصل منه التطبيق إلى البوابة، وSITE_URLهو عنوان تطبيقك الذي تعيد إليه روابط التأكيد واستعادة كلمة المرور.ENABLE_EMAIL_AUTOCONFIRM=trueيجعل الحساب مؤكداً دون رسالة بريد، وفعلناه في المختبر فقط لأننا لم نربط سيرفر بريد، أما في الإنتاج فاتركهfalseواضبط إعداداتSMTP_HOSTوأخواتها، والقيمة الافتراضيةSMTP_HOST=supabase-mailسيرفر وهمي للتطوير. وإذا لم يكن عندك سيرفر بريد فقد شرحنا البريد التفاعلي Transactional Email والإرسال عبر Amazon SES.- إذا كان التطبيق داخلياً لا يجوز لأي شخص أن يسجل فيه، فاجعل
DISABLE_SIGNUP=trueوأنشئ الحسابات من لوحة الإدارة.
أما في docker-compose.yml فغيرنا منفذ البوابة ومنافذ المجمع لتستمع على 127.0.0.1 فقط، والسبب أن Docker يفتح المنفذ المنشور على كل الواجهات ويتجاوز جدار الحماية UFW، كما شرحنا في تأمين خادم VPS، والوصول من الخارج يجب أن يكون عبر ال Reverse Proxy ب HTTPS فقط:
api-gw:
ports:
- 127.0.0.1:${API_GW_HTTP_PORT}:8000/tcp
supavisor:
ports:
- 127.0.0.1:25432:5432
- 127.0.0.1:26543:6543ويجدر الإشارة هنا إلى أننا غيرنا أيضاً أسماء ال Containers لتبدأ ب lab-baas- حتى نحذفها بالاسم بعد التجربة، وجعلنا بيانات القاعدة والتخزين في Volumes مسماة بدل مجلدات داخل المشروع، وهذان تعديلان خاصان بمختبرنا ولا تحتاجهما على سيرفرك.
التشغيل، وأول خطأ ستراه
الآن نقوم بتنزيل ال Images ثم التشغيل:
docker compose pull
docker compose up -dوعند التشغيل الأول سوف تجد أن الأمر قد ينتهي بخطأ كهذا:
Container lab-baas-db Error dependency db failed to start
dependency failed to start: container lab-baas-db is unhealthyوقد يبدو من الوهلة الأولى أن قاعدة البيانات معطلة، لكن السجل يقول غير ذلك، فقاعدة البيانات في التشغيل الأول تنفذ سكربتات التهيئة كلها، أي إنشاء المخططات Schemas والأدوار Roles والإضافات، وهذا أخذ عندنا وقتاً أطول من مهلة فحص الصحة Healthcheck، فاعتبرها Docker غير سليمة وأوقف الخدمات التي تعتمد عليها:
docker logs lab-baas-db 2>&1 | tail -4waiting for server to shut down.... 2026-10-07 15:16:07.615 UTC [84] LOG: pg_cron scheduler shutting down
.................. done
server stopped
PostgreSQL init process complete; ready for start up.والحل أن تنتظر حتى تكتمل التهيئة ثم تعيد الأمر نفسه، فيكمل تشغيل الخدمات المتبقية، ولا تقم بحذف ال Volume والبدء من جديد، لأن التهيئة الثانية ستأخذ الوقت نفسه. وبعد دقيقة تقريباً:
docker compose up -d
docker compose ps --format '{{.Name}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'والمخرج سوف يكون كما يلي:
lab-baas-auth supabase/gotrue:v2.196.0 Up About a minute (healthy)
lab-baas-db supabase/postgres:17.6.1.136 Up 2 minutes (healthy) 5432/tcp
lab-baas-edge-functions supabase/edge-runtime:v1.76.2 Up 2 minutes (healthy)
lab-baas-envoy envoyproxy/envoy:v1.39.1 Up 2 minutes (healthy) 127.0.0.1:28000->8000/tcp
lab-baas-imgproxy darthsim/imgproxy:v3.31.4 Up 3 minutes (healthy) 8080/tcp
lab-baas-meta supabase/postgres-meta:v0.99.0 Up 57 seconds (healthy) 8080/tcp
lab-baas-pooler supabase/supavisor:2.9.12 Up About a minute (healthy) 127.0.0.1:25432->5432/tcp, 127.0.0.1:26543->6543/tcp
lab-baas-realtime supabase/realtime:v2.134.10 Up About a minute (healthy)
lab-baas-rest postgrest/postgrest:v14.17 Up About a minute (healthy) 3000/tcp
lab-baas-storage supabase/storage-api:v1.74.0 Up 54 seconds (healthy) 5000/tcp
lab-baas-studio supabase/studio:2026.09.07-sha-7996410 Up 3 minutes (healthy) 3000/tcpواستهلاك الذاكرة بعد التشغيل ودون أي حمل:
docker stats --no-stream --format '{{.Name}}\t{{.MemUsage}}' | grep lab-baaslab-baas-auth 13.07MiB / 31.29GiB
lab-baas-storage 224.7MiB / 31.29GiB
lab-baas-edge-functions 28.92MiB / 31.29GiB
lab-baas-meta 120.9MiB / 31.29GiB
lab-baas-realtime 231MiB / 31.29GiB
lab-baas-pooler 190.8MiB / 31.29GiB
lab-baas-rest 33.15MiB / 31.29GiB
lab-baas-envoy 22.26MiB / 31.29GiB
lab-baas-db 114.4MiB / 31.29GiB
lab-baas-studio 223.9MiB / 31.29GiB
lab-baas-imgproxy 19.91MiB / 31.29GiBثم نتأكد أن البوابة تعمل وأن خدمة المصادقة ترد، وأن الطلب دون مفتاح يرفض من البوابة قبل أن يصل إلى أي خدمة:
curl -s http://127.0.0.1:28000/auth/v1/health -H "apikey: $SUPABASE_PUBLISHABLE_KEY"
curl -s -i http://127.0.0.1:28000/rest/v1/notes | head -1{"version":"v2.196.0","name":"GoTrue","description":"GoTrue is a user registration and authentication API"}
HTTP/1.1 401 Unauthorizedالمفاتيح: ما الذي يوضع في التطبيق، وما الذي لا يخرج من السيرفر؟
بعد تشغيل السكربتين أصبح في ملف .env نوعان من المفاتيح، القديمة وهي توكنات JWT موقعة بسر مشترك، والجديدة وهي مفاتيح نصية تبدأ ببادئة تدل على نوعها، كما يشرح توثيق مفاتيح API:
grep -E '^(ANON_KEY|SUPABASE_PUBLISHABLE_KEY|SUPABASE_SECRET_KEY|JWT_KEYS)=' .env | sed -E 's/=(.{14}).*/=\1…/'ANON_KEY=eyJhbGciOiJIUz…
SUPABASE_PUBLISHABLE_KEY=sb_publishable…
SUPABASE_SECRET_KEY=sb_secret_qEMK…
JWT_KEYS=[{"kty":"EC","…
| المفتاح | مكانه | ما يستطيعه |
|---|---|---|
SUPABASE_PUBLISHABLE_KEY والقديم ANON_KEY | كود التطبيق والمتصفح | يعرف المشروع فقط، والصلاحيات تحددها سياسات RLS وتوكن المستخدم |
SUPABASE_SECRET_KEY والقديم SERVICE_ROLE_KEY | السيرفر والدوال فقط | يتجاوز سياسات RLS كلها |
JWT_KEYS وJWT_SECRET | ملف .env فقط | توقيع توكنات المستخدمين، ومن يملكه يصنع توكن أي مستخدم |
POSTGRES_PASSWORD وDASHBOARD_PASSWORD | ملف .env ومدير كلمات المرور | قاعدة البيانات ولوحة الإدارة كاملة |
لوحة الإدارة Studio
لوحة الإدارة على العنوان نفسه، أي http://127.0.0.1:28000/، وتحميها البوابة بكلمة مرور من نوع HTTP Basic Auth، فالطلب دونها يرد ب 401، واسم المستخدم وكلمة المرور هما DASHBOARD_USERNAME وDASHBOARD_PASSWORD. والصورة التالية من لوحتنا بعد إنشاء الجدول وسياساته في القسم التالي:

ولاحظ أن قائمة المصادقة في النسخة المستضافة فيها المستخدمون والسياسات فقط، كما في الصورة التالية، أما مزودو الدخول ورسائل البريد وعناوين إعادة التوجيه، والتي تضبطها من اللوحة في النسخة السحابية، فتضبطها هنا من ملف .env ثم تعيد تشغيل خدمة المصادقة:

قاعدة البيانات وسياسات الصفوف RLS
تطبيقنا تطبيق ملاحظات، ولكل ملاحظة مالك، ولا يرى أي مستخدم إلا ملاحظاته. وقد شرحنا سياسات الصفوف بالتفصيل في سياسات الصفوف Row-Level Security خطوة بخطوة، فلن نعيد شرحها هنا، والجديد في Supabase أن الدالة auth.uid() تقرأ معرف المستخدم من التوكن الذي أرسله التطبيق، وأن الدور authenticated هو الدور الذي يعمل به كل طلب يحمل توكن مستخدم مسجل. والملف التالي ينشئ الجدول والسياسات ويضيف الجدول إلى التحديث الفوري وينشئ مجلد التخزين وسياساته:
-- notes: every row belongs to the user who created it
create table public.notes (
id bigint generated always as identity primary key,
owner_id uuid not null default auth.uid() references auth.users (id) on delete cascade,
title text not null check (char_length(title) between 1 and 200),
body text not null default '',
created_at timestamptz not null default now()
);
create index notes_owner_idx on public.notes (owner_id);
alter table public.notes enable row level security;
create policy "read own notes" on public.notes for select to authenticated
using ((select auth.uid()) = owner_id);
create policy "insert own notes" on public.notes for insert to authenticated
with check ((select auth.uid()) = owner_id);
create policy "update own notes" on public.notes for update to authenticated
using ((select auth.uid()) = owner_id) with check ((select auth.uid()) = owner_id);
create policy "delete own notes" on public.notes for delete to authenticated
using ((select auth.uid()) = owner_id);
-- realtime: send changes of this table to subscribed clients
alter publication supabase_realtime add table public.notes;
-- storage: a private bucket, each user writes and reads only inside a folder named with his id
insert into storage.buckets (id, name, public) values ('attachments', 'attachments', false);
create policy "upload to own folder" on storage.objects for insert to authenticated
with check (bucket_id = 'attachments' and (storage.foldername(name))[1] = (select auth.uid())::text);
create policy "read own folder" on storage.objects for select to authenticated
using (bucket_id = 'attachments' and (storage.foldername(name))[1] = (select auth.uid())::text);docker exec -i lab-baas-db psql -U postgres -v ON_ERROR_STOP=1 < schema.sqlCREATE TABLE
CREATE INDEX
ALTER TABLE
CREATE POLICY
CREATE POLICY
CREATE POLICY
CREATE POLICY
ALTER PUBLICATION
INSERT 0 1
CREATE POLICY
CREATE POLICYفي الإعداد أعلاه لاحظ التالي:
- العمود
owner_idقيمته الافتراضيةauth.uid()، فالتطبيق لا يرسل المالك أصلاً، والقاعدة تأخذه من التوكن، وسياسة الإضافة تمنع من يحاول أن يرسل مالكاً غيره، كما سنرى في المخرجات. - كتبنا
(select auth.uid())داخلselectوليسauth.uid()وحدها، والسبب أن PostgreSQL ينفذها مرة واحدة للاستعلام بدل مرة لكل صف، وهذه من توصيات الأداء في توثيق Supabase، ومعها الفهرس علىowner_id. - السياسات مكتوبة للدور
authenticatedفقط، فالزائر غير المسجل، ودورهanon، لا توجد له سياسة، فلا يرى شيئاً. - سياسات التخزين تستخدم الدالة
storage.foldername()لتأخذ اسم المجلد الأول في مسار الملف، كما في دوال التخزين المساعدة، فالمستخدم يكتب ويقرأ داخل مجلد اسمه معرفه فقط.
والصورة التالية تبين ما يحدث لطلب سارة من التطبيق حتى يعود بصفوفها:

ماذا يحدث لجدول نسيت أن تحميه؟
الآن سوف نكسر الإعداد عن قصد، فننشئ جدول عملاء دون أن نفعل عليه RLS، ثم نطلبه بالمفتاح العام فقط، أي بالمفتاح الموجود في كود كل نسخة من تطبيقك، ودون أي تسجيل دخول:
docker exec lab-baas-db psql -U postgres -c "create table public.customers (id int primary key, name text, phone text); insert into public.customers values (1,'شركة الأفق','0500000001'),(2,'مؤسسة النخبة','0500000002');"
curl -s "http://127.0.0.1:28000/rest/v1/customers?select=*" -H "apikey: $SUPABASE_PUBLISHABLE_KEY"CREATE TABLE
INSERT 0 2
[{"id":1,"name":"شركة الأفق","phone":"0500000001"},
{"id":2,"name":"مؤسسة النخبة","phone":"0500000002"}]وعند تشغيل الأمر سوف تجد أن الجدول كله عاد لزائر مجهول، والسبب أن الجداول الجديدة في المخطط public تصبح جزءاً من الواجهة البرمجية تلقائياً، وغياب RLS يعني غياب أي تصفية. والآن نفعل RLS دون أن نكتب أي سياسة:
docker exec lab-baas-db psql -U postgres -c "alter table public.customers enable row level security;"
curl -s "http://127.0.0.1:28000/rest/v1/customers?select=*" -H "apikey: $SUPABASE_PUBLISHABLE_KEY"ALTER TABLE
[]فالجدول المفعل عليه RLS دون سياسات مغلق للجميع إلا المفتاح السري، ثم تفتح منه ما تحتاجه بسياسة. ولهذا تعرض لوحة Studio في صفحة السياسات اقتراحاً بإنشاء Trigger يفعل RLS تلقائياً على كل جدول جديد، كما ظهر في الصورة السابقة، وهو اقتراح يستحق أن تقبله. ولاحظ أيضاً أن الطلب الأول بعد إنشاء الجدول مباشرة قد يرد بأن الجدول غير موجود في ذاكرة PostgREST:
{"code":"PGRST205","details":null,"hint":null,"message":"Could not find the table 'public.customers' in the schema cache"}وهذا ليس خطأ في الصلاحيات، وإنما PostgREST لم يحدث نسخته من بنية القاعدة بعد، وثوان قليلة تكفي.
تطبيق صغير كامل: المصادقة والبيانات والتخزين والتحديث الفوري والدوال
قبل التطبيق نحتاج الدالة التي سيستدعيها، وفي النسخة المستضافة لا يوجد أمر نشر للدوال كما في النسخة السحابية، وإنما تضع مجلد الدالة داخل volumes/functions، وخدمة الدوال تحمله عند أول طلب. والدالة التالية تحسب عدد ملاحظات المستخدم وكلماتها، وتعمل بتوكن المستخدم نفسه وليس بالمفتاح السري، فتبقى سياسات RLS مطبقة عليها:
// note-stats: counts the caller's notes and words, with the caller's own token, so RLS still applies
import { createClient } from "npm:@supabase/[email protected]"
Deno.serve(async (req) => {
const auth = req.headers.get("Authorization") ?? ""
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_ANON_KEY")!,
{ global: { headers: { Authorization: auth } } },
)
const { data: { user }, error: userError } = await supabase.auth.getUser()
if (userError || !user) return Response.json({ error: "not signed in" }, { status: 401 })
const { data, error } = await supabase.from("notes").select("body")
if (error) return Response.json({ error: error.message }, { status: 500 })
const words = data.reduce((n, r) => n + r.body.split(/\s+/).filter(Boolean).length, 0)
return Response.json({ user: user.email, notes: data.length, words })
})ويجدر الإشارة هنا إلى أن التحقق من التوكن قبل الوصول إلى الدالة يضبطه في النسخة المستضافة متغير واحد هو FUNCTIONS_VERIFY_JWT لكل الدوال معاً، وفي ملف ال Compose نفسه تعليق يقول إن ضبطه لكل دالة على حدة ما زال مهمة لم تنفذ، وقيمته الافتراضية false، ولذلك تتحقق دالتنا من المستخدم بنفسها عبر auth.getUser() وترد ب 401 إذا لم تجده.
والآن التطبيق نفسه، وهو سكربت Node.js يمثل ما يفعله تطبيق الويب أو الجوال، ويستخدم المفتاح العام فقط، ويمر على كل الخدمات بالترتيب:
// A small notes app against our self-hosted Supabase: auth, RLS, storage, realtime and an edge function
import { createClient } from '@supabase/supabase-js'
const URL = 'http://127.0.0.1:28000'
const KEY = process.env.SUPABASE_PUBLISHABLE_KEY // the public key, safe to ship in a web or mobile app
const client = () => createClient(URL, KEY, { auth: { persistSession: false } })
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
// 1) two users sign up
const sara = client(), omar = client()
const s = await sara.auth.signUp({ email: '[email protected]', password: 'Lab-Pass-2026!' })
const o = await omar.auth.signUp({ email: '[email protected]', password: 'Lab-Pass-2026!' })
const saraId = s.data.user.id, omarId = o.data.user.id
console.log('signup sara:', saraId, ' omar:', omarId)
// 2) realtime: omar listens to every change on notes
const events = []
const channel = omar.channel('notes-feed')
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'notes' }, (p) => events.push(p.new.title))
// wait until Realtime confirms it is reading changes from PostgreSQL, not only that the socket joined
const ready = new Promise(r => channel.on('system', {}, (m) => m.extension === 'postgres_changes' && m.status === 'ok' && r(m.message)))
channel.subscribe()
console.log('realtime omar:', await ready)
// 3) each user writes notes; owner_id comes from auth.uid() in the database, not from the client
await sara.from('notes').insert([{ title: 'خطة الإطلاق', body: 'نراجع الخطة يوم الأحد مع الفريق' }, { title: 'موردون', body: 'قائمة الموردين المعتمدين' }])
await omar.from('notes').insert({ title: 'ملاحظة عمر', body: 'تجربة' })
await sleep(1500)
const sNotes = await sara.from('notes').select('id,title')
const oNotes = await omar.from('notes').select('id,title')
console.log('select sara sees:', sNotes.data.map(n => n.title))
console.log('select omar sees:', oNotes.data.map(n => n.title))
console.log('realtime omar received:', events)
// 4) omar tries to change sara's note, and an anonymous visitor tries to read
const target = sNotes.data[0].id
const upd = await omar.from('notes').update({ title: 'hacked' }).eq('id', target).select()
console.log('update omar on sara note', target, '->', upd.error?.message ?? `${upd.data.length} rows`)
const forged = await omar.from('notes').insert({ title: 'forged', owner_id: saraId })
console.log('insert omar as sara ->', forged.error?.message)
const anon = await client().from('notes').select('*')
console.log('select anonymous ->', anon.data)
// 5) storage: private bucket, a folder per user
const up1 = await sara.storage.from('attachments').upload(`${saraId}/plan.txt`, new Blob(['خطة الإطلاق']), { contentType: 'text/plain' })
console.log('upload sara -> own folder:', up1.error?.message ?? up1.data.path)
const up2 = await sara.storage.from('attachments').upload(`${omarId}/x.txt`, new Blob(['x']))
console.log('upload sara -> omar folder:', up2.error?.message)
const dl = await omar.storage.from('attachments').download(`${saraId}/plan.txt`)
console.log('download omar <- sara file:', dl.error ? `${dl.error.status} ${dl.error.message}` : 'OK (unexpected)')
const signed = await sara.storage.from('attachments').createSignedUrl(`${saraId}/plan.txt`, 60)
console.log('signed url for 60s:', signed.data.signedUrl.replace(/token=.*/, 'token=…'))
// 6) edge function, called with sara's session token
const fn = await sara.functions.invoke('note-stats')
console.log('function sara:', fn.data)
const raw = await fetch(`${URL}/functions/v1/note-stats`, { headers: { apikey: KEY } })
console.log('function without login:', raw.status, await raw.text())
await omar.removeChannel(channel)
process.exit(0)npm i @supabase/[email protected]
SUPABASE_PUBLISHABLE_KEY=$(grep '^SUPABASE_PUBLISHABLE_KEY=' .env | cut -d= -f2) node app.mjsوالمخرج سوف يكون كما يلي:
signup sara: 938ffcd5-c202-4931-9b2a-23d090715411 omar: a88f14d8-aed3-4e9d-acf5-6686ccf40b53
realtime omar: Subscribed to PostgreSQL
select sara sees: [ 'خطة الإطلاق', 'موردون' ]
select omar sees: [ 'ملاحظة عمر' ]
realtime omar received: [ 'ملاحظة عمر' ]
update omar on sara note 6 -> 0 rows
insert omar as sara -> new row violates row-level security policy for table "notes"
select anonymous -> []
upload sara -> own folder: 938ffcd5-c202-4931-9b2a-23d090715411/plan.txt
upload sara -> omar folder: new row violates row-level security policy
download omar <- sara file: 400 Object not found
signed url for 60s: http://127.0.0.1:28000/storage/v1/object/sign/attachments/938ffcd5-c202-4931-9b2a-23d090715411/plan.txt?token=…
function sara: { user: '[email protected]', notes: 2, words: 9 }
function without login: 401 {"error":"not signed in"}في الإعداد أعلاه لاحظ التالي:
- كل مستخدم رأى ملاحظاته فقط، مع أن الاستعلام نفسه
select('id,title')لا يحمل أي شرط، فالشرط في السياسة داخل القاعدة. - محاولة عمر تعديل ملاحظة سارة لم ترجع خطأ، وإنما رجعت
0 rows، والسبب أن سياسة التعديل تخفي الصف عنه أصلاً، فلا يجد ما يعدله، وهذا أفضل من رسالة خطأ تؤكد له أن الصف موجود. - محاولة عمر أن يضيف ملاحظة باسم سارة رفضتها سياسة الإضافة
with check. - عمر لم يستطع تنزيل ملف سارة، والرد كان
Object not foundوليس رفض صلاحية، للسبب نفسه: الملف غير موجود بالنسبة له. وإذا أرادت سارة أن تشارك الملف فالطريقة الصحيحة هي الرابط الموقع Signed URL لمدة محددة. - الدالة عملت بتوكن سارة فحسبت ملاحظتيها فقط، أي تسع كلمات، وبدون تسجيل دخول ردت ب 401.
التحديث الفوري Realtime، والخطأ الذي وقعنا فيه أول مرة
عمر اشترك في كل إضافة على جدول الملاحظات، ومع ذلك وصله حدث ملاحظته هو فقط، والسبب أن خدمة Realtime تتحقق لكل مشترك هل تسمح له سياسة القراءة برؤية هذا الصف قبل أن ترسله، كما يذكر توثيق Postgres Changes، والصورة التالية تبين ذلك:

لكن في التشغيل الأول للتطبيق كانت قائمة الأحداث فارغة تماماً، أي أن عمر لم يستلم حتى ملاحظته:
realtime omar subscribed
...
realtime omar received: []وكان الكود الأول ينتظر حالة SUBSCRIBED ثم يضيف الملاحظات مباشرة، وهذه الحالة تعني أن قناة WebSocket انضمت فقط، أما قراءة التغييرات من قاعدة البيانات فتبدأ بعد لحظات، وتعلن عنها الخدمة برسالة نظام System Message مستقلة، كما ظهر عندما طبعنا كل الرسائل في سكربت صغير:
STATUS SUBSCRIBED
SYSTEM {"message":"Subscribed to PostgreSQL","status":"ok","extension":"postgres_changes","channel":"t"}
EVENT INSERT rt testولذلك ينتظر التطبيق في نسخته النهائية رسالة Subscribed to PostgreSQL قبل أن يعتمد على الأحداث، وهذا مهم في الاختبارات الآلية خصوصاً، أما في تطبيق حقيقي فالمستخدم عادة لا يضيف شيئاً في أول ثانية، لكن لا تقم بالاعتماد على Realtime وحده لحالة لا تحتمل فقد حدث، والسبب أن الاتصال قد ينقطع، فاقرأ البيانات من الجدول عند فتح الشاشة وعند عودة الاتصال، واجعل Realtime لتحديث الشاشة بين ذلك.
حذف الملفات من قاعدة البيانات مباشرة
عندما أردنا تفريغ المختبر لإعادة التجربة حاولنا حذف صفوف الملفات من جدول storage.objects مباشرة، فرفضت القاعدة:
ERROR: Direct deletion from storage tables is not allowed. Use the Storage API instead.
HINT: This prevents accidental data loss from orphaned objects.
CONTEXT: PL/pgSQL function storage.protect_delete() line 5 at RAISEوالسبب أن الجدول يحفظ وصف الملف فقط، والملف نفسه في التخزين، فحذف الصف وحده يترك الملف دون سجل يدل عليه، ولذلك يحذف الملف عبر واجهة التخزين، بالمفتاح السري من السيرفر إذا كان الحذف إدارياً.
ما الذي ينقص النسخة المستضافة مقارنة بالسحابية؟
صفحة الميزات في Supabase فيها خيار لعرض ميزات النسخة المستضافة فقط، والبيانات نفسها في ملف features.tsx في المستودع، وعند الإصدار الذي ثبتناه كانت الميزات المعلمة بأنها غير متاحة للنسخة المستضافة اثنتي عشرة ميزة: النطاقات المخصصة Custom Domains، والفروع Branching لبيئات المعاينة، ومزود Terraform، ونسخ القراءة Read Replicas، وخطوط Supabase Pipelines، ومخازن التحليلات Analytics Buckets ومخازن المتجهات Vector Buckets، وتشغيل الدوال في مناطق متعددة Regional Invocations، وواجهة الإدارة Management API، وصلاحيات أعضاء الفريق RBAC، وشهادة SOC 2، والربط الخاص PrivateLink.
وبجانب هذه القائمة لاحظنا في المختبر ما يلي:
- مشروع واحد: اللوحة تعرض مشروعاً واحداً اسمه Default Project، فإذا أردت عدة مشاريع منفصلة فكل مشروع تثبيت مستقل بقاعدة ومنافذ خاصة به.
- الإعدادات من ملف
.env: مزودو الدخول وقوالب البريد وعناوين إعادة التوجيه لا تظهر في اللوحة، وتغييرها يحتاج تعديل الملف وإعادة تشغيل الخدمة. - النسخ الاحتياطي عليك: صفحة الميزات تعد النسخ الاحتياطي متاحاً للنسخة المستضافة لكن بأداة تجهزها أنت مثل wal-g، فلا توجد نسخ يومية تلقائية ولا استعادة لنقطة زمنية Point-in-Time Recovery إلا إذا بنيتها، وأقل ما تفعله هو
pg_dumpمجدول مع نسخة من ملفات التخزين و.env، ومراحل النسخ الاحتياطي في التشغيل والصيانة. - السجلات اختيارية: بدون ملف
docker-compose.logs.ymlلا توجد صفحة سجلات في اللوحة، وتقرأ السجلات بالأمرdocker logs. - الدوال: لا يوجد أمر نشر، والتحقق من التوكن إعداد واحد لكل الدوال كما ذكرنا.
- الدعم: النسخة المستضافة يدعمها المجتمع، كما يقول ملف README في مجلد docker، فلا يوجد عقد دعم من Supabase لها.
وكل هذا لا يغير أن الجزء الأهم، أي المصادقة وقاعدة البيانات والسياسات والتخزين والتحديث الفوري والدوال، يعمل كاملاً على سيرفرك بترخيص مفتوح، وأن مكتبة supabase-js نفسها تعمل مع النسختين دون تغيير في الكود.
قبل الإنتاج
- ضع البوابة خلف Reverse Proxy بشهادة HTTPS، واجعل
SUPABASE_PUBLIC_URLوAPI_EXTERNAL_URLوSITE_URLعناوين HTTPS الحقيقية. - لا تنشر منفذ المجمع 5432 على الإنترنت، واتصل بقاعدة البيانات من سيرفراتك عبر شبكة خاصة أو نفق، كما في WireGuard.
- اضبط سيرفر بريد حقيقياً، واجعل
ENABLE_EMAIL_AUTOCONFIRM=false. - فعل RLS على كل جدول في
public، واختبر كل جدول بطلب مجهول وبطلب من مستخدم آخر كما فعلنا. - جدول النسخ الاحتياطي وجرب الاستعادة على سيرفر آخر قبل أن تحتاجها.
- احفظ ملف
.envفي مدير أسرار أو مدير كلمات مرور، فضياعه يعني ضياع مفاتيح توقيع التوكنات. - حدث عبر
update.shمن إصدار موسوم إلى إصدار موسوم، بعد نسخة احتياطية، وراجع ملف CHANGELOG لأن بعض الإصدارات تغير ملفات التهيئة.
حذف المختبر
حذفنا ما أنشأناه فقط، بالاسم:
docker compose -p lab-baas down -vوهذا الأمر يحذف ال Containers والشبكة وال Volumes التابعة لمشروع lab-baas فقط، ولا تقم بتشغيل docker system prune أو docker volume prune على سيرفر عليه خدمات أخرى، والسبب أنها تحذف كل ما لا يعمل في تلك اللحظة، ولو كان لمشروع آخر.
الخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- Supabase المستضاف أحد عشر Container خلف بوابة Envoy واحدة، كلها مفتوحة المصدر بتراخيص Apache-2.0 وMIT وPostgreSQL، وتحفظ حالتها في PostgreSQL 17.
- ثبت من إصدار موسوم مثل
self-hosted/v0.8.2، وولد المفاتيح بالسكربتين، واجعل المنافذ على 127.0.0.1 خلف Reverse Proxy. - المفتاح العام مكانه التطبيق، والمفتاح السري ومفاتيح التوقيع لا تخرج من السيرفر.
- جدول دون RLS مفتوح لكل من يملك المفتاح العام، وجدول عليه RLS دون سياسات مغلق للجميع، والسياسات تطبق على القراءة والكتابة والتخزين والتحديث الفوري معاً.
- انتظر رسالة
Subscribed to PostgreSQLقبل الاعتماد على Realtime، واقرأ البيانات من الجدول عند عودة الاتصال. - النسخة المستضافة ينقصها الفروع ونسخ القراءة وواجهة الإدارة وتعدد المشاريع والنسخ الاحتياطي التلقائي، فخطط لها قبل أن تعتمد عليها.
دليلما هو Backend as a Service؟ وما بديل كل خدمة في Firebaseما الذي يقدمه ال Backend الجاهز لتطبيقك، ولماذا تصبح قواعد الوصول هي الحارس الوحيد لبياناتك، وما البديل المفتوح لكل خدمة في Firebase.
دليلتطبيق SaaS متعدد المستأجرين عملياً مع Row-Level Securityنبني على PostgreSQL تطبيق SaaS يعزل كل مستأجر Tenant عن غيره بسياسات Row-Level Security، ونرى أين تتسرب البيانات رغم ذلك.
دليلPostgreSQL لكل شيء: متى يكفيك ومتى تحتاج غيره؟كم عملاً يستطيع PostgreSQL واحد أن يقوم به في شركتك الناشئة قبل أن تحتاج Redis أو Elasticsearch، وما الإشارة التي تقول لك إن الوقت قد حان.
دليلConnection Pooling: لماذا الاتصال بقاعدة البيانات مكلفلماذا تظهر رسالة too many clients عندما تزيد نسخ تطبيقك، وكيف يحل مجمع الاتصالات وPgBouncer هذه المشكلة.سجل التحديثات
- أكتوبر 2026: كتابة الدليل، وتنفيذ المختبر على Supabase self-hosted v0.8.2 مع PostgreSQL 17.6 وsupabase-js 2.117.3 وNode.js 22.14 على Docker Engine 29.8.0.