refactor: enhance backup and restore scripts, add health check functionality, and update README with operational instructions
This commit is contained in:
@@ -3,6 +3,7 @@ node_modules
|
|||||||
out
|
out
|
||||||
dist
|
dist
|
||||||
build
|
build
|
||||||
|
backups/
|
||||||
.env*
|
.env*
|
||||||
!.env.example
|
!.env.example
|
||||||
!.env.full.example
|
!.env.full.example
|
||||||
|
|||||||
@@ -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.
|
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
|
### 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.
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ create role supabase_storage_admin noinherit login password '$POSTGRES_PASSWORD'
|
|||||||
grant anon, authenticated, service_role to authenticator;
|
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_auth_admin;
|
||||||
grant all privileges on database postgres to supabase_storage_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 auth authorization supabase_auth_admin;
|
||||||
create schema if not exists storage authorization supabase_storage_admin;
|
create schema if not exists storage authorization supabase_storage_admin;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ services:
|
|||||||
neta-migrations:
|
neta-migrations:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
neta-supabase-proxy:
|
neta-supabase-proxy:
|
||||||
condition: service_started
|
condition: service_healthy
|
||||||
ports:
|
ports:
|
||||||
- "${NETA_PORT:-3000}:3000"
|
- "${NETA_PORT:-3000}:3000"
|
||||||
environment:
|
environment:
|
||||||
@@ -39,9 +39,14 @@ services:
|
|||||||
- "${SUPABASE_API_PORT:-8000}:8000"
|
- "${SUPABASE_API_PORT:-8000}:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./deploy/supabase/nginx/default.conf.template:/etc/nginx/templates/default.conf.template:ro
|
- ./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:
|
neta-auth:
|
||||||
image: supabase/gotrue:v2.177.0
|
image: supabase/gotrue:v2.189.0
|
||||||
container_name: neta-auth
|
container_name: neta-auth
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -42,6 +42,18 @@ Start:
|
|||||||
docker compose -f docker-compose.full.yml up -d --build
|
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
|
## App-Only Mode
|
||||||
|
|
||||||
Use this when Neta connects to an existing Supabase or Supabase-compatible backend.
|
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`.
|
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/<timestamp>/` 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
|
## First Admin
|
||||||
|
|
||||||
Open `/register` after the stack starts. The first registered user becomes the admin, and public registration is locked after that.
|
Open `/register` after the stack starts. The first registered user becomes the admin, and public registration is locked after that.
|
||||||
|
|||||||
+4
-1
@@ -7,7 +7,10 @@
|
|||||||
"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"
|
"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": {
|
"dependencies": {
|
||||||
"@ai-sdk/google": "^3.0.80",
|
"@ai-sdk/google": "^3.0.80",
|
||||||
|
|||||||
@@ -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" <<EOF
|
||||||
|
neta_backup_version=1
|
||||||
|
created_at_utc=$STAMP
|
||||||
|
postgres_dump=postgres.dump
|
||||||
|
storage_archive=storage.tar.gz
|
||||||
|
EOF
|
||||||
|
|
||||||
|
info "Backup created: $BACKUP_DIR_ABS"
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
FAILURES=0
|
||||||
|
|
||||||
|
pass() {
|
||||||
|
printf "ok %s\n" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf "fail %s\n" "$1"
|
||||||
|
FAILURES=$((FAILURES + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
check_container_running() {
|
||||||
|
name="$1"
|
||||||
|
if docker inspect -f '{{.State.Running}}' "$name" 2>/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"
|
||||||
@@ -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."
|
||||||
Reference in New Issue
Block a user