refactor: enhance installation process with support for full-stack and app-only modes, update environment configurations, and improve database migration handling

This commit is contained in:
Poyraz Avsever
2026-06-13 21:23:28 +03:00
parent 17ff60faea
commit 0f89d9f627
17 changed files with 620 additions and 51 deletions
+7
View File
@@ -1,5 +1,8 @@
# Neta self-host environment # Neta self-host environment
# Use app-only when Neta connects to an external Supabase-compatible backend.
NETA_INSTALL_MODE=app-only
# Public URL where users open Neta. # Public URL where users open Neta.
NEXT_PUBLIC_SITE_URL=http://localhost:3000 NEXT_PUBLIC_SITE_URL=http://localhost:3000
@@ -18,6 +21,10 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=
# Keep this secret. It is only used server-side. # Keep this secret. It is only used server-side.
SUPABASE_SERVICE_ROLE_KEY= SUPABASE_SERVICE_ROLE_KEY=
# Optional internal Supabase URL for Docker networks.
# In bundled mode this is set to http://neta-supabase-proxy:8000 by docker-compose.full.yml.
# SUPABASE_INTERNAL_URL=
# Optional: direct Postgres connection string used only by bash ./scripts/apply-migrations.sh. # Optional: direct Postgres connection string used only by bash ./scripts/apply-migrations.sh.
# Do not expose this to browsers. It is not required by the web container. # Do not expose this to browsers. It is not required by the web container.
# DATABASE_URL=postgresql://postgres:password@host:5432/postgres # DATABASE_URL=postgresql://postgres:password@host:5432/postgres
+23
View File
@@ -0,0 +1,23 @@
# Neta full-stack self-host environment
NETA_INSTALL_MODE=full-stack
NEXT_PUBLIC_SITE_URL=http://localhost:3000
NETA_PORT=3000
# Public Supabase API URL reachable from the browser.
# For local Docker installs this is usually http://localhost:8000.
NEXT_PUBLIC_SUPABASE_URL=http://localhost:8000
SUPABASE_API_PORT=8000
# Generated by install.sh in full-stack mode.
NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
JWT_SECRET=
POSTGRES_PASSWORD=
# Exposes bundled Postgres on the host for backups/manual access.
POSTGRES_PORT=54322
# Optional auth/mail settings.
JWT_EXPIRY=3600
SMTP_ADMIN_EMAIL=admin@neta.local
+1
View File
@@ -5,6 +5,7 @@ dist
build build
.env* .env*
!.env.example !.env.example
!.env.full.example
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
+22 -10
View File
@@ -50,13 +50,14 @@ Neta is engineered using modern, high-performance web technologies:
## Installation and Deployment ## Installation and Deployment
Neta is designed for self-hosting. The current Docker Compose file runs the Neta web application and connects it to a Supabase-compatible backend. A bundled Supabase Compose profile is planned for the full-stack self-host mode. Neta is designed for self-hosting. It supports two Docker deployment modes:
- **Full-stack:** Neta + bundled Postgres/Auth/PostgREST/Storage.
- **App-only:** Neta connected to an existing Supabase-compatible backend.
### Prerequisites ### Prerequisites
- Docker and Docker Compose - Docker and Docker Compose
- A Supabase project or self-hosted Supabase backend - For app-only mode: Supabase API URL, anon key, service role key, and a direct Postgres `DATABASE_URL` for migrations
- Supabase API URL, anon key, and service role key
- A direct Postgres `DATABASE_URL` if you want the installer to apply migrations automatically
### 1-Click Installation (Recommended) ### 1-Click Installation (Recommended)
@@ -66,22 +67,31 @@ You can install Neta using the interactive setup script:
curl -sL https://raw.githubusercontent.com/poyrazavsever/neta/main/install.sh | bash curl -sL https://raw.githubusercontent.com/poyrazavsever/neta/main/install.sh | bash
``` ```
The installer asks for the required Supabase values, writes a `.env` file, optionally applies database migrations, validates Docker Compose configuration, and starts the application. The installer asks for the deployment mode, writes a `.env` file, validates Docker Compose configuration, and starts the application. In full-stack mode it generates Supabase JWT secrets and applies Neta migrations automatically through the `neta-migrations` service.
### Manual Installation ### Manual Installation
If you prefer to set up Neta manually: If you prefer to set up Neta manually:
1. Clone the repository: `git clone https://github.com/poyrazavsever/neta.git` 1. Clone the repository: `git clone https://github.com/poyrazavsever/neta.git`
2. Navigate to the directory and copy the `.env.example` file to `.env`. 2. Navigate to the directory.
3. Fill every required value in `.env`. 3. For full-stack mode, generate a `.env` file and run:
4. Apply database migrations:
```bash ```bash
DATABASE_URL='postgresql://postgres:password@host:5432/postgres' bash ./scripts/apply-migrations.sh node scripts/generate-full-stack-env.mjs > .env
``` ```
5. Build and start the Docker container: ```bash
docker compose -f docker-compose.full.yml up -d --build
```
4. For app-only mode, copy `.env.example` to `.env`, fill every required value, and apply database migrations:
```bash
DATABASE_URL='postgresql://postgres:password@host:5432/postgres' sh ./scripts/apply-migrations.sh
```
5. Start the app-only Docker container:
```bash ```bash
docker compose up -d --build docker compose up -d --build
@@ -89,6 +99,8 @@ docker compose up -d --build
Docker Compose intentionally fails fast when required Supabase environment values are missing. Docker Compose intentionally fails fast when required Supabase environment values are missing.
Coolify and Dokploy users should use `docker-compose.full.yml` for the no-external-service setup, or `docker-compose.yml` when connecting to an existing Supabase backend. See `docs/deployment/self-hosting.md` for the deployment checklist.
### First Administrator Account ### First Administrator Account
To ensure data security, Neta is locked to a single administrator. Upon launching the application for the first time, navigate to the `/register` route to create the initial admin account. Once this account is created, public registration is permanently disabled. To ensure data security, Neta is locked to a single administrator. Upon launching the application for the first time, navigate to the `/register` route to create the initial admin account. Once this account is created, public registration is permanently disabled.
+2 -1
View File
@@ -18,7 +18,8 @@ export async function POST(request: Request) {
); );
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; const supabaseUrl =
process.env.SUPABASE_INTERNAL_URL || process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !serviceRoleKey) { if (!supabaseUrl || !serviceRoleKey) {
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env sh
set -eu
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<SQL
create role anon nologin;
create role authenticated nologin;
create role service_role nologin bypassrls;
create role authenticator noinherit login password '$POSTGRES_PASSWORD';
create role supabase_auth_admin noinherit login password '$POSTGRES_PASSWORD';
create role supabase_storage_admin noinherit login password '$POSTGRES_PASSWORD';
grant anon, authenticated, service_role to authenticator;
grant all privileges on database postgres to supabase_auth_admin;
grant all privileges on database postgres to supabase_storage_admin;
create schema if not exists auth authorization supabase_auth_admin;
create schema if not exists storage authorization supabase_storage_admin;
grant usage on schema public to anon, authenticated, service_role;
grant usage on schema auth to supabase_auth_admin;
grant usage on schema storage to anon, authenticated, service_role, supabase_storage_admin;
create extension if not exists "uuid-ossp";
create extension if not exists pgcrypto;
create extension if not exists vector;
create or replace function auth.uid()
returns uuid
language sql
stable
as \$\$
select nullif(current_setting('request.jwt.claim.sub', true), '')::uuid;
\$\$;
create or replace function auth.role()
returns text
language sql
stable
as \$\$
select nullif(current_setting('request.jwt.claim.role', true), '')::text;
\$\$;
create or replace function auth.email()
returns text
language sql
stable
as \$\$
select nullif(current_setting('request.jwt.claim.email', true), '')::text;
\$\$;
alter default privileges in schema public grant select, insert, update, delete on tables to anon, authenticated, service_role;
alter default privileges in schema public grant usage, select on sequences to anon, authenticated, service_role;
SQL
@@ -0,0 +1,37 @@
server {
listen 8000;
server_name _;
client_max_body_size 50m;
location /auth/v1/ {
proxy_pass http://neta-auth:9999/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /rest/v1/ {
proxy_pass http://neta-rest:3000/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /storage/v1/ {
proxy_pass http://neta-storage:5000/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /health {
return 200 "ok\n";
add_header Content-Type text/plain;
}
}
+162
View File
@@ -0,0 +1,162 @@
services:
neta-web:
build:
context: .
dockerfile: Dockerfile
args:
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL:?Set NEXT_PUBLIC_SUPABASE_URL in .env}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY:?Set NEXT_PUBLIC_SUPABASE_ANON_KEY in .env}
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3000}
container_name: neta-web
restart: unless-stopped
depends_on:
neta-migrations:
condition: service_completed_successfully
neta-supabase-proxy:
condition: service_started
ports:
- "${NETA_PORT:-3000}:3000"
environment:
NODE_ENV: production
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL:?Set NEXT_PUBLIC_SUPABASE_URL in .env}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY:?Set NEXT_PUBLIC_SUPABASE_ANON_KEY in .env}
SUPABASE_SERVICE_ROLE_KEY: ${SUPABASE_SERVICE_ROLE_KEY:?Set SUPABASE_SERVICE_ROLE_KEY in .env}
SUPABASE_INTERNAL_URL: http://neta-supabase-proxy:8000
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3000}
neta-supabase-proxy:
image: nginx:1.27-alpine
container_name: neta-supabase-proxy
restart: unless-stopped
depends_on:
neta-auth:
condition: service_started
neta-rest:
condition: service_started
neta-storage:
condition: service_started
ports:
- "${SUPABASE_API_PORT:-8000}:8000"
volumes:
- ./deploy/supabase/nginx/default.conf.template:/etc/nginx/templates/default.conf.template:ro
neta-auth:
image: supabase/gotrue:v2.177.0
container_name: neta-auth
restart: unless-stopped
depends_on:
neta-db:
condition: service_healthy
environment:
GOTRUE_API_HOST: 0.0.0.0
GOTRUE_API_PORT: 9999
API_EXTERNAL_URL: ${NEXT_PUBLIC_SUPABASE_URL:?Set NEXT_PUBLIC_SUPABASE_URL in .env}/auth/v1
GOTRUE_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3000}
GOTRUE_URI_ALLOW_LIST: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3000}
GOTRUE_DB_DRIVER: postgres
GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}@neta-db:5432/postgres
GOTRUE_JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET in .env}
GOTRUE_JWT_EXP: ${JWT_EXPIRY:-3600}
GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
GOTRUE_DISABLE_SIGNUP: "false"
GOTRUE_EXTERNAL_EMAIL_ENABLED: "true"
GOTRUE_MAILER_AUTOCONFIRM: "true"
GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL:-admin@neta.local}
GOTRUE_SMTP_HOST: ${SMTP_HOST:-}
GOTRUE_SMTP_PORT: ${SMTP_PORT:-587}
GOTRUE_SMTP_USER: ${SMTP_USER:-}
GOTRUE_SMTP_PASS: ${SMTP_PASS:-}
GOTRUE_SMTP_SENDER_NAME: ${SMTP_SENDER_NAME:-Neta}
neta-rest:
image: postgrest/postgrest:v12.2.12
container_name: neta-rest
restart: unless-stopped
depends_on:
neta-db:
condition: service_healthy
environment:
PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}@neta-db:5432/postgres
PGRST_DB_SCHEMAS: public,storage
PGRST_DB_ANON_ROLE: anon
PGRST_JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET in .env}
PGRST_DB_USE_LEGACY_GUCS: "false"
neta-storage:
image: supabase/storage-api:v1.24.7
container_name: neta-storage
restart: unless-stopped
depends_on:
neta-db:
condition: service_healthy
neta-rest:
condition: service_started
environment:
ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY:?Set NEXT_PUBLIC_SUPABASE_ANON_KEY in .env}
SERVICE_KEY: ${SUPABASE_SERVICE_ROLE_KEY:?Set SUPABASE_SERVICE_ROLE_KEY in .env}
POSTGREST_URL: http://neta-rest:3000
PGRST_JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET in .env}
DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}@neta-db:5432/postgres
FILE_SIZE_LIMIT: ${STORAGE_FILE_SIZE_LIMIT:-52428800}
STORAGE_BACKEND: file
FILE_STORAGE_BACKEND_PATH: /var/lib/storage
TENANT_ID: stub
REGION: local
GLOBAL_S3_BUCKET: stub
ENABLE_IMAGE_TRANSFORMATION: "false"
volumes:
- neta-storage-data:/var/lib/storage
neta-migrations:
image: postgres:16-alpine
container_name: neta-migrations
depends_on:
neta-db:
condition: service_healthy
neta-storage:
condition: service_started
environment:
DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}@neta-db:5432/postgres
volumes:
- ./supabase:/app/supabase:ro
- ./scripts:/app/scripts:ro
working_dir: /app
command:
- sh
- -c
- |
tries=0
until psql "$$DATABASE_URL" -v ON_ERROR_STOP=1 -tAc "select case when to_regclass('auth.users') is not null and to_regclass('storage.buckets') is not null then 'ready' else 'waiting' end" | grep -q "ready"; do
tries=$$((tries + 1))
if [ "$$tries" -gt 60 ]; then
echo "Timed out waiting for Supabase Auth/Storage schemas."
exit 1
fi
echo "Waiting for Supabase Auth/Storage schemas..."
sleep 2
done
sh ./scripts/apply-migrations.sh
neta-db:
image: pgvector/pgvector:pg16
container_name: neta-db
restart: unless-stopped
ports:
- "${POSTGRES_PORT:-54322}:5432"
environment:
POSTGRES_DB: postgres
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
POSTGRES_INITDB_ARGS: --auth-host=scram-sha-256
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
interval: 5s
timeout: 5s
retries: 20
volumes:
- neta-db-data:/var/lib/postgresql/data
- ./deploy/supabase/db/init.sh:/docker-entrypoint-initdb.d/00-neta-init.sh:ro
volumes:
neta-db-data:
neta-storage-data:
+1
View File
@@ -16,4 +16,5 @@ services:
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL:?Set NEXT_PUBLIC_SUPABASE_URL in .env} NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL:?Set NEXT_PUBLIC_SUPABASE_URL in .env}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY:?Set NEXT_PUBLIC_SUPABASE_ANON_KEY in .env} NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY:?Set NEXT_PUBLIC_SUPABASE_ANON_KEY in .env}
SUPABASE_SERVICE_ROLE_KEY: ${SUPABASE_SERVICE_ROLE_KEY:?Set SUPABASE_SERVICE_ROLE_KEY in .env} SUPABASE_SERVICE_ROLE_KEY: ${SUPABASE_SERVICE_ROLE_KEY:?Set SUPABASE_SERVICE_ROLE_KEY in .env}
SUPABASE_INTERNAL_URL: ${SUPABASE_INTERNAL_URL:-${NEXT_PUBLIC_SUPABASE_URL:?Set NEXT_PUBLIC_SUPABASE_URL in .env}}
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3000} NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3000}
+1 -1
View File
@@ -34,7 +34,7 @@ They must also be documented and registered in this file, but they should only b
Use the migration helper from the repository root: Use the migration helper from the repository root:
```bash ```bash
DATABASE_URL='postgresql://postgres:password@host:5432/postgres' bash ./scripts/apply-migrations.sh DATABASE_URL='postgresql://postgres:password@host:5432/postgres' sh ./scripts/apply-migrations.sh
``` ```
The helper applies `0001` through `0009` in the order listed above. It uses local `psql` when available, otherwise it runs `psql` through Docker. The helper applies `0001` through `0009` in the order listed above. It uses local `psql` when available, otherwise it runs `psql` through Docker.
+89
View File
@@ -0,0 +1,89 @@
# Self-Hosting Deployment
Neta has two Docker modes.
## Full-Stack Mode
Use this when the server should run Neta and the bundled Supabase-compatible stack.
Compose file:
```bash
docker-compose.full.yml
```
Services:
- `neta-web`: Next.js app
- `neta-db`: Postgres with pgvector
- `neta-auth`: Supabase Auth
- `neta-rest`: PostgREST
- `neta-storage`: Supabase Storage with local disk backend
- `neta-supabase-proxy`: single public API entrypoint
- `neta-migrations`: one-shot Neta database migration runner
Generate production secrets:
```bash
node scripts/generate-full-stack-env.mjs > .env
```
Then adjust these values before starting:
- `NEXT_PUBLIC_SITE_URL`: public Neta URL
- `NEXT_PUBLIC_SUPABASE_URL`: public bundled Supabase API URL
- `NETA_PORT`: host port for Neta when deploying directly with Docker
- `SUPABASE_API_PORT`: host port for the bundled Supabase API
- `POSTGRES_PORT`: host port for backup/manual DB access
Start:
```bash
docker compose -f docker-compose.full.yml up -d --build
```
## App-Only Mode
Use this when Neta connects to an existing Supabase or Supabase-compatible backend.
Compose file:
```bash
docker-compose.yml
```
Required env values:
- `NEXT_PUBLIC_SITE_URL`
- `NEXT_PUBLIC_SUPABASE_URL`
- `NEXT_PUBLIC_SUPABASE_ANON_KEY`
- `SUPABASE_SERVICE_ROLE_KEY`
- `DATABASE_URL` only when applying migrations manually
Apply migrations:
```bash
DATABASE_URL='postgresql://postgres:password@host:5432/postgres' sh ./scripts/apply-migrations.sh
```
Start:
```bash
docker compose up -d --build
```
## Coolify
For a no-external-service install, choose Docker Compose deployment and set the compose file to `docker-compose.full.yml`.
Set the generated full-stack env values in Coolify's environment variables. Route the app domain to `neta-web:3000`. If you expose Supabase through a second domain, route it to `neta-supabase-proxy:8000` and set `NEXT_PUBLIC_SUPABASE_URL` to that public URL.
## Dokploy
Create a Compose app from this repository. Use `docker-compose.full.yml` for full-stack deployments and paste the generated env values into the environment panel.
Route the Neta domain to `neta-web` port `3000`. Route the bundled Supabase API domain, if used, to `neta-supabase-proxy` port `8000`.
## First Admin
Open `/register` after the stack starts. The first registered user becomes the admin, and public registration is locked after that.
+137 -12
View File
@@ -6,6 +6,7 @@ set -euo pipefail
REPO_URL="${NETA_REPO_URL:-https://github.com/poyrazavsever/neta.git}" REPO_URL="${NETA_REPO_URL:-https://github.com/poyrazavsever/neta.git}"
TARGET_DIR="${NETA_TARGET_DIR:-neta-os}" TARGET_DIR="${NETA_TARGET_DIR:-neta-os}"
INSTALL_MODE="${NETA_INSTALL_MODE:-}"
info() { info() {
printf "\n%s\n" "$1" printf "\n%s\n" "$1"
@@ -89,17 +90,135 @@ prompt_secret_required() {
done done
} }
choose_install_mode() {
if [ -n "$INSTALL_MODE" ]; then
case "$INSTALL_MODE" in
full|full-stack|bundled)
INSTALL_MODE="full-stack"
;;
app|app-only|external)
INSTALL_MODE="app-only"
;;
*)
fail "Invalid NETA_INSTALL_MODE. Use full-stack or app-only."
;;
esac
export INSTALL_MODE
return
fi
echo "Choose install mode:"
echo " 1) full-stack Neta + bundled Supabase/Postgres/Auth/Storage"
echo " 2) app-only Neta app connected to an existing Supabase project"
while true; do
read -r -p "Install mode [full-stack]: " answer
case "${answer:-full-stack}" in
1|full|full-stack|bundled)
INSTALL_MODE="full-stack"
export INSTALL_MODE
return
;;
2|app|app-only|external)
INSTALL_MODE="app-only"
export INSTALL_MODE
return
;;
*)
echo "Please choose full-stack or app-only."
;;
esac
done
}
run_node_script() {
local script="$1"
if command -v node >/dev/null 2>&1; then
node -e "$script"
else
docker run --rm \
-e ROLE="${ROLE:-}" \
-e JWT_SECRET="${JWT_SECRET:-}" \
node:22-alpine node -e "$script"
fi
}
random_secret() {
run_node_script "console.log(require('crypto').randomBytes(32).toString('hex'))"
}
generate_supabase_jwt() {
local role="$1"
local secret="$2"
ROLE="$role" JWT_SECRET="$secret" run_node_script "const crypto=require('crypto'); const b64=(v)=>Buffer.from(v).toString('base64url'); const header=b64(JSON.stringify({alg:'HS256',typ:'JWT'})); const payload=b64(JSON.stringify({iss:'supabase',ref:'neta',role:process.env.ROLE,iat:1700000000,exp:4102444800})); const unsigned=header+'.'+payload; const sig=crypto.createHmac('sha256', process.env.JWT_SECRET).update(unsigned).digest('base64url'); console.log(unsigned+'.'+sig);"
}
write_env_file() { write_env_file() {
cat > .env <<EOF cat > .env <<EOF
NETA_INSTALL_MODE=$INSTALL_MODE
NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
NETA_PORT=$NETA_PORT NETA_PORT=$NETA_PORT
NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
SUPABASE_SERVICE_ROLE_KEY=$SUPABASE_SERVICE_ROLE_KEY SUPABASE_SERVICE_ROLE_KEY=$SUPABASE_SERVICE_ROLE_KEY
EOF EOF
if [ "$INSTALL_MODE" = "full-stack" ]; then
cat >> .env <<EOF
SUPABASE_API_PORT=$SUPABASE_API_PORT
POSTGRES_PORT=$POSTGRES_PORT
POSTGRES_PASSWORD=$POSTGRES_PASSWORD
JWT_SECRET=$JWT_SECRET
JWT_EXPIRY=${JWT_EXPIRY:-3600}
SMTP_ADMIN_EMAIL=${SMTP_ADMIN_EMAIL:-admin@neta.local}
EOF
fi
chmod 600 .env || true chmod 600 .env || true
} }
configure_app_only() {
prompt_optional NEXT_PUBLIC_SITE_URL "Public Neta URL" "http://localhost:3000"
prompt_optional NETA_PORT "Host port for Neta" "3000"
prompt_required NEXT_PUBLIC_SUPABASE_URL "Supabase API URL"
prompt_secret_required NEXT_PUBLIC_SUPABASE_ANON_KEY "Supabase anon key"
prompt_secret_required SUPABASE_SERVICE_ROLE_KEY "Supabase service role key"
}
configure_full_stack() {
prompt_optional NEXT_PUBLIC_SITE_URL "Public Neta URL" "http://localhost:3000"
prompt_optional NETA_PORT "Host port for Neta" "3000"
prompt_optional NEXT_PUBLIC_SUPABASE_URL "Public Supabase API URL" "http://localhost:8000"
prompt_optional SUPABASE_API_PORT "Host port for bundled Supabase API" "8000"
prompt_optional POSTGRES_PORT "Host port for bundled Postgres" "54322"
POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-$(random_secret)}"
JWT_SECRET="${JWT_SECRET:-$(random_secret)}"
NEXT_PUBLIC_SUPABASE_ANON_KEY="${NEXT_PUBLIC_SUPABASE_ANON_KEY:-$(generate_supabase_jwt anon "$JWT_SECRET")}"
SUPABASE_SERVICE_ROLE_KEY="${SUPABASE_SERVICE_ROLE_KEY:-$(generate_supabase_jwt service_role "$JWT_SECRET")}"
export POSTGRES_PASSWORD JWT_SECRET NEXT_PUBLIC_SUPABASE_ANON_KEY SUPABASE_SERVICE_ROLE_KEY
}
run_compose() {
local compose="$1"
if [ "$INSTALL_MODE" = "full-stack" ]; then
info "Validating full-stack Docker Compose configuration"
$compose -f docker-compose.full.yml config >/dev/null
info "Building and starting bundled Neta stack"
$compose -f docker-compose.full.yml up -d --build
else
info "Validating Docker Compose configuration"
$compose config >/dev/null
info "Building and starting Neta"
$compose up -d --build
fi
}
main() { main() {
info "Neta self-host installer" info "Neta self-host installer"
@@ -115,36 +234,42 @@ main() {
git clone "$REPO_URL" "$TARGET_DIR" git clone "$REPO_URL" "$TARGET_DIR"
cd "$TARGET_DIR" cd "$TARGET_DIR"
prompt_optional NEXT_PUBLIC_SITE_URL "Public Neta URL" "http://localhost:3000" choose_install_mode
prompt_optional NETA_PORT "Host port for Neta" "3000"
prompt_required NEXT_PUBLIC_SUPABASE_URL "Supabase API URL" if [ "$INSTALL_MODE" = "full-stack" ]; then
prompt_secret_required NEXT_PUBLIC_SUPABASE_ANON_KEY "Supabase anon key" configure_full_stack
prompt_secret_required SUPABASE_SERVICE_ROLE_KEY "Supabase service role key" else
configure_app_only
fi
write_env_file write_env_file
info "Wrote .env" info "Wrote .env"
if [ "$INSTALL_MODE" = "app-only" ]; then
read -r -p "Apply Neta database migrations now? Requires a direct Postgres DATABASE_URL. [y/N]: " apply_migrations read -r -p "Apply Neta database migrations now? Requires a direct Postgres DATABASE_URL. [y/N]: " apply_migrations
if [ "$apply_migrations" = "y" ] || [ "$apply_migrations" = "Y" ]; then if [ "$apply_migrations" = "y" ] || [ "$apply_migrations" = "Y" ]; then
prompt_secret_required DATABASE_URL "Postgres DATABASE_URL" prompt_secret_required DATABASE_URL "Postgres DATABASE_URL"
DATABASE_URL="$DATABASE_URL" bash ./scripts/apply-migrations.sh DATABASE_URL="$DATABASE_URL" sh ./scripts/apply-migrations.sh
else else
echo "Skipping migrations. Run them later with:" echo "Skipping migrations. Run them later with:"
echo " DATABASE_URL='postgresql://...' bash ./scripts/apply-migrations.sh" echo " DATABASE_URL='postgresql://...' sh ./scripts/apply-migrations.sh"
fi
else
echo "Bundled mode applies migrations automatically through the neta-migrations service."
fi fi
local compose local compose
compose="$(compose_cmd)" compose="$(compose_cmd)"
info "Validating Docker Compose configuration" run_compose "$compose"
$compose config >/dev/null
info "Building and starting Neta"
$compose up -d --build
info "Neta is starting" info "Neta is starting"
echo "Open: $NEXT_PUBLIC_SITE_URL" echo "Open: $NEXT_PUBLIC_SITE_URL"
echo "Create the first admin account at: $NEXT_PUBLIC_SITE_URL/register" echo "Create the first admin account at: $NEXT_PUBLIC_SITE_URL/register"
if [ "$INSTALL_MODE" = "full-stack" ]; then
echo "Bundled Supabase API: $NEXT_PUBLIC_SUPABASE_URL"
echo "Bundled Postgres host port: $POSTGRES_PORT"
fi
} }
main "$@" main "$@"
+3 -1
View File
@@ -5,9 +5,11 @@ export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({ let supabaseResponse = NextResponse.next({
request, request,
}) })
const supabaseUrl =
process.env.SUPABASE_INTERNAL_URL || process.env.NEXT_PUBLIC_SUPABASE_URL!
const supabase = createServerClient( const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!, supabaseUrl,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{ {
cookies: { cookies: {
+3 -1
View File
@@ -3,9 +3,11 @@ import { cookies } from 'next/headers'
export async function createClient() { export async function createClient() {
const cookieStore = await cookies() const cookieStore = await cookies()
const supabaseUrl =
process.env.SUPABASE_INTERNAL_URL || process.env.NEXT_PUBLIC_SUPABASE_URL!
return createServerClient( return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!, supabaseUrl,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{ {
cookies: { cookies: {
+2 -1
View File
@@ -6,7 +6,8 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint ." "lint": "eslint .",
"selfhost:env": "node scripts/generate-full-stack-env.mjs"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/google": "^3.0.80", "@ai-sdk/google": "^3.0.80",
+19 -20
View File
@@ -1,31 +1,19 @@
#!/usr/bin/env bash #!/usr/bin/env sh
set -euo pipefail set -eu
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
if [ -z "${DATABASE_URL:-}" ]; then if [ -z "${DATABASE_URL:-}" ]; then
echo "DATABASE_URL is required." >&2 echo "DATABASE_URL is required." >&2
echo "Example:" >&2 echo "Example:" >&2
echo " DATABASE_URL='postgresql://postgres:password@host:5432/postgres' bash ./scripts/apply-migrations.sh" >&2 echo " DATABASE_URL='postgresql://postgres:password@host:5432/postgres' sh ./scripts/apply-migrations.sh" >&2
exit 1 exit 1
fi fi
SQL_FILES=(
"supabase/schema.sql"
"supabase/migrations/0002_add_freelancer_os_core_tables.sql"
"supabase/migrations/0003_add_project_planning_assets.sql"
"supabase/migrations/0004_add_business_os_tables.sql"
"supabase/migrations/0005_add_advanced_crm_tables.sql"
"supabase/migrations/0006_add_pgvector_and_embeddings.sql"
"supabase/migrations/0007_add_client_portal_tables.sql"
"supabase/migrations/0008_add_project_progress_and_quota.sql"
"supabase/migrations/0009_lock_registration_after_first_admin.sql"
)
run_sql_file() { run_sql_file() {
local file_path="$1" file_path="$1"
local absolute_path="$ROOT_DIR/$file_path" absolute_path="$ROOT_DIR/$file_path"
if [ ! -f "$absolute_path" ]; then if [ ! -f "$absolute_path" ]; then
echo "Missing SQL file: $file_path" >&2 echo "Missing SQL file: $file_path" >&2
@@ -45,8 +33,19 @@ run_sql_file() {
fi fi
} }
for sql_file in "${SQL_FILES[@]}"; do while IFS= read -r sql_file; do
[ -n "$sql_file" ] || continue
run_sql_file "$sql_file" run_sql_file "$sql_file"
done done <<'SQL_FILES'
supabase/schema.sql
supabase/migrations/0002_add_freelancer_os_core_tables.sql
supabase/migrations/0003_add_project_planning_assets.sql
supabase/migrations/0004_add_business_os_tables.sql
supabase/migrations/0005_add_advanced_crm_tables.sql
supabase/migrations/0006_add_pgvector_and_embeddings.sql
supabase/migrations/0007_add_client_portal_tables.sql
supabase/migrations/0008_add_project_progress_and_quota.sql
supabase/migrations/0009_lock_registration_after_first_admin.sql
SQL_FILES
echo "All Neta migrations were applied." echo "All Neta migrations were applied."
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env node
import crypto from "node:crypto";
const jwtSecret = process.env.JWT_SECRET || randomSecret();
const values = {
NETA_INSTALL_MODE: "full-stack",
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000",
NETA_PORT: process.env.NETA_PORT || "3000",
NEXT_PUBLIC_SUPABASE_URL:
process.env.NEXT_PUBLIC_SUPABASE_URL || "http://localhost:8000",
SUPABASE_API_PORT: process.env.SUPABASE_API_PORT || "8000",
NEXT_PUBLIC_SUPABASE_ANON_KEY:
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || generateSupabaseJwt("anon", jwtSecret),
SUPABASE_SERVICE_ROLE_KEY:
process.env.SUPABASE_SERVICE_ROLE_KEY ||
generateSupabaseJwt("service_role", jwtSecret),
JWT_SECRET: jwtSecret,
POSTGRES_PASSWORD: process.env.POSTGRES_PASSWORD || randomSecret(),
POSTGRES_PORT: process.env.POSTGRES_PORT || "54322",
JWT_EXPIRY: process.env.JWT_EXPIRY || "3600",
SMTP_ADMIN_EMAIL: process.env.SMTP_ADMIN_EMAIL || "admin@neta.local",
};
for (const [key, value] of Object.entries(values)) {
console.log(`${key}=${value}`);
}
function randomSecret() {
return crypto.randomBytes(32).toString("hex");
}
function generateSupabaseJwt(role, secret) {
const header = base64UrlJson({ alg: "HS256", typ: "JWT" });
const payload = base64UrlJson({
iss: "supabase",
ref: "neta",
role,
iat: 1700000000,
exp: 4102444800,
});
const unsigned = `${header}.${payload}`;
const signature = crypto
.createHmac("sha256", secret)
.update(unsigned)
.digest("base64url");
return `${unsigned}.${signature}`;
}
function base64UrlJson(value) {
return Buffer.from(JSON.stringify(value)).toString("base64url");
}