chore: initial commit - Supabase stack with local Docker configuration

This commit is contained in:
qzl
2026-01-28 15:21:06 +08:00
commit 6af0989fe7
32 changed files with 2342 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
# Local environment files
infra/local/env/*.env
configs/env/*.env
infra/cloud/volcano/env/*.env
!infra/local/env/*.env.example
!configs/env/*.env.example
!infra/cloud/volcano/env/*.env.example
.env
.env.local
.env.*.local
.env.cloud
.env.*.cloud
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"chrome-devtools": {
"enabled": false
},
"zai-mcp-server": {
"enabled": false
}
}
}
+6
View File
@@ -0,0 +1,6 @@
## Docker Startup
Must use environment file when starting services:
```bash
docker compose --env-file infra/env/.env.local -f infra/local/docker-compose.yml up -d
```
+180
View File
@@ -0,0 +1,180 @@
# ============================================
# Social App Monorepo - 统一命令入口
# ============================================
#
# 使用方式:
# make <command>
#
# 常用命令:
# make up 启动本地开发依赖
# make down 停止并移除容器
# make logs 查看依赖服务日志
# make ps 查看运行状态
# make clean 清理本地数据卷(警告:会丢失数据)
# make env 提示如何配置环境变量
# make api-dev 启动 FastAPI 后端服务
# make flutter-dev 启动 Flutter 移动应用
# make milvus-init 初始化 Milvus 向量数据库
.PHONY: help up down logs ps clean env api-dev flutter-dev milvus-init
# 默认目标:显示帮助
help:
@echo "Social App Monorepo - 可用命令:"
@echo ""
@echo " 依赖服务管理:"
@echo " make up 启动本地开发依赖(Redis、Milvus、Postgres"
@echo " make down 停止并移除所有容器"
@echo " make logs 查看依赖服务日志(可用 SERVICE=redis 等指定)"
@echo " make ps 查看容器运行状态"
@echo " make clean 清理本地数据卷(警告:会丢失数据)"
@echo ""
@echo " 应用启动:"
@echo " make api-dev 启动 FastAPI 后端服务"
@echo " make flutter-dev 启动 Flutter 移动应用"
@echo ""
@echo " 配置与初始化:"
@echo " make env 显示如何配置环境变量"
@echo " make milvus-init 初始化 Milvus 向量数据库"
@echo ""
@echo " 示例:"
@echo " make logs SERVICE=redis"
@echo " make logs SERVICE=milvus"
# ============================================
# 依赖服务管理
# ============================================
# 启动本地开发依赖
up:
@echo "🚀 启动本地开发依赖..."
docker compose -f infra/local/docker-compose.yml --env-file infra/local/env/.env up -d
@echo ""
@echo "✅ 依赖服务已启动"
@echo ""
@echo "服务端口:"
@echo " - Redis: 6379"
@echo " - Milvus: 19530 (gRPC) / 19111 (HTTP)"
@echo " - Postgres: 54322"
@echo ""
@echo "检查状态:make ps"
@echo "查看日志:make logs"
# 停止并移除容器
down:
@echo "🛑 停止并移除容器..."
docker compose -f infra/local/docker-compose.yml --env-file infra/local/env/.env down
@echo "✅ 容器已停止并移除"
# 查看日志
logs:
ifndef SERVICE
@echo "📋 查看所有服务日志(Ctrl+C 退出)..."
docker compose -f infra/local/docker-compose.yml --env-file infra/local/env/.env logs -f
else
@echo "📋 查看 $(SERVICE) 服务日志(Ctrl+C 退出)..."
docker compose -f infra/local/docker-compose.yml --env-file infra/local/env/.env logs -f $(SERVICE)
endif
# 查看运行状态
ps:
@echo "📊 容器运行状态:"
docker compose -f infra/local/docker-compose.yml --env-file infra/local/env/.env ps
# 清理数据卷(警告:会丢失数据)
clean:
@echo "⚠️ 警告:此操作将删除所有本地数据(Redis、Milvus、Postgres"
@read -p "确认继续?[y/N] " confirm; \
if [ "$$confirm" = "y" ] || [ "$$confirm" = "Y" ]; then \
docker compose -f infra/local/docker-compose.yml --env-file infra/local/env/.env down -v; \
echo "✅ 数据卷已清理"; \
else \
echo "❌ 操作已取消"; \
fi
# ============================================
# 配置管理
# ============================================
# 显示环境变量配置说明
env:
@echo "📝 环境变量配置说明:"
@echo ""
@echo "1. 查看配置规范:"
@echo " cat configs/env/.env.example"
@echo ""
@echo "2. 本地开发配置:"
@echo " mkdir -p infra/local/env"
@echo " cp configs/env/.env.example configs/env/.env"
@echo " cp infra/local/env/.env.example infra/local/env/.env"
@echo " # 编辑 configs/env/.env 与 infra/local/env/.env"
@echo ""
@echo "3. 确保以下变量已正确配置:"
@echo " - DATABASE_URL (连接到 localhost:54322)"
@echo " - REDIS_URL (连接到 localhost:6379)"
@echo " - MILVUS_URI (连接到 localhost:19530)"
@echo ""
@echo "4. 严禁将包含真实密钥的配置文件提交到 Git"
# ============================================
# 应用启动
# ============================================
# 启动 FastAPI 后端服务
api-dev:
@echo "🔧 启动 FastAPI 后端服务..."
@echo ""
@echo "前置条件:"
@echo " 1. 确保依赖服务已启动:make up"
@echo " 2. 确保环境变量已配置:make env"
@echo ""
@echo "启动命令(示例):"
@echo " cd apps/api"
@echo " uvicorn src.main:app --host 0.0.0.0 --port 8000 --reload"
@echo ""
@echo "或使用 Python 虚拟环境:"
@echo " python -m venv .venv"
@echo " source .venv/bin/activate"
@echo " pip install -r requirements.txt"
@echo " uvicorn src.main:app --host 0.0.0.0 --port 8000 --reload"
# 启动 Flutter 移动应用
flutter-dev:
@echo "📱 启动 Flutter 移动应用..."
@echo ""
@echo "前置条件:"
@echo " 1. 确保后端服务已启动:make api-dev"
@echo " 2. 确保 Flutter SDK 已安装"
@echo ""
@echo "启动命令(示例):"
@echo " cd apps/mobile"
@echo " flutter run --dart-define=PUBLIC_API_BASE_URL=http://localhost:8000"
@echo ""
@echo "或使用调试模式:"
@echo " flutter run --dart-define=PUBLIC_API_BASE_URL=http://localhost:8000 --debug"
@echo ""
@echo "构建版本:"
@echo " flutter build apk --dart-define=PUBLIC_API_BASE_URL=http://localhost:8000"
# ============================================
# 初始化脚本
# ============================================
# 初始化 Milvus 向量数据库
milvus-init:
@echo "🔧 初始化 Milvus 向量数据库..."
@echo ""
@echo "前置条件:"
@echo " 1. 确保依赖服务已启动:make up"
@echo " 2. 确保 Milvus 服务已健康(检查:make ps"
@echo ""
@echo "初始化脚本:"
@echo " bash tools/scripts/init_milvus.sh"
@echo ""
@echo "如脚本不存在,请创建:"
@echo " mkdir -p tools/scripts"
@echo " cat > tools/scripts/init_milvus.sh << 'EOF'"
@echo "#!/bin/bash"
@echo "# Milvus 初始化脚本"
@echo "# TODO: 创建集合、索引等"
@echo "EOF"
+17
View File
@@ -0,0 +1,17 @@
# Social App Monorepo
Flutter + FastAPI + Supabase + Redis + Milvus
## 说明
本仓库仅初始化结构,不包含业务实现
## 目录结构
- `apps/` —— 可运行应用(Flutter / FastAPI / Worker
- `infra/` —— 基础设施(本地 docker / 云部署 / 迁移)
- `configs/` —— 配置规范与公共配置模板(不含密钥)
- `tools/` —— 脚本与生成器
- `docs/` —— 文档与规则
详见 `docs/rules/repo-structure.md`
+2
View File
@@ -0,0 +1,2 @@
# FastAPI 服务占位文件
# 后续添加依赖和配置
+2
View File
@@ -0,0 +1,2 @@
# Flutter 应用占位文件
# 后续添加依赖和配置
+2
View File
@@ -0,0 +1,2 @@
# 异步任务/队列服务占位文件
# 预留:后续可能添加
+1
View File
@@ -0,0 +1 @@
# 放后端开发项目需要的环境变量
+4
View File
@@ -0,0 +1,4 @@
{
"apiBaseUrl": "http://localhost:8000",
"environment": "development"
}
+4
View File
@@ -0,0 +1,4 @@
{
"apiBaseUrl": "https://api.yourdomain.com",
"environment": "production"
}
+2
View File
@@ -0,0 +1,2 @@
# OpenAPI 规范占位文件
# 后续通过 FastAPI 自动生成
+38
View File
@@ -0,0 +1,38 @@
# 技术栈选择
## 背景
本项目需要构建一个跨平台社交应用,支持本地开发和云端部署。
## 决策
1. **前端框架:Flutter**
- 跨平台支持(iOS / Android / Web
- 高性能原生渲染
- 丰富的 UI 组件
2. **后端框架:FastAPI**
- 高性能异步框架
- 自动生成 OpenAPI 文档
- 类型安全
3. **数据库:SupabasePostgreSQL**
- 开箱即用的 PostgreSQL
- 内置认证和权限管理
- 实时订阅功能
4. **缓存:Redis**
- 高性能键值存储
- 支持多种数据结构
5. **向量数据库:Milvus**
- 高性能向量检索
- 支持大规模向量存储
- 适合 RAG 和推荐场景
## 后续考虑
根据业务发展,可能需要评估:
- CDN 方案
- 消息队列
- 监控和日志系统
+17
View File
@@ -0,0 +1,17 @@
# 系统架构概述
## 技术栈
- **前端**Flutter
- **后端**FastAPI
- **数据库**SupabasePostgreSQL
- **缓存**Redis
- **向量数据库**Milvus
- **部署**Docker + 火山云(未来)
## 架构特点
- Monorepo 结构
- 微服务架构(API + Worker
- 云原生设计
- 支持本地 Docker 开发和云端部署
+203
View File
@@ -0,0 +1,203 @@
# 配置规则与使用原则
## 配置文件组织
### 1. 环境变量规范(真相源)
**文件位置**`configs/env/.env.example`
**作用**
- 定义应用层环境变量的名称、类型、用途和敏感级别
- 作为应用配置的"真相源"source of truth
- 不可直接用于运行,仅作为参考和规范
- Docker/Supabase 栈配置不在本文件,使用 `infra/local/env/.env.example`
- 本文件仅包含应用层连接配置(如 `SUPABASE_URL``DATABASE_URL`),不包含 Supabase 栈运行变量
- 不得在 `infra/local/env/.env.example` 中添加应用层变量(如 `PUBLIC_``API_`
**变量分类**
- `public`:可公开的信息,如服务地址、端口号
- `secret`:敏感信息,如密钥、密码、连接串
**变量块说明**
- A. 通用环境(APP_ENV、LOG_LEVEL、TZ
- B. Flutter 配置(仅 PUBLIC_ 变量)
- C. FastAPI 服务配置
- D. Supabase / Postgres 连接配置
- E. Redis 配置
- F. Milvus 配置
- G. 对象存储配置(可选)
- H. 其他配置
### 2. 本地开发配置
**文件位置**`configs/env/.env`
**作用**
- 本地应用层的实际配置文件
-`configs/env/.env.example` 复制后填入真实值
- **禁止提交到 Git 仓库**
**创建方式**
```bash
cp configs/env/.env.example configs/env/.env
# 编辑 configs/env/.env,填入实际值
```
### 3. 本地 Supabase 栈配置
**文件位置**`infra/local/env/.env`
**作用**
- 本地 Supabase + 周边依赖的实际配置文件
-`infra/local/env/.env.example` 复制后填入真实值
- **禁止提交到 Git 仓库**
**创建方式**
```bash
cp infra/local/env/.env.example infra/local/env/.env
```
### 4. 云端部署配置
**方式一:环境文件**
- 文件位置:`infra/cloud/volcano/env/.env`(不提交)
-`infra/cloud/volcano/env/.env.example` 复制后填入云端真实值
- 仅用于本地测试云端连接
**方式二:Secret 注入(推荐)**
- 使用火山云或其他云平台的 Secret 管理服务
- 通过 CI/CD 或容器运行时注入环境变量
- 代码无需修改,通过环境切换
### 5. 应用特定配置
**Flutter 配置**
- `configs/flutter/dev.json` —— 开发环境
- `configs/flutter/prod.json` —— 生产环境
- 通过 `--dart-define` 在构建时注入
## 安全原则
### Public vs Secret
**Public 变量**
- 可公开的服务地址和端口号
- 前端可直接使用的配置
- 示例:`PUBLIC_API_BASE_URL``API_PORT`
**Secret 变量**
- 密钥、密码、连接串
- 仅服务端使用的敏感信息
- 示例:`DATABASE_URL``REDIS_URL``JWT_SECRET`
### Flutter 配置限制
**严格规则**
- Flutter 只能使用以 `PUBLIC_` 开头的变量
- 严禁将任何 secret 信息注入到 Flutter
- 通过 `--dart-define` 在构建时注入,运行时不可修改
**示例**
```bash
# ✅ 正确:Flutter 使用 PUBLIC_ 变量
flutter run --dart-define=PUBLIC_API_BASE_URL=http://localhost:8000
# ❌ 错误:Flutter 不能使用 secret
flutter run --dart-define=DATABASE_URL=postgresql://user:pass@localhost/db
```
### 后端配置读取
**严格规则**
- `apps/api``apps/worker` 只能通过环境变量读取配置
- 不得直接读取 `infra/local/*.env` 文件路径
- 使用 Python 的 `os.getenv()` 或环境变量库
**示例**
```python
# ✅ 正确:通过环境变量读取
import os
database_url = os.getenv("DATABASE_URL")
# ❌ 错误:直接读取本地配置文件
from dotenv import load_dotenv
load_dotenv("configs/env/.env") # 禁止
```
## 使用方式
### 1. 本地开发
1. 复制环境变量模板:
```bash
make env
```
2. 启动依赖服务:
```bash
make up
```
3. 启动后端服务:
```bash
make api-dev
```
4. 启动 Flutter 应用:
```bash
make flutter-dev
```
### 2. 云端部署
1. 准备云端环境变量:
- 将 `.env.example` 中的变量映射到云平台 Secret
- 或创建 `infra/cloud/volcano/env/.env`(仅用于本地测试)
2. 修改配置指向云端:
```bash
# 示例:修改 .env 指向火山云托管地址
REDIS_URL=redis://volcano-redis:6379/0
MILVUS_URI=https://volcano-milvus:19530
DATABASE_URL=postgresql://user:pass@volcano-postgres/db
```
3. 部署应用:
- 通过 CI/CD 自动注入环境变量
- 或通过云平台控制台配置
## 配置优先级
从高到低:
1. 运行时环境变量(最高)
2. 环境文件(`.env`
3. 配置文件(`configs/flutter/*.json`
## 常见问题
### Q: 如何切换环境?
**本地开发**:使用 `configs/env/.env`
**云端部署**:使用云平台 Secret 注入
### Q: 如何确保 secret 不泄露?
- 所有 secret 变量使用 `CHANGE_ME` 占位符
- 本地配置文件(`configs/env/.env`、`infra/local/env/.env`)添加到 `.gitignore`
- 云端使用密钥管理服务注入
### Q: 如何测试云端配置?
1. 在本地创建 `infra/cloud/volcano/env/.env`(不提交)
2. 修改变量指向云端地址
3. 导出环境变量测试:
```bash
export $(cat infra/cloud/volcano/env/.env | xargs)
```
### Q: Compose 如何读取配置?
- 使用 `--env-file infra/local/env/.env` 注入本地 Supabase 栈变量
- Compose 文件为 `infra/local/docker-compose.yml`
- 应用代码通过 `os.getenv()` 读取本地应用变量
+21
View File
@@ -0,0 +1,21 @@
# 目录结构规则
## 顶层目录(必须遵守)
仓库根目录只能包含以下目录:
- `apps/` —— 可运行应用(Flutter / FastAPI / Worker
- `infra/` —— 基础设施(本地 docker / 云部署 / 迁移)
- `configs/` —— 配置规范与公共配置模板(不含密钥)
- `tools/` —— 脚本与生成器
- `docs/` —— 文档与规则
- `.github/`(可选,用于 CI/CD
- `README.md`
- `Makefile`(可选)
## 禁止事项
- 禁止在根目录直接出现:`backend/``ui/``docker/``scripts/` 等非规范目录
- 所有业务代码必须放在 `apps/` 目录下
- 所有配置文件必须放在 `configs/` 目录下
- 所有基础设施相关代码必须放在 `infra/` 目录下
+25
View File
@@ -0,0 +1,25 @@
# 火山云部署指南
## 准备工作
1. 火山云账号
2. 配置云端环境变量
3. 准备镜像仓库
## 环境变量模板
云端模板文件位置:
```bash
cp infra/cloud/volcano/env/.env.example infra/cloud/volcano/env/.env
```
## 部署流程
待补充详细步骤...
## 注意事项
- 确保所有敏感信息使用环境变量或密钥管理
- 遵循最小权限原则
- 配置适当的监控和日志
+272
View File
@@ -0,0 +1,272 @@
# 本地开发指南
## 前置要求
- Docker 和 Docker Compose
- Flutter SDK
- Python 3.11+
## 快速开始
### 1. 配置环境变量
```bash
make env
```
按照提示创建并编辑应用配置 `configs/env/.env`,并创建 Supabase 本地栈配置 `infra/local/env/.env`
创建配置文件:
```bash
cp configs/env/.env.example configs/env/.env
cp infra/local/env/.env.example infra/local/env/.env
```
确保以下变量配置正确:
- `DATABASE_URL`(连接到 localhost:54322
- `REDIS_URL`(连接到 localhost:6379
- `MILVUS_URI`(连接到 localhost:19530
### 2. 启动依赖服务
```bash
make up
```
这将启动以下服务:
- **Redis**:端口 6379
- **Milvus**:端口 19530 (gRPC) / 19111 (HTTP)
- **Postgres**:端口 54322
### 3. 检查服务状态
```bash
make ps
```
确保所有服务显示为 `Up` 状态。
### 4. 查看服务日志
```bash
# 查看所有服务日志
make logs
# 查看特定服务日志
make logs SERVICE=redis
make logs SERVICE=milvus
make logs SERVICE=db
```
## 启动应用
### 启动 FastAPI 后端
```bash
make api-dev
```
或手动启动:
```bash
cd apps/api
# 创建虚拟环境(首次)
python -m venv .venv
source .venv/bin/activate
# 安装依赖(首次)
pip install -r requirements.txt
# 启动服务
uvicorn src.main:app --host 0.0.0.0 --port 8000 --reload
```
后端启动后,访问:
- API 文档:http://localhost:8000/docs
- ReDochttp://localhost:8000/redoc
### 启动 Flutter 应用
```bash
make flutter-dev
```
或手动启动:
```bash
cd apps/mobile
# 安装依赖(首次)
flutter pub get
# 启动开发服务器
flutter run --dart-define=PUBLIC_API_BASE_URL=http://localhost:8000
# 或指定设备
flutter run -d chrome --dart-define=PUBLIC_API_BASE_URL=http://localhost:8000
```
构建 Android APK
```bash
flutter build apk --dart-define=PUBLIC_API_BASE_URL=http://localhost:8000
```
## 初始化 Milvus
```bash
make milvus-init
```
或手动运行初始化脚本:
```bash
bash tools/scripts/init_milvus.sh
```
## 常用命令
### 依赖服务管理
```bash
# 启动服务
make up
# 停止服务
make down
# 重启服务
make down && make up
# 查看状态
make ps
# 查看日志
make logs
# 清理数据(警告:会丢失数据)
make clean
```
### 应用管理
```bash
# 启动后端
make api-dev
# 启动前端
make flutter-dev
# 配置环境变量
make env
```
## 常见问题
### 端口冲突
如果启动依赖服务时出现端口冲突:
1. 检查端口占用:
```bash
# 检查 6379Redis
lsof -i :6379
# 检查 54322Postgres
lsof -i :54322
# 检查 19530Milvus
lsof -i :19530
```
2. 停止占用端口的进程,或修改 `infra/local/docker-compose.yml` 中的端口映射
### 容器未健康
如果服务状态显示 `Up (health: starting)` 但一直未变成 `Up (healthy)`
1. 查看服务日志:
```bash
make logs SERVICE=<service_name>
```
2. 检查依赖服务是否正常启动:
```bash
make ps
```
3. 重启服务:
```bash
docker compose -f infra/local/docker-compose.yml --env-file infra/local/env/.env restart <service_name>
```
### 后端无法连接数据库
1. 检查 Postgres 是否正常启动:
```bash
make ps
```
2. 检查环境变量配置:
```bash
cat configs/env/.env | grep DATABASE_URL
```
3. 确保数据库 URL 格式正确:
```
postgresql://postgres:postgres@localhost:54322/postgres
```
### Flutter 无法连接后端
1. 确保后端服务已启动并监听在 8000 端口:
```bash
curl http://localhost:8000/docs
```
2. 检查 Flutter 的 API_BASE_URL 是否正确注入:
```bash
flutter run --dart-define=PUBLIC_API_BASE_URL=http://localhost:8000
```
3. 如果使用模拟器,确保能访问 localhost:
- Android 模拟器:使用 `10.0.2.2` 代替 `localhost`
- iOS 模拟器:使用 `localhost` 即可
### Milvus 连接失败
1. 检查 Milvus 服务是否健康:
```bash
make ps
```
2. 等待 Milvus 完全启动(可能需要 1-2 分钟):
```bash
make logs SERVICE=milvus
```
3. 测试连接:
```bash
curl http://localhost:19530/healthz
```
## 清理环境
```bash
# 停止所有服务
make down
# 清理数据卷(警告:会丢失所有数据)
make clean
# 完全清理(包括未使用的镜像)
docker system prune -a
```
## 下一步
- 阅读架构文档:`docs/architecture/overview.md`
- 了解配置规则:`docs/rules/config-rules.md`
- 查看技术栈决策:`docs/adr/0001-tech-stack.md`
+89
View File
@@ -0,0 +1,89 @@
# Infra
Local Docker environment for Supabase stack and supporting services.
## Quick Start
1. Copy environment file:
```bash
cp infra/env/.env.example infra/env/.env.local
```
2. Update `infra/env/.env.local` with your secrets and configurations.
**Important**: Ensure all required fields are filled (no `CHANGE_ME` values).
3. Start services:
```bash
docker compose --env-file infra/env/.env.local -f infra/local/docker-compose.yml up -d
```
## Access
- **Supabase Studio**: http://localhost:8001
Credentials from `DASHBOARD_USERNAME` and `DASHBOARD_PASSWORD` in `.env.local`
- **Qdrant**: http://localhost:6333
- **Redis**: localhost:6379
## Troubleshooting
### Port Conflicts
If `localhost:8001` is unreachable, check for port conflicts:
**Linux/WSL:**
```bash
ss -ltnp 'sport = :8001'
```
**Windows:**
```powershell
Get-NetTCPConnection -LocalPort 8001 | Select-Object LocalAddress, LocalPort, State, OwningProcess
Get-Process -Id (Get-NetTCPConnection -LocalPort 8001).OwningProcess
```
If a Windows service occupies the port, either:
- Stop the conflicting service
- Change `KONG_HTTP_PORT`, `API_EXTERNAL_URL`, and `SUPABASE_PUBLIC_URL` in `.env.local`
### Environment Variables Not Applied
If Docker reports warnings about missing variables, verify:
- The `--env-file` path is correct
- All required variables are set in `.env.local` (no empty values)
### Service Health
Check service status:
```bash
docker compose --env-file infra/env/.env.local -f infra/local/docker-compose.yml ps
```
View logs:
```bash
docker compose --env-file infra/env/.env.local -f infra/local/docker-compose.yml logs <service-name>
```
## Services
| Service | Description |
|------------|---------------------------------------|
| kong | API gateway (8001/8443) |
| studio | Supabase UI (via Kong) |
| auth | Authentication (GoTrue) |
| db | PostgreSQL database |
| rest | PostgREST API |
| realtime | Realtime subscriptions |
| storage | Storage API |
| functions | Edge functions |
| analytics | Logflare logging |
| vector | Log aggregator |
| supavisor | Database connection pooler |
| qdrant | Vector database |
| redis | Cache/message broker |
## Important Notes
- Never commit `.env.local` to version control
- Always use `--env-file` when running docker compose
- Port conflicts on Windows (especially 8000) can prevent Kong from starting
- Kong configuration is auto-generated from templates on container start
+90
View File
@@ -0,0 +1,90 @@
# Local Docker (Supabase stack) environment example
# Copy to infra/env/.env.local and fill values
# Do not commit real secrets
############
# Secrets
############
POSTGRES_PASSWORD=CHANGE_ME
JWT_SECRET=CHANGE_ME
ANON_KEY=CHANGE_ME
SERVICE_ROLE_KEY=CHANGE_ME
DASHBOARD_USERNAME=supabase
DASHBOARD_PASSWORD=CHANGE_ME
SECRET_KEY_BASE=CHANGE_ME
VAULT_ENC_KEY=CHANGE_ME
PG_META_CRYPTO_KEY=CHANGE_ME
############
# Database
############
POSTGRES_HOST=db
POSTGRES_DB=postgres
POSTGRES_PORT=5432
############
# Supavisor -- Database pooler
############
POOLER_PROXY_PORT_TRANSACTION=6543
POOLER_DEFAULT_POOL_SIZE=20
POOLER_MAX_CLIENT_CONN=100
POOLER_TENANT_ID=local-tenant
POOLER_DB_POOL_SIZE=5
############
# API Proxy - Kong
############
KONG_HTTP_PORT=8001
KONG_HTTPS_PORT=8443
############
# API - PostgREST
############
PGRST_DB_SCHEMAS=public,storage,graphql_public
############
# Auth - GoTrue
############
SITE_URL=http://localhost:3000
ADDITIONAL_REDIRECT_URLS=
JWT_EXPIRY=3600
DISABLE_SIGNUP=false
API_EXTERNAL_URL=http://localhost:8001
MAILER_URLPATHS_CONFIRMATION="/auth/v1/verify"
MAILER_URLPATHS_INVITE="/auth/v1/verify"
MAILER_URLPATHS_RECOVERY="/auth/v1/verify"
MAILER_URLPATHS_EMAIL_CHANGE="/auth/v1/verify"
ENABLE_EMAIL_SIGNUP=true
ENABLE_EMAIL_AUTOCONFIRM=false
SMTP_ADMIN_EMAIL=admin@example.com
SMTP_HOST=supabase-mail
SMTP_PORT=2500
SMTP_USER=fake_mail_user
SMTP_PASS=fake_mail_password
SMTP_SENDER_NAME=fake_sender
ENABLE_ANONYMOUS_USERS=false
ENABLE_PHONE_SIGNUP=true
ENABLE_PHONE_AUTOCONFIRM=true
############
# Studio
############
STUDIO_DEFAULT_ORGANIZATION=Default Organization
STUDIO_DEFAULT_PROJECT=Default Project
SUPABASE_PUBLIC_URL=http://localhost:8000
IMGPROXY_ENABLE_WEBP_DETECTION=true
OPENAI_API_KEY=
############
# Functions
############
FUNCTIONS_VERIFY_JWT=false
############
# Logs / Analytics
############
LOGFLARE_PUBLIC_ACCESS_TOKEN=CHANGE_ME
LOGFLARE_PRIVATE_ACCESS_TOKEN=CHANGE_ME
DOCKER_SOCKET_LOCATION=/var/run/docker.sock
GOOGLE_PROJECT_ID=GOOGLE_PROJECT_ID
GOOGLE_PROJECT_NUMBER=GOOGLE_PROJECT_NUMBER
+457
View File
@@ -0,0 +1,457 @@
name: social-app-local
services:
redis:
image: redis:7-alpine
container_name: social-local-redis
restart: unless-stopped
ports:
- "6379:6379"
volumes:
- redis_data:/data
command: redis-server --appendonly yes
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
qdrant:
image: qdrant/qdrant:latest
container_name: social-local-qdrant
restart: unless-stopped
ports:
- "6333:6333"
- "6334:6334"
volumes:
- qdrant_data:/qdrant/storage
studio:
container_name: supabase-studio
image: supabase/studio:2026.01.27-sha-6aa59ff
restart: unless-stopped
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://studio:3000/api/platform/profile').then((r) => {if (r.status !== 200) throw new Error(r.status)})",
]
timeout: 10s
interval: 5s
retries: 3
depends_on:
analytics:
condition: service_healthy
environment:
HOSTNAME: "::"
STUDIO_PG_META_URL: http://meta:8080
POSTGRES_PORT: ${POSTGRES_PORT}
POSTGRES_HOST: ${POSTGRES_HOST}
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
PG_META_CRYPTO_KEY: ${PG_META_CRYPTO_KEY}
DEFAULT_ORGANIZATION_NAME: ${STUDIO_DEFAULT_ORGANIZATION}
DEFAULT_PROJECT_NAME: ${STUDIO_DEFAULT_PROJECT}
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
SUPABASE_URL: http://kong:8000
SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL}
SUPABASE_ANON_KEY: ${ANON_KEY}
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
AUTH_JWT_SECRET: ${JWT_SECRET}
LOGFLARE_API_KEY: ${LOGFLARE_PUBLIC_ACCESS_TOKEN}
LOGFLARE_PUBLIC_ACCESS_TOKEN: ${LOGFLARE_PUBLIC_ACCESS_TOKEN}
LOGFLARE_PRIVATE_ACCESS_TOKEN: ${LOGFLARE_PRIVATE_ACCESS_TOKEN}
LOGFLARE_URL: http://analytics:4000
NEXT_PUBLIC_ENABLE_LOGS: true
NEXT_ANALYTICS_BACKEND_PROVIDER: postgres
SNIPPETS_MANAGEMENT_FOLDER: /app/snippets
volumes:
- ./supabase/volumes/snippets:/app/snippets:Z
kong:
container_name: supabase-kong
image: kong:2.8.1
restart: unless-stopped
ports:
- ${KONG_HTTP_PORT}:8000/tcp
- ${KONG_HTTPS_PORT}:8443/tcp
volumes:
- ./supabase/volumes/api/kong.yml:/home/kong/temp.yml:ro,z
depends_on:
analytics:
condition: service_healthy
environment:
KONG_DATABASE: "off"
KONG_DECLARATIVE_CONFIG: /home/kong/kong.yml
KONG_DNS_ORDER: LAST,A,CNAME
KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth,request-termination,ip-restriction
KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
SUPABASE_ANON_KEY: ${ANON_KEY}
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
DASHBOARD_USERNAME: ${DASHBOARD_USERNAME}
DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD}
entrypoint: bash -c 'eval "echo \"$$(cat ~/temp.yml)\"" > ~/kong.yml && /docker-entrypoint.sh kong docker-start'
auth:
container_name: supabase-auth
image: supabase/gotrue:v2.185.0
restart: unless-stopped
healthcheck:
test:
[
"CMD",
"wget",
"--no-verbose",
"--tries=1",
"--spider",
"http://localhost:9999/health",
]
timeout: 5s
interval: 5s
retries: 3
depends_on:
db:
condition: service_healthy
analytics:
condition: service_healthy
environment:
GOTRUE_API_HOST: 0.0.0.0
GOTRUE_API_PORT: 9999
API_EXTERNAL_URL: ${API_EXTERNAL_URL}
GOTRUE_DB_DRIVER: postgres
GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
GOTRUE_SITE_URL: ${SITE_URL}
GOTRUE_URI_ALLOW_LIST: ${ADDITIONAL_REDIRECT_URLS}
GOTRUE_DISABLE_SIGNUP: ${DISABLE_SIGNUP}
GOTRUE_JWT_ADMIN_ROLES: service_role
GOTRUE_JWT_AUD: authenticated
GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
GOTRUE_JWT_EXP: ${JWT_EXPIRY}
GOTRUE_JWT_SECRET: ${JWT_SECRET}
GOTRUE_EXTERNAL_EMAIL_ENABLED: ${ENABLE_EMAIL_SIGNUP}
GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: ${ENABLE_ANONYMOUS_USERS}
GOTRUE_MAILER_AUTOCONFIRM: ${ENABLE_EMAIL_AUTOCONFIRM}
GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL}
GOTRUE_SMTP_HOST: ${SMTP_HOST}
GOTRUE_SMTP_PORT: ${SMTP_PORT}
GOTRUE_SMTP_USER: ${SMTP_USER}
GOTRUE_SMTP_PASS: ${SMTP_PASS}
GOTRUE_SMTP_SENDER_NAME: ${SMTP_SENDER_NAME}
GOTRUE_MAILER_URLPATHS_INVITE: ${MAILER_URLPATHS_INVITE}
GOTRUE_MAILER_URLPATHS_CONFIRMATION: ${MAILER_URLPATHS_CONFIRMATION}
GOTRUE_MAILER_URLPATHS_RECOVERY: ${MAILER_URLPATHS_RECOVERY}
GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: ${MAILER_URLPATHS_EMAIL_CHANGE}
GOTRUE_EXTERNAL_PHONE_ENABLED: ${ENABLE_PHONE_SIGNUP}
GOTRUE_SMS_AUTOCONFIRM: ${ENABLE_PHONE_AUTOCONFIRM}
rest:
container_name: supabase-rest
image: postgrest/postgrest:v14.3
restart: unless-stopped
depends_on:
db:
condition: service_healthy
analytics:
condition: service_healthy
environment:
PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS}
PGRST_DB_ANON_ROLE: anon
PGRST_JWT_SECRET: ${JWT_SECRET}
PGRST_DB_USE_LEGACY_GUCS: "false"
PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET}
PGRST_APP_SETTINGS_JWT_EXP: ${JWT_EXPIRY}
command: ["postgrest"]
realtime:
container_name: realtime-dev.supabase-realtime
image: supabase/realtime:v2.72.0
restart: unless-stopped
depends_on:
db:
condition: service_healthy
analytics:
condition: service_healthy
healthcheck:
test:
[
"CMD-SHELL",
'curl -sSfL --head -o /dev/null -H "Authorization: Bearer ${ANON_KEY}" http://localhost:4000/api/tenants/realtime-dev/health',
]
timeout: 5s
interval: 30s
retries: 3
start_period: 10s
environment:
PORT: 4000
DB_HOST: ${POSTGRES_HOST}
DB_PORT: ${POSTGRES_PORT}
DB_USER: supabase_admin
DB_PASSWORD: ${POSTGRES_PASSWORD}
DB_NAME: ${POSTGRES_DB}
DB_AFTER_CONNECT_QUERY: "SET search_path TO _realtime"
DB_ENC_KEY: supabaserealtime
API_JWT_SECRET: ${JWT_SECRET}
ANON_KEY: ${ANON_KEY}
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
ERL_AFLAGS: -proto_dist inet_tcp
DNS_NODES: "''"
RLIMIT_NOFILE: "10000"
APP_NAME: realtime
SEED_SELF_HOST: "true"
RUN_JANITOR: "true"
DISABLE_HEALTHCHECK_LOGGING: "true"
storage:
container_name: supabase-storage
image: supabase/storage-api:v1.33.5
restart: unless-stopped
volumes:
- ./supabase/volumes/storage:/var/lib/storage:z
healthcheck:
test:
[
"CMD",
"wget",
"--no-verbose",
"--tries=1",
"--spider",
"http://storage:5000/status",
]
timeout: 5s
interval: 5s
retries: 3
depends_on:
db:
condition: service_healthy
rest:
condition: service_started
imgproxy:
condition: service_started
environment:
ANON_KEY: ${ANON_KEY}
SERVICE_KEY: ${SERVICE_ROLE_KEY}
POSTGREST_URL: http://rest:3000
PGRST_JWT_SECRET: ${JWT_SECRET}
DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
REQUEST_ALLOW_X_FORWARDED_PATH: "true"
FILE_SIZE_LIMIT: 52428800
STORAGE_BACKEND: file
FILE_STORAGE_BACKEND_PATH: /var/lib/storage
TENANT_ID: stub
REGION: stub
GLOBAL_S3_BUCKET: stub
ENABLE_IMAGE_TRANSFORMATION: "true"
IMGPROXY_URL: http://imgproxy:5001
imgproxy:
container_name: supabase-imgproxy
image: darthsim/imgproxy:v3.30.1
restart: unless-stopped
volumes:
- ./supabase/volumes/storage:/var/lib/storage:z
healthcheck:
test: ["CMD", "imgproxy", "health"]
timeout: 5s
interval: 5s
retries: 3
environment:
IMGPROXY_BIND: ":5001"
IMGPROXY_LOCAL_FILESYSTEM_ROOT: /
IMGPROXY_USE_ETAG: "true"
IMGPROXY_ENABLE_WEBP_DETECTION: ${IMGPROXY_ENABLE_WEBP_DETECTION}
IMGPROXY_MAX_SRC_RESOLUTION: 16.8
meta:
container_name: supabase-meta
image: supabase/postgres-meta:v0.95.2
restart: unless-stopped
depends_on:
db:
condition: service_healthy
analytics:
condition: service_healthy
environment:
PG_META_PORT: 8080
PG_META_DB_HOST: ${POSTGRES_HOST}
PG_META_DB_PORT: ${POSTGRES_PORT}
PG_META_DB_NAME: ${POSTGRES_DB}
PG_META_DB_USER: supabase_admin
PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD}
CRYPTO_KEY: ${PG_META_CRYPTO_KEY}
functions:
container_name: supabase-edge-functions
image: supabase/edge-runtime:v1.70.0
restart: unless-stopped
volumes:
- ./supabase/volumes/functions:/home/deno/functions:Z
depends_on:
analytics:
condition: service_healthy
environment:
JWT_SECRET: ${JWT_SECRET}
SUPABASE_URL: http://kong:8000
SUPABASE_ANON_KEY: ${ANON_KEY}
SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
SUPABASE_DB_URL: postgresql://postgres:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
VERIFY_JWT: "${FUNCTIONS_VERIFY_JWT}"
command: ["start", "--main-service", "/home/deno/functions/main"]
analytics:
container_name: supabase-analytics
image: supabase/logflare:1.30.3
restart: unless-stopped
ports:
- 4000:4000
healthcheck:
test: ["CMD", "curl", "http://localhost:4000/health"]
timeout: 5s
interval: 5s
retries: 10
depends_on:
db:
condition: service_healthy
environment:
LOGFLARE_NODE_HOST: 127.0.0.1
DB_USERNAME: supabase_admin
DB_DATABASE: _supabase
DB_HOSTNAME: ${POSTGRES_HOST}
DB_PORT: ${POSTGRES_PORT}
DB_PASSWORD: ${POSTGRES_PASSWORD}
DB_SCHEMA: _analytics
LOGFLARE_PUBLIC_ACCESS_TOKEN: ${LOGFLARE_PUBLIC_ACCESS_TOKEN}
LOGFLARE_PRIVATE_ACCESS_TOKEN: ${LOGFLARE_PRIVATE_ACCESS_TOKEN}
LOGFLARE_SINGLE_TENANT: true
LOGFLARE_SUPABASE_MODE: true
POSTGRES_BACKEND_URL: postgresql://supabase_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/_supabase
POSTGRES_BACKEND_SCHEMA: _analytics
LOGFLARE_FEATURE_FLAG_OVERRIDE: multibackend=true
db:
container_name: supabase-db
image: supabase/postgres:15.8.1.085
restart: unless-stopped
volumes:
- ./supabase/volumes/db/realtime.sql:/docker-entrypoint-initdb.d/migrations/99-realtime.sql:Z
- ./supabase/volumes/db/webhooks.sql:/docker-entrypoint-initdb.d/init-scripts/98-webhooks.sql:Z
- ./supabase/volumes/db/roles.sql:/docker-entrypoint-initdb.d/init-scripts/99-roles.sql:Z
- ./supabase/volumes/db/jwt.sql:/docker-entrypoint-initdb.d/init-scripts/99-jwt.sql:Z
- ./supabase/volumes/db/data:/var/lib/postgresql/data:Z
- ./supabase/volumes/db/_supabase.sql:/docker-entrypoint-initdb.d/migrations/97-_supabase.sql:Z
- ./supabase/volumes/db/logs.sql:/docker-entrypoint-initdb.d/migrations/99-logs.sql:Z
- ./supabase/volumes/db/pooler.sql:/docker-entrypoint-initdb.d/migrations/99-pooler.sql:Z
- db-config:/etc/postgresql-custom
healthcheck:
test: ["CMD", "pg_isready", "-U", "postgres", "-h", "localhost"]
interval: 5s
timeout: 5s
retries: 10
depends_on:
vector:
condition: service_healthy
environment:
POSTGRES_HOST: /var/run/postgresql
PGPORT: ${POSTGRES_PORT}
POSTGRES_PORT: ${POSTGRES_PORT}
PGPASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
PGDATABASE: ${POSTGRES_DB}
POSTGRES_DB: ${POSTGRES_DB}
JWT_SECRET: ${JWT_SECRET}
JWT_EXP: ${JWT_EXPIRY}
command:
[
"postgres",
"-c",
"config_file=/etc/postgresql/postgresql.conf",
"-c",
"log_min_messages=fatal",
]
vector:
container_name: supabase-vector
image: timberio/vector:0.28.1-alpine
restart: unless-stopped
volumes:
- ./supabase/volumes/logs/vector.yml:/etc/vector/vector.yml:ro,z
- ${DOCKER_SOCKET_LOCATION}:/var/run/docker.sock:ro,z
healthcheck:
test:
[
"CMD",
"wget",
"--no-verbose",
"--tries=1",
"--spider",
"http://vector:9001/health",
]
timeout: 5s
interval: 5s
retries: 3
environment:
LOGFLARE_PUBLIC_ACCESS_TOKEN: ${LOGFLARE_PUBLIC_ACCESS_TOKEN}
command: ["--config", "/etc/vector/vector.yml"]
security_opt:
- "label=disable"
supavisor:
container_name: supabase-pooler
image: supabase/supavisor:2.7.4
restart: unless-stopped
ports:
- ${POSTGRES_PORT}:5432
- ${POOLER_PROXY_PORT_TRANSACTION}:6543
volumes:
- ./supabase/volumes/pooler/pooler.exs:/etc/pooler/pooler.exs:ro,z
healthcheck:
test:
[
"CMD",
"curl",
"-sSfL",
"--head",
"-o",
"/dev/null",
"http://127.0.0.1:4000/api/health",
]
interval: 10s
timeout: 5s
retries: 5
depends_on:
db:
condition: service_healthy
analytics:
condition: service_healthy
environment:
PORT: 4000
POSTGRES_PORT: ${POSTGRES_PORT}
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
DATABASE_URL: ecto://supabase_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/_supabase
CLUSTER_POSTGRES: true
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
VAULT_ENC_KEY: ${VAULT_ENC_KEY}
API_JWT_SECRET: ${JWT_SECRET}
METRICS_JWT_SECRET: ${JWT_SECRET}
REGION: local
ERL_AFLAGS: -proto_dist inet_tcp
POOLER_TENANT_ID: ${POOLER_TENANT_ID}
POOLER_DEFAULT_POOL_SIZE: ${POOLER_DEFAULT_POOL_SIZE}
POOLER_MAX_CLIENT_CONN: ${POOLER_MAX_CLIENT_CONN}
POOLER_POOL_MODE: transaction
DB_POOL_SIZE: ${POOLER_DB_POOL_SIZE}
command:
[
"/bin/sh",
"-c",
'/app/bin/migrate && /app/bin/supavisor eval "$$(cat /etc/pooler/pooler.exs)" && /app/bin/server',
]
volumes:
redis_data:
qdrant_data:
db-config:
+284
View File
@@ -0,0 +1,284 @@
_format_version: '2.1'
_transform: true
###
### Consumers / Users
###
consumers:
- username: DASHBOARD
- username: anon
keyauth_credentials:
- key: $SUPABASE_ANON_KEY
- username: service_role
keyauth_credentials:
- key: $SUPABASE_SERVICE_KEY
###
### Access Control List
###
acls:
- consumer: anon
group: anon
- consumer: service_role
group: admin
###
### Dashboard credentials
###
basicauth_credentials:
- consumer: DASHBOARD
username: '$DASHBOARD_USERNAME'
password: '$DASHBOARD_PASSWORD'
###
### API Routes
###
services:
## Open Auth routes
- name: auth-v1-open
url: http://auth:9999/verify
routes:
- name: auth-v1-open
strip_path: true
paths:
- /auth/v1/verify
plugins:
- name: cors
- name: auth-v1-open-callback
url: http://auth:9999/callback
routes:
- name: auth-v1-open-callback
strip_path: true
paths:
- /auth/v1/callback
plugins:
- name: cors
- name: auth-v1-open-authorize
url: http://auth:9999/authorize
routes:
- name: auth-v1-open-authorize
strip_path: true
paths:
- /auth/v1/authorize
plugins:
- name: cors
## Secure Auth routes
- name: auth-v1
_comment: 'GoTrue: /auth/v1/* -> http://auth:9999/*'
url: http://auth:9999/
routes:
- name: auth-v1-all
strip_path: true
paths:
- /auth/v1/
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: false
- name: acl
config:
hide_groups_header: true
allow:
- admin
- anon
## Secure REST routes
- name: rest-v1
_comment: 'PostgREST: /rest/v1/* -> http://rest:3000/*'
url: http://rest:3000/
routes:
- name: rest-v1-all
strip_path: true
paths:
- /rest/v1/
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: true
- name: acl
config:
hide_groups_header: true
allow:
- admin
- anon
## Secure GraphQL routes
- name: graphql-v1
_comment: 'PostgREST: /graphql/v1/* -> http://rest:3000/rpc/graphql'
url: http://rest:3000/rpc/graphql
routes:
- name: graphql-v1-all
strip_path: true
paths:
- /graphql/v1
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: true
- name: request-transformer
config:
add:
headers:
- Content-Profile:graphql_public
- name: acl
config:
hide_groups_header: true
allow:
- admin
- anon
## Secure Realtime routes
- name: realtime-v1-ws
_comment: 'Realtime: /realtime/v1/* -> ws://realtime:4000/socket/*'
url: http://realtime-dev.supabase-realtime:4000/socket
protocol: ws
routes:
- name: realtime-v1-ws
strip_path: true
paths:
- /realtime/v1/
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: false
- name: acl
config:
hide_groups_header: true
allow:
- admin
- anon
- name: realtime-v1-rest
_comment: 'Realtime: /realtime/v1/* -> ws://realtime:4000/socket/*'
url: http://realtime-dev.supabase-realtime:4000/api
protocol: http
routes:
- name: realtime-v1-rest
strip_path: true
paths:
- /realtime/v1/api
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: false
- name: acl
config:
hide_groups_header: true
allow:
- admin
- anon
## Storage routes: the storage server manages its own auth
- name: storage-v1
_comment: 'Storage: /storage/v1/* -> http://storage:5000/*'
url: http://storage:5000/
routes:
- name: storage-v1-all
strip_path: true
paths:
- /storage/v1/
plugins:
- name: cors
## Edge Functions routes
- name: functions-v1
_comment: 'Edge Functions: /functions/v1/* -> http://functions:9000/*'
url: http://functions:9000/
routes:
- name: functions-v1-all
strip_path: true
paths:
- /functions/v1/
plugins:
- name: cors
## Analytics routes
- name: analytics-v1
_comment: 'Analytics: /analytics/v1/* -> http://logflare:4000/*'
url: http://analytics:4000/
routes:
- name: analytics-v1-all
strip_path: true
paths:
- /analytics/v1/
## Secure Database routes
- name: meta
_comment: 'pg-meta: /pg/* -> http://pg-meta:8080/*'
url: http://meta:8080/
routes:
- name: meta-all
strip_path: true
paths:
- /pg/
plugins:
- name: key-auth
config:
hide_credentials: false
- name: acl
config:
hide_groups_header: true
allow:
- admin
## Block access to /api/mcp
- name: mcp-blocker
_comment: 'Block direct access to /api/mcp'
url: http://studio:3000/api/mcp
routes:
- name: mcp-blocker-route
strip_path: true
paths:
- /api/mcp
plugins:
- name: request-termination
config:
status_code: 403
message: "Access is forbidden."
## MCP endpoint - local access
- name: mcp
_comment: 'MCP: /mcp -> http://studio:3000/api/mcp (local access)'
url: http://studio:3000/api/mcp
routes:
- name: mcp
strip_path: true
paths:
- /mcp
plugins:
# Block access to /mcp by default
- name: request-termination
config:
status_code: 403
message: "Access is forbidden."
# Enable local access (danger zone!)
# 1. Comment out the 'request-termination' section above
# 2. Uncomment the entire section below, including 'deny'
# 3. Add your local IPs to the 'allow' list
#- name: cors
#- name: ip-restriction
# config:
# allow:
# - 127.0.0.1
# - ::1
# deny: []
## Protected Dashboard - catch all remaining routes
- name: dashboard
_comment: 'Studio: /* -> http://studio:3000/*'
url: http://studio:3000/
routes:
- name: dashboard-all
strip_path: true
paths:
- /
plugins:
- name: cors
- name: basic-auth
config:
hide_credentials: true
@@ -0,0 +1,3 @@
\set pguser `echo "$POSTGRES_USER"`
CREATE DATABASE _supabase WITH OWNER :pguser;
+5
View File
@@ -0,0 +1,5 @@
\set jwt_secret `echo "$JWT_SECRET"`
\set jwt_exp `echo "$JWT_EXP"`
ALTER DATABASE postgres SET "app.settings.jwt_secret" TO :'jwt_secret';
ALTER DATABASE postgres SET "app.settings.jwt_exp" TO :'jwt_exp';
+6
View File
@@ -0,0 +1,6 @@
\set pguser `echo "$POSTGRES_USER"`
\c _supabase
create schema if not exists _analytics;
alter schema _analytics owner to :pguser;
\c postgres
@@ -0,0 +1,6 @@
\set pguser `echo "$POSTGRES_USER"`
\c _supabase
create schema if not exists _supavisor;
alter schema _supavisor owner to :pguser;
\c postgres
@@ -0,0 +1,4 @@
\set pguser `echo "$POSTGRES_USER"`
create schema if not exists _realtime;
alter schema _realtime owner to :pguser;
@@ -0,0 +1,8 @@
-- NOTE: change to your own passwords for production environments
\set pgpass `echo "$POSTGRES_PASSWORD"`
ALTER USER authenticator WITH PASSWORD :'pgpass';
ALTER USER pgbouncer WITH PASSWORD :'pgpass';
ALTER USER supabase_auth_admin WITH PASSWORD :'pgpass';
ALTER USER supabase_functions_admin WITH PASSWORD :'pgpass';
ALTER USER supabase_storage_admin WITH PASSWORD :'pgpass';
@@ -0,0 +1,208 @@
BEGIN;
-- Create pg_net extension
CREATE EXTENSION IF NOT EXISTS pg_net SCHEMA extensions;
-- Create supabase_functions schema
CREATE SCHEMA supabase_functions AUTHORIZATION supabase_admin;
GRANT USAGE ON SCHEMA supabase_functions TO postgres, anon, authenticated, service_role;
ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON TABLES TO postgres, anon, authenticated, service_role;
ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON FUNCTIONS TO postgres, anon, authenticated, service_role;
ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON SEQUENCES TO postgres, anon, authenticated, service_role;
-- supabase_functions.migrations definition
CREATE TABLE supabase_functions.migrations (
version text PRIMARY KEY,
inserted_at timestamptz NOT NULL DEFAULT NOW()
);
-- Initial supabase_functions migration
INSERT INTO supabase_functions.migrations (version) VALUES ('initial');
-- supabase_functions.hooks definition
CREATE TABLE supabase_functions.hooks (
id bigserial PRIMARY KEY,
hook_table_id integer NOT NULL,
hook_name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT NOW(),
request_id bigint
);
CREATE INDEX supabase_functions_hooks_request_id_idx ON supabase_functions.hooks USING btree (request_id);
CREATE INDEX supabase_functions_hooks_h_table_id_h_name_idx ON supabase_functions.hooks USING btree (hook_table_id, hook_name);
COMMENT ON TABLE supabase_functions.hooks IS 'Supabase Functions Hooks: Audit trail for triggered hooks.';
CREATE FUNCTION supabase_functions.http_request()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
DECLARE
request_id bigint;
payload jsonb;
url text := TG_ARGV[0]::text;
method text := TG_ARGV[1]::text;
headers jsonb DEFAULT '{}'::jsonb;
params jsonb DEFAULT '{}'::jsonb;
timeout_ms integer DEFAULT 1000;
BEGIN
IF url IS NULL OR url = 'null' THEN
RAISE EXCEPTION 'url argument is missing';
END IF;
IF method IS NULL OR method = 'null' THEN
RAISE EXCEPTION 'method argument is missing';
END IF;
IF TG_ARGV[2] IS NULL OR TG_ARGV[2] = 'null' THEN
headers = '{"Content-Type": "application/json"}'::jsonb;
ELSE
headers = TG_ARGV[2]::jsonb;
END IF;
IF TG_ARGV[3] IS NULL OR TG_ARGV[3] = 'null' THEN
params = '{}'::jsonb;
ELSE
params = TG_ARGV[3]::jsonb;
END IF;
IF TG_ARGV[4] IS NULL OR TG_ARGV[4] = 'null' THEN
timeout_ms = 1000;
ELSE
timeout_ms = TG_ARGV[4]::integer;
END IF;
CASE
WHEN method = 'GET' THEN
SELECT http_get INTO request_id FROM net.http_get(
url,
params,
headers,
timeout_ms
);
WHEN method = 'POST' THEN
payload = jsonb_build_object(
'old_record', OLD,
'record', NEW,
'type', TG_OP,
'table', TG_TABLE_NAME,
'schema', TG_TABLE_SCHEMA
);
SELECT http_post INTO request_id FROM net.http_post(
url,
payload,
params,
headers,
timeout_ms
);
ELSE
RAISE EXCEPTION 'method argument % is invalid', method;
END CASE;
INSERT INTO supabase_functions.hooks
(hook_table_id, hook_name, request_id)
VALUES
(TG_RELID, TG_NAME, request_id);
RETURN NEW;
END
$function$;
-- Supabase super admin
DO
$$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_roles
WHERE rolname = 'supabase_functions_admin'
)
THEN
CREATE USER supabase_functions_admin NOINHERIT CREATEROLE LOGIN NOREPLICATION;
END IF;
END
$$;
GRANT ALL PRIVILEGES ON SCHEMA supabase_functions TO supabase_functions_admin;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA supabase_functions TO supabase_functions_admin;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA supabase_functions TO supabase_functions_admin;
ALTER USER supabase_functions_admin SET search_path = "supabase_functions";
ALTER table "supabase_functions".migrations OWNER TO supabase_functions_admin;
ALTER table "supabase_functions".hooks OWNER TO supabase_functions_admin;
ALTER function "supabase_functions".http_request() OWNER TO supabase_functions_admin;
GRANT supabase_functions_admin TO postgres;
-- Remove unused supabase_pg_net_admin role
DO
$$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_roles
WHERE rolname = 'supabase_pg_net_admin'
)
THEN
REASSIGN OWNED BY supabase_pg_net_admin TO supabase_admin;
DROP OWNED BY supabase_pg_net_admin;
DROP ROLE supabase_pg_net_admin;
END IF;
END
$$;
-- pg_net grants when extension is already enabled
DO
$$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_extension
WHERE extname = 'pg_net'
)
THEN
GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role;
ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER;
ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER;
ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net;
ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net;
REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC;
REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role;
GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role;
END IF;
END
$$;
-- Event trigger for pg_net
CREATE OR REPLACE FUNCTION extensions.grant_pg_net_access()
RETURNS event_trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_event_trigger_ddl_commands() AS ev
JOIN pg_extension AS ext
ON ev.objid = ext.oid
WHERE ext.extname = 'pg_net'
)
THEN
GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role;
ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER;
ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER;
ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net;
ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net;
REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC;
REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role;
GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role;
END IF;
END;
$$;
COMMENT ON FUNCTION extensions.grant_pg_net_access IS 'Grants access to pg_net';
DO
$$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_event_trigger
WHERE evtname = 'issue_pg_net_access'
) THEN
CREATE EVENT TRIGGER issue_pg_net_access ON ddl_command_end WHEN TAG IN ('CREATE EXTENSION')
EXECUTE PROCEDURE extensions.grant_pg_net_access();
END IF;
END
$$;
INSERT INTO supabase_functions.migrations (version) VALUES ('20210809183423_update_grants');
ALTER function supabase_functions.http_request() SECURITY DEFINER;
ALTER function supabase_functions.http_request() SET search_path = supabase_functions;
REVOKE ALL ON FUNCTION supabase_functions.http_request() FROM PUBLIC;
GRANT EXECUTE ON FUNCTION supabase_functions.http_request() TO postgres, anon, authenticated, service_role;
COMMIT;
@@ -0,0 +1,89 @@
import { serve } from 'https://deno.land/std@0.131.0/http/server.ts'
import * as jose from 'https://deno.land/x/jose@v4.14.4/index.ts'
const JWT_SECRET = Deno.env.get('JWT_SECRET')
const VERIFY_JWT = Deno.env.get('VERIFY_JWT') === 'true'
function getAuthToken(req: Request) {
const authHeader = req.headers.get('authorization')
if (!authHeader) {
throw new Error('Missing authorization header')
}
const [bearer, token] = authHeader.split(' ')
if (bearer !== 'Bearer') {
throw new Error("Auth header is not 'Bearer {token}'")
}
return token
}
async function verifyJWT(jwt: string): Promise<boolean> {
const encoder = new TextEncoder()
const secretKey = encoder.encode(JWT_SECRET)
try {
await jose.jwtVerify(jwt, secretKey)
} catch (_err) {
return false
}
return true
}
serve(async (req: Request) => {
if (req.method !== 'OPTIONS' && VERIFY_JWT) {
try {
const token = getAuthToken(req)
const isValidJWT = await verifyJWT(token)
if (!isValidJWT) {
return new Response(JSON.stringify({ msg: 'Invalid JWT' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
})
}
} catch (e) {
return new Response(JSON.stringify({ msg: e.toString() }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
})
}
}
const url = new URL(req.url)
const { pathname } = url
const path_parts = pathname.split('/')
const service_name = path_parts[1]
if (!service_name || service_name === '') {
const error = { msg: 'missing function name in request' }
return new Response(JSON.stringify(error), {
status: 400,
headers: { 'Content-Type': 'application/json' },
})
}
const servicePath = `/home/deno/functions/${service_name}`
const memoryLimitMb = 150
const workerTimeoutMs = 1 * 60 * 1000
const noModuleCache = false
const importMapPath = null
const envVarsObj = Deno.env.toObject()
const envVars = Object.keys(envVarsObj).map((k) => [k, envVarsObj[k]])
try {
const worker = await EdgeRuntime.userWorkers.create({
servicePath,
memoryLimitMb,
workerTimeoutMs,
noModuleCache,
importMapPath,
envVars,
})
return await worker.fetch(req)
} catch (e) {
const error = { msg: e.toString() }
return new Response(JSON.stringify(error), {
status: 500,
headers: { 'Content-Type': 'application/json' },
})
}
})
@@ -0,0 +1,247 @@
api:
enabled: true
address: 0.0.0.0:9001
sources:
docker_host:
type: docker_logs
exclude_containers:
- supabase-vector
transforms:
project_logs:
type: remap
inputs:
- docker_host
source: |-
.project = "default"
.event_message = del(.message)
.appname = del(.container_name)
del(.container_created_at)
del(.container_id)
del(.source_type)
del(.stream)
del(.label)
del(.image)
del(.host)
del(.stream)
router:
type: route
inputs:
- project_logs
route:
kong: '.appname == "supabase-kong"'
auth: '.appname == "supabase-auth"'
rest: '.appname == "supabase-rest"'
realtime: '.appname == "realtime-dev.supabase-realtime"'
storage: '.appname == "supabase-storage"'
functions: '.appname == "supabase-edge-functions"'
db: '.appname == "supabase-db"'
kong_logs:
type: remap
inputs:
- router.kong
source: |-
req, err = parse_nginx_log(.event_message, "combined")
if err == null {
.timestamp = req.timestamp
.metadata.request.headers.referer = req.referer
.metadata.request.headers.user_agent = req.agent
.metadata.request.headers.cf_connecting_ip = req.client
.metadata.request.method = req.method
.metadata.request.path = req.path
.metadata.request.protocol = req.protocol
.metadata.response.status_code = req.status
}
if err != null {
abort
}
kong_err:
type: remap
inputs:
- router.kong
source: |-
.metadata.request.method = "GET"
.metadata.response.status_code = 200
parsed, err = parse_nginx_log(.event_message, "error")
if err == null {
.timestamp = parsed.timestamp
.severity = parsed.severity
.metadata.request.host = parsed.host
.metadata.request.headers.cf_connecting_ip = parsed.client
url, err = split(parsed.request, " ")
if err == null {
.metadata.request.method = url[0]
.metadata.request.path = url[1]
.metadata.request.protocol = url[2]
}
}
if err != null {
abort
}
auth_logs:
type: remap
inputs:
- router.auth
source: |-
parsed, err = parse_json(.event_message)
if err == null {
.metadata.timestamp = parsed.time
.metadata = merge!(.metadata, parsed)
}
rest_logs:
type: remap
inputs:
- router.rest
source: |-
parsed, err = parse_regex(.event_message, r'^(?P<time>.*): (?P<msg>.*)$')
if err == null {
.event_message = parsed.msg
.timestamp = to_timestamp!(parsed.time)
.metadata.host = .project
}
realtime_logs_filtered:
type: filter
inputs:
- router.realtime
condition: '!contains(string!(.event_message), "/health")'
realtime_logs:
type: remap
inputs:
- realtime_logs_filtered
source: |-
.metadata.project = del(.project)
.metadata.external_id = .metadata.project
parsed, err = parse_regex(.event_message, r'^(?P<time>\d+:\d+:\d+\.\d+) \[(?P<level>\w+)\] (?P<msg>.*)$')
if err == null {
.event_message = parsed.msg
.metadata.level = parsed.level
}
functions_logs:
type: remap
inputs:
- router.functions
source: |-
.metadata.project_ref = del(.project)
storage_logs:
type: remap
inputs:
- router.storage
source: |-
.metadata.project = del(.project)
.metadata.tenantId = .metadata.project
parsed, err = parse_json(.event_message)
if err == null {
.event_message = parsed.msg
.metadata.level = parsed.level
.metadata.timestamp = parsed.time
.metadata.context[0].host = parsed.hostname
.metadata.context[0].pid = parsed.pid
}
db_logs:
type: remap
inputs:
- router.db
source: |-
.metadata.host = "db-default"
.metadata.parsed.timestamp = .timestamp
parsed, err = parse_regex(.event_message, r'.*(?P<level>INFO|NOTICE|WARNING|ERROR|LOG|FATAL|PANIC?):.*', numeric_groups: true)
if err != null || parsed == null {
.metadata.parsed.error_severity = "info"
}
if parsed.level != null {
.metadata.parsed.error_severity = parsed.level
}
if .metadata.parsed.error_severity == "info" {
.metadata.parsed.error_severity = "log"
}
.metadata.parsed.error_severity = upcase!(.metadata.parsed.error_severity)
sinks:
logflare_auth:
type: 'http'
inputs:
- auth_logs
encoding:
codec: 'json'
method: 'post'
request:
retry_max_duration_secs: 10
headers:
x-api-key: ${LOGFLARE_PUBLIC_ACCESS_TOKEN?LOGFLARE_PUBLIC_ACCESS_TOKEN is required}
uri: 'http://analytics:4000/api/logs?source_name=gotrue.logs.prod'
logflare_realtime:
type: 'http'
inputs:
- realtime_logs
encoding:
codec: 'json'
method: 'post'
request:
retry_max_duration_secs: 10
headers:
x-api-key: ${LOGFLARE_PUBLIC_ACCESS_TOKEN?LOGFLARE_PUBLIC_ACCESS_TOKEN is required}
uri: 'http://analytics:4000/api/logs?source_name=realtime.logs.prod'
logflare_rest:
type: 'http'
inputs:
- rest_logs
encoding:
codec: 'json'
method: 'post'
request:
retry_max_duration_secs: 10
headers:
x-api-key: ${LOGFLARE_PUBLIC_ACCESS_TOKEN?LOGFLARE_PUBLIC_ACCESS_TOKEN is required}
uri: 'http://analytics:4000/api/logs?source_name=postgREST.logs.prod'
logflare_db:
type: 'http'
inputs:
- db_logs
encoding:
codec: 'json'
method: 'post'
request:
retry_max_duration_secs: 10
headers:
x-api-key: ${LOGFLARE_PUBLIC_ACCESS_TOKEN?LOGFLARE_PUBLIC_ACCESS_TOKEN is required}
uri: 'http://kong:8000/analytics/v1/api/logs?source_name=postgres.logs'
logflare_functions:
type: 'http'
inputs:
- functions_logs
encoding:
codec: 'json'
method: 'post'
request:
retry_max_duration_secs: 10
headers:
x-api-key: ${LOGFLARE_PUBLIC_ACCESS_TOKEN?LOGFLARE_PUBLIC_ACCESS_TOKEN is required}
uri: 'http://analytics:4000/api/logs?source_name=deno-relay-logs'
logflare_storage:
type: 'http'
inputs:
- storage_logs
encoding:
codec: 'json'
method: 'post'
request:
retry_max_duration_secs: 10
headers:
x-api-key: ${LOGFLARE_PUBLIC_ACCESS_TOKEN?LOGFLARE_PUBLIC_ACCESS_TOKEN is required}
uri: 'http://analytics:4000/api/logs?source_name=storage.logs.prod.2'
logflare_kong:
type: 'http'
inputs:
- kong_logs
- kong_err
encoding:
codec: 'json'
method: 'post'
request:
retry_max_duration_secs: 10
headers:
x-api-key: ${LOGFLARE_PUBLIC_ACCESS_TOKEN?LOGFLARE_PUBLIC_ACCESS_TOKEN is required}
uri: 'http://analytics:4000/api/logs?source_name=cloudflare.logs.prod'
@@ -0,0 +1,27 @@
{:ok, _} = Application.ensure_all_started(:supavisor)
{:ok, version} =
case Supavisor.Repo.query!("select version()") do
%{rows: [[ver]]} -> Supavisor.Helpers.parse_pg_version(ver)
_ -> nil
end
params = %{
"external_id" => System.get_env("POOLER_TENANT_ID"),
"db_host" => "db",
"db_port" => System.get_env("POSTGRES_PORT"),
"db_database" => System.get_env("POSTGRES_DB"),
"require_user" => false,
"auth_query" => "SELECT * FROM pgbouncer.get_auth($1)",
"default_max_clients" => System.get_env("POOLER_MAX_CLIENT_CONN"),
"default_pool_size" => System.get_env("POOLER_DEFAULT_POOL_SIZE"),
"default_parameter_status" => %{"server_version" => version},
"users" => [%{
"db_user" => "pgbouncer",
"db_password" => System.get_env("POSTGRES_PASSWORD"),
"mode_type" => System.get_env("POOLER_POOL_MODE"),
"pool_size" => System.get_env("POOLER_DEFAULT_POOL_SIZE"),
"is_manager" => true
}]
}
if !Supavisor.Tenants.get_tenant_by_external_id(params["external_id"]) do
{:ok, _} = Supavisor.Tenants.create_tenant(params)
end