From 2f2bdeaabfc77b64cd2fee9729805da608c8b4b6 Mon Sep 17 00:00:00 2001 From: Poyraz Avsever Date: Sat, 13 Jun 2026 23:02:30 +0300 Subject: [PATCH] refactor: enhance backup and restore scripts, add health check functionality, and update README with operational instructions --- .gitignore | 1 + README.md | 21 +++++ deploy/supabase/db/init.sh | 2 + docker-compose.full.yml | 9 +- docs/deployment/self-hosting.md | 40 ++++++++ package.json | 5 +- scripts/selfhost-backup.sh | 58 ++++++++++++ scripts/selfhost-doctor.sh | 161 ++++++++++++++++++++++++++++++++ scripts/selfhost-restore.sh | 73 +++++++++++++++ 9 files changed, 367 insertions(+), 3 deletions(-) create mode 100644 scripts/selfhost-backup.sh create mode 100644 scripts/selfhost-doctor.sh create mode 100644 scripts/selfhost-restore.sh diff --git a/.gitignore b/.gitignore index 6bfa344..b6f7fde 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules out dist build +backups/ .env* !.env.example !.env.full.example diff --git a/README.md b/README.md index a93895c..fe910ba 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,27 @@ Docker Compose intentionally fails fast when required Supabase environment value 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. +### Operations + +For full-stack installs, run health diagnostics and backups from the repository root: + +```bash +sh ./scripts/selfhost-doctor.sh +sh ./scripts/selfhost-backup.sh +``` + +To also verify real Auth signup and password login, run: + +```bash +NETA_DOCTOR_AUTH_SMOKE=1 sh ./scripts/selfhost-doctor.sh +``` + +Restore requires an existing backup directory and explicit confirmation: + +```bash +sh ./scripts/selfhost-restore.sh ./backups/20260101T120000Z +``` + ### 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. diff --git a/deploy/supabase/db/init.sh b/deploy/supabase/db/init.sh index c772095..14d8b37 100644 --- a/deploy/supabase/db/init.sh +++ b/deploy/supabase/db/init.sh @@ -12,6 +12,8 @@ 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; +alter role supabase_auth_admin set search_path = auth, public; +alter role supabase_storage_admin set search_path = storage, public; create schema if not exists auth authorization supabase_auth_admin; create schema if not exists storage authorization supabase_storage_admin; diff --git a/docker-compose.full.yml b/docker-compose.full.yml index 41c210a..9caaf70 100644 --- a/docker-compose.full.yml +++ b/docker-compose.full.yml @@ -13,7 +13,7 @@ services: neta-migrations: condition: service_completed_successfully neta-supabase-proxy: - condition: service_started + condition: service_healthy ports: - "${NETA_PORT:-3000}:3000" environment: @@ -39,9 +39,14 @@ services: - "${SUPABASE_API_PORT:-8000}:8000" volumes: - ./deploy/supabase/nginx/default.conf.template:/etc/nginx/templates/default.conf.template:ro + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8000/health >/dev/null"] + interval: 10s + timeout: 5s + retries: 12 neta-auth: - image: supabase/gotrue:v2.177.0 + image: supabase/gotrue:v2.189.0 container_name: neta-auth restart: unless-stopped depends_on: diff --git a/docs/deployment/self-hosting.md b/docs/deployment/self-hosting.md index 442d292..55beabc 100644 --- a/docs/deployment/self-hosting.md +++ b/docs/deployment/self-hosting.md @@ -42,6 +42,18 @@ Start: docker compose -f docker-compose.full.yml up -d --build ``` +Check the running stack: + +```bash +sh ./scripts/selfhost-doctor.sh +``` + +To run a real Auth signup/login smoke test, enable the optional check: + +```bash +NETA_DOCTOR_AUTH_SMOKE=1 sh ./scripts/selfhost-doctor.sh +``` + ## App-Only Mode Use this when Neta connects to an existing Supabase or Supabase-compatible backend. @@ -84,6 +96,34 @@ Create a Compose app from this repository. Use `docker-compose.full.yml` for ful Route the Neta domain to `neta-web` port `3000`. Route the bundled Supabase API domain, if used, to `neta-supabase-proxy` port `8000`. +## Backup And Restore + +Full-stack mode stores the database in the `neta-db-data` Docker volume and uploaded files in the `neta-storage-data` Docker volume. + +Create a backup: + +```bash +sh ./scripts/selfhost-backup.sh +``` + +The backup is written to `./backups//` and contains: + +- `postgres.dump`: custom-format Postgres dump +- `storage.tar.gz`: local storage archive +- `manifest.txt`: backup metadata + +Restore a backup: + +```bash +sh ./scripts/selfhost-restore.sh ./backups/20260101T120000Z +``` + +Set `NETA_RESTORE_FORCE=1` for non-interactive restores: + +```bash +NETA_RESTORE_FORCE=1 sh ./scripts/selfhost-restore.sh ./backups/20260101T120000Z +``` + ## First Admin Open `/register` after the stack starts. The first registered user becomes the admin, and public registration is locked after that. diff --git a/package.json b/package.json index 2f8c31e..ff58feb 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,10 @@ "build": "next build", "start": "next start", "lint": "eslint .", - "selfhost:env": "node scripts/generate-full-stack-env.mjs" + "selfhost:env": "node scripts/generate-full-stack-env.mjs", + "selfhost:doctor": "sh scripts/selfhost-doctor.sh", + "selfhost:backup": "sh scripts/selfhost-backup.sh", + "selfhost:restore": "sh scripts/selfhost-restore.sh" }, "dependencies": { "@ai-sdk/google": "^3.0.80", diff --git a/scripts/selfhost-backup.sh b/scripts/selfhost-backup.sh new file mode 100644 index 0000000..25fc366 --- /dev/null +++ b/scripts/selfhost-backup.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env sh + +set -eu + +BACKUP_ROOT="${NETA_BACKUP_DIR:-./backups}" +STAMP="$(date -u +%Y%m%dT%H%M%SZ)" +BACKUP_DIR="$BACKUP_ROOT/$STAMP" + +info() { + printf "%s\n" "$1" +} + +fail() { + echo "Error: $1" >&2 + exit 1 +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || fail "$1 is required." +} + +require_container() { + name="$1" + docker inspect "$name" >/dev/null 2>&1 || fail "Container '$name' was not found." +} + +require_command docker +require_command tar +require_container neta-db +require_container neta-storage + +mkdir -p "$BACKUP_DIR" +BACKUP_DIR_ABS="$(cd "$BACKUP_DIR" && pwd)" + +info "Creating Postgres backup" +docker exec neta-db pg_dump \ + -U postgres \ + -d postgres \ + --format=custom \ + --clean \ + --if-exists \ + --file=/tmp/neta-postgres.dump +docker cp neta-db:/tmp/neta-postgres.dump "$BACKUP_DIR_ABS/postgres.dump" +docker exec neta-db rm -f /tmp/neta-postgres.dump + +info "Creating Storage backup" +docker cp neta-storage:/var/lib/storage "$BACKUP_DIR_ABS/storage" +tar -czf "$BACKUP_DIR_ABS/storage.tar.gz" -C "$BACKUP_DIR_ABS/storage" . +rm -rf "$BACKUP_DIR_ABS/storage" + +cat > "$BACKUP_DIR_ABS/manifest.txt" </dev/null | grep -q true; then + pass "$name is running" + else + fail "$name is not running" + fi +} + +check_container_exit_zero() { + name="$1" + if [ "$(docker inspect -f '{{.State.ExitCode}}' "$name" 2>/dev/null)" = "0" ]; then + pass "$name exited successfully" + else + fail "$name did not exit successfully" + fi +} + +check_container_health() { + name="$1" + status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$name" 2>/dev/null || true)" + if [ "$status" = "healthy" ]; then + pass "$name health is $status" + else + fail "$name health is $status" + fi +} + +check_exec() { + label="$1" + shift + if "$@" >/dev/null 2>&1; then + pass "$label" + else + fail "$label" + fi +} + +if ! command -v docker >/dev/null 2>&1; then + echo "fail docker is not installed" + exit 1 +fi + +if ! docker info >/dev/null 2>&1; then + echo "fail docker daemon is not reachable" + exit 1 +fi + +for container in neta-db neta-auth neta-rest neta-storage neta-supabase-proxy neta-web; do + check_container_running "$container" +done + +check_container_exit_zero neta-migrations +check_container_health neta-db +check_container_health neta-web +check_container_health neta-supabase-proxy + +check_exec "web health endpoint returns JSON" \ + docker exec neta-web wget -qO- http://127.0.0.1:3000/api/health + +check_exec "supabase proxy health endpoint responds" \ + docker exec neta-supabase-proxy wget -qO- http://127.0.0.1:8000/health + +check_exec "auth settings endpoint responds through proxy" \ + docker exec neta-supabase-proxy wget -qO- http://127.0.0.1:8000/auth/v1/settings + +check_exec "database contains Neta tables" \ + docker exec neta-db sh -c "psql -U postgres -d postgres -tAc \"select to_regclass('public.profiles') is not null and to_regclass('public.projects') is not null\" | grep -q t" + +if [ "${NETA_DOCTOR_AUTH_SMOKE:-0}" = "1" ]; then + profile_count="$(docker exec neta-db sh -c "psql -U postgres -d postgres -tAc \"select count(*) from public.profiles\"" 2>/dev/null | tr -d "[:space:]")" + + if [ "$profile_count" = "0" ]; then + check_exec "auth signup/login smoke succeeds" \ + docker exec neta-web node -e ' +const apiUrl = process.env.SUPABASE_INTERNAL_URL || process.env.NEXT_PUBLIC_SUPABASE_URL; +const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; +const email = `doctor-${Date.now()}@neta.local`; +const password = "Test123456!"; +const headers = { + apikey: anonKey, + authorization: `Bearer ${anonKey}`, + "content-type": "application/json", +}; + +async function request(path, body) { + const response = await fetch(`${apiUrl}${path}`, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + if (!response.ok) { + throw new Error(`${path} returned ${response.status}: ${await response.text()}`); + } + return response.json(); +} + +(async () => { + const signup = await request("/auth/v1/signup", { email, password }); + const login = await request("/auth/v1/token?grant_type=password", { email, password }); + if (!signup.user?.id || !login.access_token || signup.user.id !== login.user?.id) { + throw new Error("signup/login response did not include a matching user and token"); + } +})().catch((error) => { + console.error(error.message); + process.exit(1); +}); +' + else + check_exec "auth password endpoint rejects invalid login without database errors" \ + docker exec neta-web node -e ' +const apiUrl = process.env.SUPABASE_INTERNAL_URL || process.env.NEXT_PUBLIC_SUPABASE_URL; +const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; +const email = `doctor-${Date.now()}@neta.local`; +const headers = { + apikey: anonKey, + authorization: `Bearer ${anonKey}`, + "content-type": "application/json", +}; + +(async () => { + const response = await fetch(`${apiUrl}/auth/v1/token?grant_type=password`, { + method: "POST", + headers, + body: JSON.stringify({ email, password: "NotARealPassword123!" }), + }); + if (response.status >= 500) { + throw new Error(`password endpoint returned ${response.status}: ${await response.text()}`); + } + if (response.ok) { + throw new Error("invalid credentials unexpectedly succeeded"); + } +})().catch((error) => { + console.error(error.message); + process.exit(1); +}); +' + fi +fi + +if [ "$FAILURES" -gt 0 ]; then + printf "\n%d check(s) failed.\n" "$FAILURES" + exit 1 +fi + +printf "\nAll self-host checks passed.\n" diff --git a/scripts/selfhost-restore.sh b/scripts/selfhost-restore.sh new file mode 100644 index 0000000..9d6c014 --- /dev/null +++ b/scripts/selfhost-restore.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env sh + +set -eu + +BACKUP_DIR="${1:-}" + +info() { + printf "%s\n" "$1" +} + +fail() { + echo "Error: $1" >&2 + exit 1 +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || fail "$1 is required." +} + +require_container() { + name="$1" + docker inspect "$name" >/dev/null 2>&1 || fail "Container '$name' was not found." +} + +if [ -z "$BACKUP_DIR" ]; then + fail "Backup directory is required. Usage: sh ./scripts/selfhost-restore.sh ./backups/20260101T120000Z" +fi + +require_command docker +require_command tar +require_container neta-db +require_container neta-storage + +[ -f "$BACKUP_DIR/postgres.dump" ] || fail "Missing $BACKUP_DIR/postgres.dump" +[ -f "$BACKUP_DIR/storage.tar.gz" ] || fail "Missing $BACKUP_DIR/storage.tar.gz" + +BACKUP_DIR_ABS="$(cd "$BACKUP_DIR" && pwd)" +STORAGE_RESTORE_DIR="$BACKUP_DIR_ABS/.storage-restore" + +if [ "${NETA_RESTORE_FORCE:-}" != "1" ]; then + echo "This will overwrite the current full-stack Neta database and local storage." + printf "Type RESTORE to continue: " + read answer + [ "$answer" = "RESTORE" ] || fail "Restore cancelled." +fi + +info "Stopping application services" +docker stop neta-web neta-supabase-proxy neta-storage neta-rest neta-auth >/dev/null 2>&1 || true + +info "Restoring Postgres" +docker cp "$BACKUP_DIR_ABS/postgres.dump" neta-db:/tmp/neta-postgres-restore.dump +docker exec neta-db pg_restore \ + -U postgres \ + -d postgres \ + --clean \ + --if-exists \ + /tmp/neta-postgres-restore.dump +docker exec neta-db rm -f /tmp/neta-postgres-restore.dump + +info "Restoring Storage" +docker start neta-storage >/dev/null +rm -rf "$STORAGE_RESTORE_DIR" +mkdir -p "$STORAGE_RESTORE_DIR" +tar -xzf "$BACKUP_DIR_ABS/storage.tar.gz" -C "$STORAGE_RESTORE_DIR" +docker exec neta-storage sh -c "rm -rf /var/lib/storage/* /var/lib/storage/.[!.]* /var/lib/storage/..?* 2>/dev/null || true; mkdir -p /var/lib/storage" +docker cp "$STORAGE_RESTORE_DIR/." neta-storage:/var/lib/storage +rm -rf "$STORAGE_RESTORE_DIR" +docker restart neta-storage >/dev/null + +info "Starting application services" +docker start neta-auth neta-rest neta-supabase-proxy neta-web >/dev/null + +info "Restore completed."