chore: import upstream snapshot with attribution
Build and Push Docker Images / create_manifest (web, surfsense-web, , cpu) (push) Has been cancelled
Build and Push Docker Images / finalize_release (push) Has been cancelled
Obsidian Plugin Lint / lint (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_web, cpu, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-24.04-arm, linux/arm64, arm64, , runner, false, cpu) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_web, cpu, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-latest, linux/amd64, amd64, , runner, false, cpu) (push) Has been cancelled
Build and Push Docker Images / compute_version (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cpu, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, , production, false, cpu) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cpu, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, , production, false, cpu) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cu126, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, -cuda126, production, true, cuda126) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cu126, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, -cuda126, production, true, cuda126) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cu128, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, -cuda, production, true, cuda) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cu128, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, -cuda, production, true, cuda) (push) Has been cancelled
Build and Push Docker Images / verify_digests (push) Has been cancelled
Build and Push Docker Images / create_manifest (backend, surfsense-backend, , cpu) (push) Has been cancelled
Build and Push Docker Images / create_manifest (backend, surfsense-backend, -cuda, cuda) (push) Has been cancelled
Build and Push Docker Images / create_manifest (backend, surfsense-backend, -cuda126, cuda126) (push) Has been cancelled
Build and Push Docker Images / create_manifest (web, surfsense-web, , cpu) (push) Has been cancelled
Build and Push Docker Images / finalize_release (push) Has been cancelled
Obsidian Plugin Lint / lint (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_web, cpu, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-24.04-arm, linux/arm64, arm64, , runner, false, cpu) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_web, cpu, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-latest, linux/amd64, amd64, , runner, false, cpu) (push) Has been cancelled
Build and Push Docker Images / compute_version (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cpu, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, , production, false, cpu) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cpu, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, , production, false, cpu) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cu126, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, -cuda126, production, true, cuda126) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cu126, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, -cuda126, production, true, cuda126) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cu128, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, -cuda, production, true, cuda) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cu128, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, -cuda, production, true, cuda) (push) Has been cancelled
Build and Push Docker Images / verify_digests (push) Has been cancelled
Build and Push Docker Images / create_manifest (backend, surfsense-backend, , cpu) (push) Has been cancelled
Build and Push Docker Images / create_manifest (backend, surfsense-backend, -cuda, cuda) (push) Has been cancelled
Build and Push Docker Images / create_manifest (backend, surfsense-backend, -cuda126, cuda126) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,545 @@
|
||||
# ==============================================================================
|
||||
# SurfSense Docker Configuration
|
||||
# ==============================================================================
|
||||
# Database, Redis, and internal service wiring are handled automatically.
|
||||
# ==============================================================================
|
||||
|
||||
# SurfSense version (use "latest" or a specific version like "0.0.14")
|
||||
SURFSENSE_VERSION=latest
|
||||
|
||||
# Image variant: empty = CPU (default), "cuda" = CUDA 12.8, "cuda126" = CUDA 12.6.
|
||||
# GPU acceleration also requires the NVIDIA Container Toolkit on the host and
|
||||
# the GPU overlay in COMPOSE_FILE. Linux/macOS use ":"; Windows uses ";".
|
||||
# Example Linux/macOS: COMPOSE_FILE=docker-compose.yml:docker-compose.gpu.yml
|
||||
# Example Windows: COMPOSE_FILE=docker-compose.yml;docker-compose.gpu.yml
|
||||
# Use "cuda126" for older NVIDIA driver stacks; use "cuda" for newer drivers.
|
||||
SURFSENSE_VARIANT=
|
||||
# COMPOSE_FILE=docker-compose.yml:docker-compose.gpu.yml
|
||||
# SURFSENSE_GPU_COUNT=1
|
||||
|
||||
# Deployment environment: dev or production
|
||||
SURFSENSE_ENV=production
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Core Settings
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
# REQUIRED: Generate a secret key with: openssl rand -base64 32
|
||||
SECRET_KEY=replace_me_with_a_random_string
|
||||
|
||||
# Auth type: LOCAL (email/password) or GOOGLE (OAuth)
|
||||
AUTH_TYPE=LOCAL
|
||||
|
||||
# Cloud only: set COOKIE_DOMAIN=.surfsense.com so api., zero., and app
|
||||
# subdomains all receive the same first-party session cookie. Leave empty for
|
||||
# self-hosted Docker where Caddy serves a single origin.
|
||||
# COOKIE_DOMAIN=
|
||||
|
||||
# Deployment mode: self-hosted enables local filesystem connectors; cloud hides them.
|
||||
DEPLOYMENT_MODE=self-hosted
|
||||
|
||||
# Allow new user registrations (TRUE or FALSE)
|
||||
# REGISTRATION_ENABLED=TRUE
|
||||
|
||||
# Document parsing service: DOCLING, UNSTRUCTURED, or LLAMACLOUD
|
||||
ETL_SERVICE=DOCLING
|
||||
|
||||
# Embedding model for vector search
|
||||
# Local: sentence-transformers/all-MiniLM-L6-v2
|
||||
# OpenAI: openai://text-embedding-ada-002 (set OPENAI_API_KEY below)
|
||||
# Cohere: cohere://embed-english-light-v3.0 (set COHERE_API_KEY below)
|
||||
EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# How You Access SurfSense
|
||||
# ------------------------------------------------------------------------------
|
||||
# One public URL. Browser traffic stays same-origin and Caddy routes internally.
|
||||
SURFSENSE_PUBLIC_URL=http://localhost:3929
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Public Ports
|
||||
# ------------------------------------------------------------------------------
|
||||
# Production Docker exposes only Caddy to your machine. Caddy then routes
|
||||
# frontend, backend, and zero-cache traffic internally.
|
||||
#
|
||||
# Local default: LISTEN_HTTP_PORT=3929
|
||||
# Domain default: LISTEN_HTTP_PORT=80 and LISTEN_HTTPS_PORT=443
|
||||
LISTEN_HTTP_PORT=3929
|
||||
LISTEN_HTTPS_PORT=443
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Custom Domain / HTTPS
|
||||
# ------------------------------------------------------------------------------
|
||||
# Leave SURFSENSE_SITE_ADDRESS as :80 for local HTTP.
|
||||
# Set it to your domain to enable automatic HTTPS:
|
||||
# SURFSENSE_SITE_ADDRESS=surf.example.com
|
||||
# CERT_EMAIL=you@example.com
|
||||
SURFSENSE_SITE_ADDRESS=:80
|
||||
CERT_EMAIL=
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Advanced Reverse Proxy Settings
|
||||
# ------------------------------------------------------------------------------
|
||||
# Usually do not change these. They are for custom certificate setup, CDNs/load
|
||||
# balancers, trusted proxy IPs, or changing upload limits.
|
||||
#
|
||||
# CERT_ACME_CA=https://acme-v02.api.letsencrypt.org/directory
|
||||
# CERT_ACME_DNS=
|
||||
# If a CDN/load balancer sits in front of Caddy, narrow this to that proxy's CIDRs.
|
||||
# TRUSTED_PROXIES=0.0.0.0/0
|
||||
# SURFSENSE_MAX_BODY_SIZE=5GB
|
||||
#
|
||||
# Browser API and Zero URLs are same-origin relative behind bundled Caddy.
|
||||
# Next.js server-side calls use Docker DNS through SURFSENSE_BACKEND_INTERNAL_URL
|
||||
# set internally by docker-compose.yml. Usually do not override it.
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Zero-cache (real-time sync)
|
||||
# ------------------------------------------------------------------------------
|
||||
# Defaults work out of the box for Docker deployments.
|
||||
# Change ZERO_ADMIN_PASSWORD for security in production.
|
||||
|
||||
# ZERO_ADMIN_PASSWORD=surfsense-zero-admin
|
||||
|
||||
# Publication restricting which tables zero-cache replicates from Postgres.
|
||||
# Created automatically by Alembic migration 116.
|
||||
# Only change this if you manage publications manually.
|
||||
# ZERO_APP_PUBLICATIONS=zero_publication
|
||||
|
||||
# Keep Zero's documented halt safety net enabled. If replication halts, Zero
|
||||
# can wipe and re-sync its local SQLite replica without touching Postgres.
|
||||
# ZERO_AUTO_RESET=true
|
||||
|
||||
# Sync worker tuning. zero-cache defaults ZERO_NUM_SYNC_WORKERS to the number
|
||||
# of CPU cores, which can exceed the connection pool limits on high-core machines.
|
||||
# Each sync worker needs at least 1 connection from both the UPSTREAM and CVR pools.
|
||||
# Keep ZERO_UPSTREAM_MAX_CONNS and ZERO_CVR_MAX_CONNS greater than or equal to
|
||||
# ZERO_NUM_SYNC_WORKERS.
|
||||
# Default of 4 workers is sufficient for self-hosted / personal use.
|
||||
# ZERO_NUM_SYNC_WORKERS=4
|
||||
# ZERO_UPSTREAM_MAX_CONNS=20
|
||||
# ZERO_CVR_MAX_CONNS=30
|
||||
|
||||
# Full override for the Zero → Postgres connection URLs.
|
||||
# Leave commented out to use the Docker-managed `db` container (default).
|
||||
# ZERO_UPSTREAM_DB=postgresql://surfsense:surfsense@db:5432/surfsense
|
||||
# ZERO_CVR_DB=postgresql://surfsense:surfsense@db:5432/surfsense
|
||||
# ZERO_CHANGE_DB=postgresql://surfsense:surfsense@db:5432/surfsense
|
||||
|
||||
# ZERO_QUERY_URL: where zero-cache forwards query requests for resolution.
|
||||
# ZERO_MUTATE_URL: required by zero-cache when auth tokens are used, even though
|
||||
# SurfSense does not use Zero mutators. Setting both URLs tells zero-cache to
|
||||
# skip its own JWT verification and let the app endpoints handle auth instead.
|
||||
# The mutate endpoint is a no-op that returns an empty response.
|
||||
# Default: Docker service networking (http://frontend:3000/api/zero/...).
|
||||
# Override when running the frontend outside Docker:
|
||||
# ZERO_QUERY_URL=http://host.docker.internal:3000/api/zero/query
|
||||
# ZERO_MUTATE_URL=http://host.docker.internal:3000/api/zero/mutate
|
||||
# Override for custom domain only when zero-cache is not in the bundled Docker network:
|
||||
# ZERO_QUERY_URL=https://surf.example.com/api/zero/query
|
||||
# ZERO_MUTATE_URL=https://surf.example.com/api/zero/mutate
|
||||
# ZERO_QUERY_URL=http://frontend:3000/api/zero/query
|
||||
# ZERO_MUTATE_URL=http://frontend:3000/api/zero/mutate
|
||||
#
|
||||
# Forward browser session cookies from zero-cache to the query route. Keep this
|
||||
# enabled before switching the web app to cookie-only auth.
|
||||
# ZERO_QUERY_FORWARD_COOKIES=true
|
||||
#
|
||||
# Optional shared secret for the zero-cache -> /api/zero/query hop. Set the same
|
||||
# value on zero-cache and the frontend. When unset, the query route accepts the
|
||||
# request for backward-compatible rollout.
|
||||
# ZERO_QUERY_API_KEY=
|
||||
#
|
||||
# Bounds for auth revocation and RBAC membership changes on already-open sockets.
|
||||
# ZERO_AUTH_REVALIDATE_INTERVAL_SECONDS=60
|
||||
# ZERO_AUTH_RETRANSFORM_INTERVAL_SECONDS=60
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Database (defaults work out of the box, change for security)
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
# DB_USER=surfsense
|
||||
# DB_PASSWORD=surfsense
|
||||
# DB_NAME=surfsense
|
||||
# DB_HOST=db
|
||||
# DB_PORT=5432
|
||||
|
||||
# SSL mode for database connections: disable, require, verify-ca, verify-full
|
||||
# DB_SSLMODE=disable
|
||||
|
||||
# Full DATABASE_URL override. When set, this takes precedence over the individual
|
||||
# DB_USER / DB_PASSWORD / DB_NAME / DB_HOST / DB_PORT settings above.
|
||||
# Use this for managed databases (AWS RDS, GCP Cloud SQL, Supabase, etc.)
|
||||
# DATABASE_URL=postgresql+asyncpg://user:password@your-rds-host:5432/surfsense?sslmode=require
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Redis (defaults work out of the box)
|
||||
# ------------------------------------------------------------------------------
|
||||
# Full Redis URL override for Celery broker, result backend, and app cache.
|
||||
# Use this for managed Redis (AWS ElastiCache, Redis Cloud, etc.)
|
||||
# Supports auth: redis://:password@host:port/0
|
||||
# Supports TLS: rediss://:password@host:6380/0
|
||||
# REDIS_URL=redis://redis:6379/0
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Stripe (unified credit wallet, disabled by default)
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
# Set TRUE to allow users to buy credit packs via Stripe Checkout. $1 buys
|
||||
# 1_000_000 micro-USD of credit; both ETL page processing and premium turns
|
||||
# debit this balance at the actual per-call provider cost from LiteLLM.
|
||||
STRIPE_CREDIT_BUYING_ENABLED=FALSE
|
||||
# STRIPE_SECRET_KEY=sk_test_...
|
||||
# STRIPE_WEBHOOK_SECRET=whsec_...
|
||||
# STRIPE_CREDIT_PRICE_ID=price_...
|
||||
# STRIPE_CREDIT_MICROS_PER_UNIT=1000000
|
||||
# STRIPE_RECONCILIATION_INTERVAL=10m
|
||||
# STRIPE_RECONCILIATION_LOOKBACK_MINUTES=10
|
||||
# STRIPE_RECONCILIATION_BATCH_SIZE=100
|
||||
|
||||
# Auto-reload: top up via a saved Stripe card when the balance drops below
|
||||
# the user-chosen threshold. Off by default.
|
||||
# AUTO_RELOAD_ENABLED=FALSE
|
||||
# AUTO_RELOAD_MIN_AMOUNT_MICROS=1000000
|
||||
# AUTO_RELOAD_COOLDOWN_MINUTES=10
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# TTS & STT (Text-to-Speech / Speech-to-Text)
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
# Local Kokoro TTS (default) or LiteLLM provider
|
||||
TTS_SERVICE=local/kokoro
|
||||
# TTS_SERVICE_API_KEY=
|
||||
# TTS_SERVICE_API_BASE=
|
||||
|
||||
# Local Faster-Whisper STT: local/MODEL_SIZE (tiny, base, small, medium, large-v3)
|
||||
STT_SERVICE=local/base
|
||||
# Or use LiteLLM: openai/whisper-1
|
||||
# STT_SERVICE_API_KEY=
|
||||
# STT_SERVICE_API_BASE=
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Rerankers (optional, disabled by default)
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
# RERANKERS_ENABLED=TRUE
|
||||
# RERANKERS_MODEL_NAME=ms-marco-MiniLM-L-12-v2
|
||||
# RERANKERS_MODEL_TYPE=flashrank
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Google OAuth (only if AUTH_TYPE=GOOGLE)
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
# GOOGLE_OAUTH_CLIENT_ID=
|
||||
# GOOGLE_OAUTH_CLIENT_SECRET=
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Connector OAuth Keys (uncomment connectors you want to use)
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
# -- Google Connectors --
|
||||
# GOOGLE_CALENDAR_REDIRECT_URI=http://localhost:3929/api/v1/auth/google/calendar/connector/callback
|
||||
# GOOGLE_GMAIL_REDIRECT_URI=http://localhost:3929/api/v1/auth/google/gmail/connector/callback
|
||||
# GOOGLE_DRIVE_REDIRECT_URI=http://localhost:3929/api/v1/auth/google/drive/connector/callback
|
||||
|
||||
# -- Notion --
|
||||
# NOTION_CLIENT_ID=
|
||||
# NOTION_CLIENT_SECRET=
|
||||
# NOTION_REDIRECT_URI=http://localhost:3929/api/v1/auth/notion/connector/callback
|
||||
|
||||
# -- Slack --
|
||||
# SLACK_CLIENT_ID=
|
||||
# SLACK_CLIENT_SECRET=
|
||||
# SLACK_REDIRECT_URI=http://localhost:3929/api/v1/auth/slack/connector/callback
|
||||
|
||||
# -- Discord --
|
||||
# DISCORD_CLIENT_ID=
|
||||
# DISCORD_CLIENT_SECRET=
|
||||
# DISCORD_REDIRECT_URI=http://localhost:3929/api/v1/auth/discord/connector/callback
|
||||
# DISCORD_BOT_TOKEN=
|
||||
|
||||
# -- Atlassian (Jira & Confluence) --
|
||||
# ATLASSIAN_CLIENT_ID=
|
||||
# ATLASSIAN_CLIENT_SECRET=
|
||||
# JIRA_REDIRECT_URI=http://localhost:3929/api/v1/auth/jira/connector/callback
|
||||
# CONFLUENCE_REDIRECT_URI=http://localhost:3929/api/v1/auth/confluence/connector/callback
|
||||
|
||||
# -- Linear --
|
||||
# LINEAR_CLIENT_ID=
|
||||
# LINEAR_CLIENT_SECRET=
|
||||
# LINEAR_REDIRECT_URI=http://localhost:3929/api/v1/auth/linear/connector/callback
|
||||
|
||||
# -- ClickUp --
|
||||
# CLICKUP_CLIENT_ID=
|
||||
# CLICKUP_CLIENT_SECRET=
|
||||
# CLICKUP_REDIRECT_URI=http://localhost:3929/api/v1/auth/clickup/connector/callback
|
||||
|
||||
# -- Airtable --
|
||||
# AIRTABLE_CLIENT_ID=
|
||||
# AIRTABLE_CLIENT_SECRET=
|
||||
# AIRTABLE_REDIRECT_URI=http://localhost:3929/api/v1/auth/airtable/connector/callback
|
||||
|
||||
# -- Microsoft OAuth (Teams & OneDrive) --
|
||||
# MICROSOFT_CLIENT_ID=
|
||||
# MICROSOFT_CLIENT_SECRET=
|
||||
# TEAMS_REDIRECT_URI=http://localhost:3929/api/v1/auth/teams/connector/callback
|
||||
# ONEDRIVE_REDIRECT_URI=http://localhost:3929/api/v1/auth/onedrive/connector/callback
|
||||
|
||||
# -- Dropbox --
|
||||
# DROPBOX_APP_KEY=
|
||||
# DROPBOX_APP_SECRET=
|
||||
# DROPBOX_REDIRECT_URI=http://localhost:3929/api/v1/auth/dropbox/connector/callback
|
||||
|
||||
# -- Composio --
|
||||
# COMPOSIO_API_KEY=
|
||||
# COMPOSIO_ENABLED=TRUE
|
||||
# COMPOSIO_REDIRECT_URI=http://localhost:3929/api/v1/auth/composio/connector/callback
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Messaging Channels (optional)
|
||||
# ------------------------------------------------------------------------------
|
||||
# Configure only the external chat channels you want to use.
|
||||
# GATEWAY_ENABLED=TRUE
|
||||
|
||||
# -- Telegram --
|
||||
# TELEGRAM_SHARED_BOT_TOKEN=
|
||||
# TELEGRAM_SHARED_BOT_USERNAME=
|
||||
# TELEGRAM_WEBHOOK_SECRET=
|
||||
# GATEWAY_BASE_URL=http://localhost:3929
|
||||
# GATEWAY_TELEGRAM_INTAKE_MODE=webhook
|
||||
|
||||
# -- WhatsApp --
|
||||
# GATEWAY_WHATSAPP_INTAKE_MODE=disabled
|
||||
# WHATSAPP_SHARED_BUSINESS_TOKEN=
|
||||
# WHATSAPP_SHARED_PHONE_NUMBER_ID=
|
||||
# WHATSAPP_SHARED_DISPLAY_PHONE_NUMBER=
|
||||
# WHATSAPP_SHARED_WABA_ID=
|
||||
# WHATSAPP_GRAPH_API_VERSION=v25.0
|
||||
# WHATSAPP_WEBHOOK_VERIFY_TOKEN=
|
||||
# WHATSAPP_WEBHOOK_APP_SECRET=
|
||||
# WHATSAPP_BRIDGE_URL=http://whatsapp-bridge:9929
|
||||
|
||||
# -- Slack --
|
||||
# Uses SLACK_CLIENT_ID and SLACK_CLIENT_SECRET from the Slack connector section.
|
||||
#
|
||||
# GATEWAY_SLACK_ENABLED=FALSE
|
||||
# GATEWAY_SLACK_SIGNING_SECRET=
|
||||
# GATEWAY_SLACK_REDIRECT_URI=http://localhost:3929/api/v1/gateway/slack/callback
|
||||
|
||||
# -- Discord --
|
||||
# Uses DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET, and DISCORD_BOT_TOKEN from the
|
||||
# Discord connector section.
|
||||
#
|
||||
# GATEWAY_DISCORD_ENABLED=FALSE
|
||||
# GATEWAY_DISCORD_REDIRECT_URI=http://localhost:3929/api/v1/gateway/discord/callback
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Daytona Sandbox (optional cloud code execution for the deep agent)
|
||||
# ------------------------------------------------------------------------------
|
||||
# Set DAYTONA_SANDBOX_ENABLED=TRUE and provide credentials to give the agent
|
||||
# an isolated code execution environment via the Daytona cloud API.
|
||||
# DAYTONA_SANDBOX_ENABLED=FALSE
|
||||
# DAYTONA_API_KEY=
|
||||
# DAYTONA_API_URL=https://app.daytona.io/api
|
||||
# DAYTONA_TARGET=us
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# External API Keys (optional)
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
# Unstructured (if ETL_SERVICE=UNSTRUCTURED)
|
||||
# UNSTRUCTURED_API_KEY=
|
||||
|
||||
# LlamaCloud (if ETL_SERVICE=LLAMACLOUD)
|
||||
# LLAMA_CLOUD_API_KEY=
|
||||
# Optional: Azure Document Intelligence accelerator (used with LLAMACLOUD)
|
||||
# AZURE_DI_ENDPOINT=https://your-resource.cognitiveservices.azure.com/
|
||||
# AZURE_DI_KEY=
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Observability (optional)
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
# LANGSMITH_TRACING=true
|
||||
# LANGSMITH_ENDPOINT=https://api.smith.langchain.com
|
||||
# LANGSMITH_API_KEY=
|
||||
# LANGSMITH_PROJECT=surfsense
|
||||
|
||||
# OpenTelemetry traces and metrics.
|
||||
# Enable the collector with: docker compose --profile observability up -d
|
||||
# SURFSENSE_ENABLE_OTEL=true
|
||||
# OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
|
||||
# OTEL_EXPORTER_OTLP_PROTOCOL=grpc
|
||||
# OTEL_RESOURCE_ATTRIBUTES=service.namespace=surfsense
|
||||
#
|
||||
# Emergency kill switch.
|
||||
# OTEL_SDK_DISABLED=true
|
||||
#
|
||||
# Grafana Cloud OTLP credentials. These are used only by the collector container.
|
||||
# GRAFANA_CLOUD_OTLP_ENDPOINT=https://otlp-gateway-<region>.grafana.net/otlp
|
||||
# GRAFANA_CLOUD_INSTANCE_ID=
|
||||
# GRAFANA_CLOUD_API_KEY=
|
||||
#
|
||||
# Optional host port overrides for the bundled OTel Collector. Only change
|
||||
# these if the host already uses 4317/4318/13133; backend containers still use
|
||||
# the internal Docker endpoint above.
|
||||
# OTEL_GRPC_PORT=4317
|
||||
# OTEL_HTTP_PORT=4318
|
||||
# OTEL_HEALTH_PORT=13133
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Advanced (optional)
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
# New-chat agent feature flags
|
||||
SURFSENSE_ENABLE_CONTEXT_EDITING=true
|
||||
SURFSENSE_ENABLE_COMPACTION_V2=true
|
||||
SURFSENSE_ENABLE_RETRY_AFTER=true
|
||||
SURFSENSE_ENABLE_MODEL_FALLBACK=false
|
||||
SURFSENSE_ENABLE_MODEL_CALL_LIMIT=true
|
||||
SURFSENSE_ENABLE_TOOL_CALL_LIMIT=true
|
||||
SURFSENSE_ENABLE_TOOL_CALL_REPAIR=true
|
||||
SURFSENSE_ENABLE_BUSY_MUTEX=true
|
||||
SURFSENSE_ENABLE_SKILLS=true
|
||||
SURFSENSE_ENABLE_SPECIALIZED_SUBAGENTS=true
|
||||
SURFSENSE_ENABLE_ACTION_LOG=true
|
||||
SURFSENSE_ENABLE_REVERT_ROUTE=true
|
||||
SURFSENSE_ENABLE_PERMISSION=true
|
||||
SURFSENSE_ENABLE_DOOM_LOOP=true
|
||||
|
||||
# Periodic connector sync interval (default: 5m)
|
||||
# SCHEDULE_CHECKER_INTERVAL=5m
|
||||
|
||||
# JWT token lifetimes
|
||||
# ACCESS_TOKEN_LIFETIME_SECONDS=86400
|
||||
# REFRESH_TOKEN_LIFETIME_SECONDS=1209600
|
||||
|
||||
# Unified credit wallet starting balance for new users, in micro-USD
|
||||
# (default: $5). Funds both ETL page processing and premium model calls,
|
||||
# debited at the actual per-call provider cost reported by LiteLLM.
|
||||
# DEFAULT_CREDIT_MICROS_BALANCE=5000000
|
||||
|
||||
# Debit the credit wallet for ETL page processing. Default FALSE keeps ETL
|
||||
# effectively free for self-hosted installs. 1 page == MICROS_PER_PAGE
|
||||
# micro-USD ($0.001); premium ETL mode is 10x.
|
||||
# ETL_CREDIT_BILLING_ENABLED=FALSE
|
||||
# MICROS_PER_PAGE=1000
|
||||
|
||||
# Debit the credit wallet per *successful* web crawl. Default FALSE keeps
|
||||
# crawling effectively free for self-hosted installs. Price is config-driven:
|
||||
# WEB_CRAWL_MICROS_PER_SUCCESS = round(USD_per_1000_crawls * 1_000)
|
||||
# 2000 == $2/1000 (default) | 1000 == $1/1000. Captcha solves bill as a
|
||||
# separate per-attempt unit (independent flag).
|
||||
# WEB_CRAWL_CREDIT_BILLING_ENABLED=FALSE
|
||||
# WEB_CRAWL_MICROS_PER_SUCCESS=2000
|
||||
# WEB_CRAWL_CAPTCHA_BILLING_ENABLED=FALSE
|
||||
# WEB_CRAWL_CAPTCHA_MICROS_PER_SOLVE=3000
|
||||
|
||||
# Debit the credit wallet per *item returned* by the platform-native scrapers
|
||||
# (Reddit, Google Search, Google Maps, YouTube). Default FALSE keeps scraping
|
||||
# effectively free for self-hosted installs. Each rate is micro-USD per item,
|
||||
# config-driven: <KEY> = round(USD_per_1000_items * 1_000). Defaults sit
|
||||
# at/above Apify's first-party actor rates (we charge no subscription tiers,
|
||||
# start fees, or separate proxy/compute billing). google_maps.scrape is
|
||||
# dual-metered (places + attached reviews).
|
||||
# PLATFORM_SCRAPE_BILLING_ENABLED=FALSE
|
||||
# REDDIT_SCRAPE_MICROS_PER_ITEM=3500
|
||||
# GOOGLE_SEARCH_MICROS_PER_SERP=5500
|
||||
# GOOGLE_MAPS_MICROS_PER_PLACE=3500
|
||||
# GOOGLE_MAPS_MICROS_PER_REVIEW=1500
|
||||
# YOUTUBE_MICROS_PER_VIDEO=2500
|
||||
# YOUTUBE_MICROS_PER_COMMENT=1500
|
||||
|
||||
# Safety ceiling on per-call premium reservation, in micro-USD ($1.00 default).
|
||||
# QUOTA_MAX_RESERVE_MICROS=1000000
|
||||
|
||||
# Per-image reservation for POST /image-generations, in micro-USD ($0.05 default).
|
||||
# QUOTA_DEFAULT_IMAGE_RESERVE_MICROS=50000
|
||||
|
||||
# Per-podcast reservation for the podcast Celery task ($0.20 default).
|
||||
# QUOTA_DEFAULT_PODCAST_RESERVE_MICROS=200000
|
||||
|
||||
# Per-video-presentation reservation for the video Celery task ($1.00 default).
|
||||
# Override path bypasses QUOTA_MAX_RESERVE_MICROS clamp. Raise with care.
|
||||
# QUOTA_DEFAULT_VIDEO_PRESENTATION_RESERVE_MICROS=1000000
|
||||
|
||||
# No-login (anonymous) mode. Public users can chat without an account
|
||||
# Set TRUE to enable /free pages and anonymous chat API
|
||||
NOLOGIN_MODE_ENABLED=FALSE
|
||||
# ANON_TOKEN_LIMIT=1000000
|
||||
# ANON_TOKEN_WARNING_THRESHOLD=800000
|
||||
# ANON_TOKEN_QUOTA_TTL_DAYS=30
|
||||
# ANON_MAX_UPLOAD_SIZE_MB=5
|
||||
# QUOTA_MAX_RESERVE_PER_CALL=8000
|
||||
# Abuse prevention: max concurrent anonymous streams per IP
|
||||
# ANON_MAX_CONCURRENT_STREAMS=2
|
||||
# Number of chat requests per IP before Turnstile CAPTCHA is required
|
||||
# ANON_CAPTCHA_REQUEST_THRESHOLD=5
|
||||
# Cloudflare Turnstile CAPTCHA (https://dash.cloudflare.com/ -> Turnstile)
|
||||
# TURNSTILE_ENABLED=FALSE
|
||||
# TURNSTILE_SECRET_KEY=
|
||||
|
||||
# Connector indexing lock TTL in seconds (default: 28800 = 8 hours)
|
||||
# CONNECTOR_INDEXING_LOCK_TTL_SECONDS=28800
|
||||
|
||||
# Proxy provider selection: "custom" (default) or "dataimpulse".
|
||||
# PROXY_PROVIDER=custom
|
||||
|
||||
# Proxy endpoint(s), shared across providers. PROXY_URL is a single full URL used
|
||||
# by every provider (for "dataimpulse", country is a "__cr.<country>" username
|
||||
# suffix the provider parses for geoip). PROXY_URLS is a comma-separated pool the
|
||||
# "custom" provider rotates client-side. Leave unset to disable proxying.
|
||||
# PROXY_URL=http://user:pass@host:port
|
||||
# PROXY_URLS=http://user:pass@host1:port,http://user:pass@host2:port
|
||||
|
||||
# Captcha solving — last-resort bypass tier via captchatools. Only fires on the
|
||||
# stealth browser tier when a sitekey is detected AND the flag is TRUE.
|
||||
# Cloudflare Turnstile is already solved free in-framework. Off by default.
|
||||
# NOTE: automated solving may violate a target site's ToS — opt-in, public
|
||||
# data only. See surfsense_backend/.env.example for the full option docs.
|
||||
# CAPTCHA_SOLVING_ENABLED=FALSE
|
||||
# CAPTCHA_SOLVER_PROVIDER=capsolver
|
||||
# CAPTCHA_SOLVER_API_KEY=
|
||||
# CAPTCHA_MAX_ATTEMPTS_PER_URL=1
|
||||
# CAPTCHA_SOLVE_TIMEOUT_S=120
|
||||
# CAPTCHA_TYPE_DEFAULT=v2
|
||||
# CAPTCHA_V3_MIN_SCORE=0.7
|
||||
# CAPTCHA_V3_ACTION=verify
|
||||
|
||||
# Stealth hardening levers on the stealth browser tier. Defaults preserve
|
||||
# current behavior; see surfsense_backend/.env.example for per-flag docs.
|
||||
# CRAWL_GEOIP_MATCH_ENABLED=FALSE
|
||||
# CRAWL_BLOCK_WEBRTC=TRUE
|
||||
# CRAWL_HIDE_CANVAS=FALSE
|
||||
# CRAWL_GOOGLE_SEARCH_REFERER=TRUE
|
||||
# CRAWL_DNS_OVER_HTTPS=FALSE
|
||||
|
||||
# ==============================================================================
|
||||
# DEV / DEPS-ONLY COMPOSE OVERRIDES
|
||||
# These are only needed for docker-compose.dev.yml or docker-compose.deps-only.yml.
|
||||
# Production Docker exposes Caddy only; raw app ports below do not affect
|
||||
# docker-compose.yml.
|
||||
# ==============================================================================
|
||||
|
||||
# -- pgAdmin (database GUI, dev/deps-only only) --
|
||||
# PGADMIN_PORT=5050
|
||||
# PGADMIN_DEFAULT_EMAIL=admin@surfsense.com
|
||||
# PGADMIN_DEFAULT_PASSWORD=surfsense
|
||||
|
||||
# -- Redis exposed port (dev/deps-only only; Redis is internal-only in prod) --
|
||||
# REDIS_PORT=6379
|
||||
|
||||
# -- WhatsApp bridge exposed port (dev/hybrid only; prod keeps it Docker-internal) --
|
||||
# WHATSAPP_BRIDGE_PORT=9929
|
||||
|
||||
# -- Raw app ports (dev/deps-only only; prod exposes Caddy instead) --
|
||||
# BACKEND_PORT=8000
|
||||
# FRONTEND_PORT=3000
|
||||
# ZERO_CACHE_PORT=4848
|
||||
|
||||
# -- Frontend runtime flags (prod and dev compose) --
|
||||
# The frontend reads these at request time in Docker; no NEXT_PUBLIC_* rebuild
|
||||
# or startup substitution is required.
|
||||
# AUTH_TYPE=LOCAL
|
||||
# ETL_SERVICE=DOCLING
|
||||
# DEPLOYMENT_MODE=self-hosted
|
||||
@@ -0,0 +1,150 @@
|
||||
# =============================================================================
|
||||
# SurfSense — Dependencies only (no backend / frontend / Celery images)
|
||||
# =============================================================================
|
||||
# Postgres, Redis, pgAdmin, Zero — run API + Next + Celery on the host.
|
||||
# Celery is not Dockerized here: use `uv run` from surfsense_backend/ (no extra
|
||||
# backend image build just for workers).
|
||||
#
|
||||
# From repo root (SurfSense/):
|
||||
# docker compose -f docker/docker-compose.deps-only.yml up -d
|
||||
#
|
||||
# Compose variable substitution uses `docker/.env` (copy from .env.example).
|
||||
# Bind mounts use ./postgresql.conf in this directory.
|
||||
#
|
||||
# Local Celery (from surfsense_backend/, after Redis is up):
|
||||
# uv run celery -A celery_worker.celery_app worker --loglevel=info --concurrency=1 --pool=solo --queues=surfsense,surfsense.connectors
|
||||
# uv run celery -A celery_worker.celery_app beat --loglevel=info
|
||||
#
|
||||
# Host setup:
|
||||
# - Backend .env: DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/surfsense
|
||||
# - Backend .env: CELERY_BROKER_URL / REDIS_APP_URL → redis://localhost:6379/0
|
||||
# - Web .env: NEXT_PUBLIC_ZERO_CACHE_URL=http://localhost:${ZERO_CACHE_PORT:-4848}
|
||||
#
|
||||
# IMPORTANT — schema migrations:
|
||||
# This compose file does NOT build the backend image and therefore cannot
|
||||
# run a `migrations` service. You MUST run alembic on the host before
|
||||
# bringing zero-cache up, or zero-cache will crash-loop with
|
||||
# `Unknown or invalid publications. Specified: [zero_publication]`.
|
||||
#
|
||||
# First-time / after-pull:
|
||||
# cd surfsense_backend && uv run alembic upgrade head
|
||||
#
|
||||
# The other compose files (docker-compose.yml, docker-compose.dev.yml)
|
||||
# handle this automatically via a dedicated `migrations` service.
|
||||
# =============================================================================
|
||||
|
||||
name: surfsense-deps
|
||||
|
||||
services:
|
||||
db:
|
||||
image: pgvector/pgvector:pg17
|
||||
ports:
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./postgresql.conf:/etc/postgresql/postgresql.conf:ro
|
||||
environment:
|
||||
- POSTGRES_USER=${DB_USER:-postgres}
|
||||
- POSTGRES_PASSWORD=${DB_PASSWORD:-postgres}
|
||||
- POSTGRES_DB=${DB_NAME:-surfsense}
|
||||
command: postgres -c config_file=/etc/postgresql/postgresql.conf
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-surfsense}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
pgadmin:
|
||||
image: dpage/pgadmin4
|
||||
ports:
|
||||
- "${PGADMIN_PORT:-5050}:80"
|
||||
environment:
|
||||
- PGADMIN_DEFAULT_EMAIL=${PGADMIN_DEFAULT_EMAIL:-admin@surfsense.com}
|
||||
- PGADMIN_DEFAULT_PASSWORD=${PGADMIN_DEFAULT_PASSWORD:-surfsense}
|
||||
volumes:
|
||||
- pgadmin_data:/var/lib/pgadmin
|
||||
depends_on:
|
||||
- db
|
||||
|
||||
redis:
|
||||
image: redis:8-alpine
|
||||
ports:
|
||||
- "${REDIS_PORT:-6379}:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
command: redis-server --appendonly yes
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# NOTE: zero-cache requires the `zero_publication` Postgres publication to
|
||||
# exist before it starts. In this deps-only stack there is no backend
|
||||
# container to run migrations, so you must run `uv run alembic upgrade head`
|
||||
# from `surfsense_backend/` on the host BEFORE `docker compose up -d`.
|
||||
zero-cache:
|
||||
image: rocicorp/zero:1.6.0
|
||||
ports:
|
||||
- "${ZERO_CACHE_PORT:-4848}:4848"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- ZERO_UPSTREAM_DB=postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@db:5432/${DB_NAME:-surfsense}?sslmode=${DB_SSLMODE:-disable}
|
||||
- ZERO_CVR_DB=postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@db:5432/${DB_NAME:-surfsense}?sslmode=${DB_SSLMODE:-disable}
|
||||
- ZERO_CHANGE_DB=postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@db:5432/${DB_NAME:-surfsense}?sslmode=${DB_SSLMODE:-disable}
|
||||
- ZERO_REPLICA_FILE=/data/zero.db
|
||||
- ZERO_ADMIN_PASSWORD=${ZERO_ADMIN_PASSWORD:-surfsense-zero-admin}
|
||||
- ZERO_APP_PUBLICATIONS=${ZERO_APP_PUBLICATIONS:-zero_publication}
|
||||
- ZERO_AUTO_RESET=${ZERO_AUTO_RESET:-true}
|
||||
- ZERO_NUM_SYNC_WORKERS=${ZERO_NUM_SYNC_WORKERS:-4}
|
||||
- ZERO_UPSTREAM_MAX_CONNS=${ZERO_UPSTREAM_MAX_CONNS:-20}
|
||||
- ZERO_CVR_MAX_CONNS=${ZERO_CVR_MAX_CONNS:-30}
|
||||
- ZERO_QUERY_URL=${ZERO_QUERY_URL:-http://host.docker.internal:3000/api/zero/query}
|
||||
- ZERO_MUTATE_URL=${ZERO_MUTATE_URL:-http://host.docker.internal:3000/api/zero/mutate}
|
||||
- ZERO_QUERY_FORWARD_COOKIES=${ZERO_QUERY_FORWARD_COOKIES:-true}
|
||||
- ZERO_QUERY_API_KEY=${ZERO_QUERY_API_KEY:-}
|
||||
- ZERO_AUTH_REVALIDATE_INTERVAL_SECONDS=${ZERO_AUTH_REVALIDATE_INTERVAL_SECONDS:-60}
|
||||
- ZERO_AUTH_RETRANSFORM_INTERVAL_SECONDS=${ZERO_AUTH_RETRANSFORM_INTERVAL_SECONDS:-60}
|
||||
volumes:
|
||||
- zero_cache_data:/data
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 300s
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:4848/keepalive"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 600s
|
||||
|
||||
# OPTIONAL — Azurite emulates Azure Blob Storage for testing the Azure
|
||||
# original-file backend. The default filesystem backend needs none of this.
|
||||
# To exercise it, set in surfsense_backend/.env:
|
||||
# FILE_STORAGE_BACKEND=azure
|
||||
# AZURE_STORAGE_CONTAINER=surfsense-documents
|
||||
# AZURE_STORAGE_CONNECTION_STRING=DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://localhost:${AZURITE_BLOB_PORT:-10000}/devstoreaccount1;
|
||||
# The backend creates blobs on upload; create the container once first
|
||||
# (Azure CLI / Storage Explorer), then upload a document.
|
||||
azurite:
|
||||
image: mcr.microsoft.com/azure-storage/azurite:3.33.0
|
||||
command: azurite-blob --blobHost 0.0.0.0 --blobPort 10000
|
||||
ports:
|
||||
- "${AZURITE_BLOB_PORT:-10000}:10000"
|
||||
volumes:
|
||||
- azurite_data:/data
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
name: surfsense-deps-postgres
|
||||
pgadmin_data:
|
||||
name: surfsense-deps-pgadmin
|
||||
redis_data:
|
||||
name: surfsense-deps-redis
|
||||
zero_cache_data:
|
||||
name: surfsense-deps-zero-cache
|
||||
azurite_data:
|
||||
name: surfsense-deps-azurite
|
||||
@@ -0,0 +1,275 @@
|
||||
# =============================================================================
|
||||
# SurfSense — Development Docker Compose
|
||||
# =============================================================================
|
||||
# Usage (from repo root):
|
||||
# docker compose -f docker/docker-compose.dev.yml up --build
|
||||
#
|
||||
# This file builds from source and includes dev tools like pgAdmin.
|
||||
# For production with prebuilt images, use docker/docker-compose.yml instead.
|
||||
# =============================================================================
|
||||
|
||||
name: surfsense-dev
|
||||
|
||||
x-backend-build: &backend-build
|
||||
context: ../surfsense_backend
|
||||
args:
|
||||
EMBEDDING_MODEL: ${EMBEDDING_MODEL:-sentence-transformers/all-MiniLM-L6-v2}
|
||||
|
||||
services:
|
||||
db:
|
||||
image: pgvector/pgvector:pg17
|
||||
ports:
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./postgresql.conf:/etc/postgresql/postgresql.conf:ro
|
||||
environment:
|
||||
- POSTGRES_USER=${DB_USER:-postgres}
|
||||
- POSTGRES_PASSWORD=${DB_PASSWORD:-postgres}
|
||||
- POSTGRES_DB=${DB_NAME:-surfsense}
|
||||
command: postgres -c config_file=/etc/postgresql/postgresql.conf
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-surfsense}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# Short-lived schema runner; see docker/docker-compose.yml `migrations`
|
||||
# service for the full rationale. Builds from the same backend context as
|
||||
# the dev backend/celery services.
|
||||
migrations:
|
||||
build: *backend-build
|
||||
env_file:
|
||||
- ../surfsense_backend/.env
|
||||
environment:
|
||||
- DATABASE_URL=${DATABASE_URL:-postgresql+asyncpg://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}}
|
||||
- PYTHONPATH=/app
|
||||
- SERVICE_ROLE=migrate
|
||||
- MIGRATION_TIMEOUT=${MIGRATION_TIMEOUT:-900}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
restart: "no"
|
||||
|
||||
pgadmin:
|
||||
image: dpage/pgadmin4
|
||||
ports:
|
||||
- "${PGADMIN_PORT:-5050}:80"
|
||||
environment:
|
||||
- PGADMIN_DEFAULT_EMAIL=${PGADMIN_DEFAULT_EMAIL:-admin@surfsense.com}
|
||||
- PGADMIN_DEFAULT_PASSWORD=${PGADMIN_DEFAULT_PASSWORD:-surfsense}
|
||||
volumes:
|
||||
- pgadmin_data:/var/lib/pgadmin
|
||||
depends_on:
|
||||
- db
|
||||
|
||||
redis:
|
||||
image: redis:8-alpine
|
||||
ports:
|
||||
- "${REDIS_PORT:-6379}:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
command: redis-server --appendonly yes
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
otel-lgtm:
|
||||
image: grafana/otel-lgtm:latest
|
||||
ports:
|
||||
- "${OTEL_GRPC_PORT:-4317}:4317"
|
||||
- "${OTEL_HTTP_PORT:-4318}:4318"
|
||||
- "${OTEL_GRAFANA_PORT:-3001}:3000"
|
||||
- "${OTEL_TEMPO_PORT:-3200}:3200"
|
||||
restart: unless-stopped
|
||||
|
||||
backend:
|
||||
build: *backend-build
|
||||
ports:
|
||||
- "${BACKEND_PORT:-8000}:8000"
|
||||
volumes:
|
||||
- ../surfsense_backend/app:/app/app
|
||||
- shared_temp:/shared_tmp
|
||||
- object_store:/app/.local_object_store
|
||||
env_file:
|
||||
- ../surfsense_backend/.env
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
environment:
|
||||
- DATABASE_URL=${DATABASE_URL:-postgresql+asyncpg://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}}
|
||||
- CELERY_BROKER_URL=${REDIS_URL:-redis://redis:6379/0}
|
||||
- CELERY_RESULT_BACKEND=${REDIS_URL:-redis://redis:6379/0}
|
||||
- REDIS_APP_URL=${REDIS_URL:-redis://redis:6379/0}
|
||||
- CELERY_TASK_DEFAULT_QUEUE=surfsense
|
||||
- PYTHONPATH=/app
|
||||
- UVICORN_LOOP=asyncio
|
||||
- UNSTRUCTURED_HAS_PATCHED_LOOP=1
|
||||
- FILE_STORAGE_LOCAL_PATH=/app/.local_object_store
|
||||
- LANGCHAIN_TRACING_V2=false
|
||||
- LANGSMITH_TRACING=false
|
||||
- AUTH_TYPE=${AUTH_TYPE:-LOCAL}
|
||||
- NEXT_FRONTEND_URL=${NEXT_FRONTEND_URL:-http://localhost:3000}
|
||||
- WHATSAPP_BRIDGE_URL=${WHATSAPP_BRIDGE_URL:-http://whatsapp-bridge:9929}
|
||||
# Daytona Sandbox – uncomment and set credentials to enable cloud code execution
|
||||
# - DAYTONA_SANDBOX_ENABLED=TRUE
|
||||
# - DAYTONA_API_KEY=${DAYTONA_API_KEY:-}
|
||||
# - DAYTONA_API_URL=${DAYTONA_API_URL:-https://app.daytona.io/api}
|
||||
# - DAYTONA_TARGET=${DAYTONA_TARGET:-us}
|
||||
- SERVICE_ROLE=api
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
migrations:
|
||||
condition: service_completed_successfully
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/ready"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 200s
|
||||
|
||||
whatsapp-bridge:
|
||||
build: ../surfsense_backend/scripts/whatsapp-bridge
|
||||
profiles:
|
||||
- whatsapp
|
||||
ports:
|
||||
- "127.0.0.1:${WHATSAPP_BRIDGE_PORT:-9929}:9929"
|
||||
volumes:
|
||||
- whatsapp_sessions:/data/sessions
|
||||
environment:
|
||||
- PORT=9929
|
||||
- WHATSAPP_MODE=${WHATSAPP_MODE:-self-chat}
|
||||
- WHATSAPP_SESSION_DIR=/data/sessions
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:9929/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
celery_worker:
|
||||
build: *backend-build
|
||||
volumes:
|
||||
- ../surfsense_backend/app:/app/app
|
||||
- shared_temp:/shared_tmp
|
||||
- object_store:/app/.local_object_store
|
||||
env_file:
|
||||
- ../surfsense_backend/.env
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
environment:
|
||||
- DATABASE_URL=${DATABASE_URL:-postgresql+asyncpg://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}}
|
||||
- CELERY_BROKER_URL=${REDIS_URL:-redis://redis:6379/0}
|
||||
- CELERY_RESULT_BACKEND=${REDIS_URL:-redis://redis:6379/0}
|
||||
- REDIS_APP_URL=${REDIS_URL:-redis://redis:6379/0}
|
||||
- CELERY_TASK_DEFAULT_QUEUE=surfsense
|
||||
- PYTHONPATH=/app
|
||||
- FILE_STORAGE_LOCAL_PATH=/app/.local_object_store
|
||||
- SERVICE_ROLE=worker
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
migrations:
|
||||
condition: service_completed_successfully
|
||||
backend:
|
||||
condition: service_healthy
|
||||
|
||||
celery_beat:
|
||||
build: *backend-build
|
||||
env_file:
|
||||
- ../surfsense_backend/.env
|
||||
environment:
|
||||
- DATABASE_URL=${DATABASE_URL:-postgresql+asyncpg://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}}
|
||||
- CELERY_BROKER_URL=${REDIS_URL:-redis://redis:6379/0}
|
||||
- CELERY_RESULT_BACKEND=${REDIS_URL:-redis://redis:6379/0}
|
||||
- CELERY_TASK_DEFAULT_QUEUE=surfsense
|
||||
- PYTHONPATH=/app
|
||||
- SERVICE_ROLE=beat
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
migrations:
|
||||
condition: service_completed_successfully
|
||||
celery_worker:
|
||||
condition: service_started
|
||||
|
||||
zero-cache:
|
||||
image: rocicorp/zero:1.6.0
|
||||
ports:
|
||||
- "${ZERO_CACHE_PORT:-4848}:4848"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
migrations:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
- ZERO_UPSTREAM_DB=${ZERO_UPSTREAM_DB:-postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}?sslmode=${DB_SSLMODE:-disable}}
|
||||
- ZERO_CVR_DB=${ZERO_CVR_DB:-postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}?sslmode=${DB_SSLMODE:-disable}}
|
||||
- ZERO_CHANGE_DB=${ZERO_CHANGE_DB:-postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}?sslmode=${DB_SSLMODE:-disable}}
|
||||
- ZERO_REPLICA_FILE=/data/zero.db
|
||||
- ZERO_ADMIN_PASSWORD=${ZERO_ADMIN_PASSWORD:-surfsense-zero-admin}
|
||||
- ZERO_APP_PUBLICATIONS=${ZERO_APP_PUBLICATIONS:-zero_publication}
|
||||
- ZERO_AUTO_RESET=${ZERO_AUTO_RESET:-true}
|
||||
- ZERO_NUM_SYNC_WORKERS=${ZERO_NUM_SYNC_WORKERS:-4}
|
||||
- ZERO_UPSTREAM_MAX_CONNS=${ZERO_UPSTREAM_MAX_CONNS:-20}
|
||||
- ZERO_CVR_MAX_CONNS=${ZERO_CVR_MAX_CONNS:-30}
|
||||
- ZERO_QUERY_URL=${ZERO_QUERY_URL:-http://frontend:3000/api/zero/query}
|
||||
- ZERO_MUTATE_URL=${ZERO_MUTATE_URL:-http://frontend:3000/api/zero/mutate}
|
||||
- ZERO_QUERY_FORWARD_COOKIES=${ZERO_QUERY_FORWARD_COOKIES:-true}
|
||||
- ZERO_QUERY_API_KEY=${ZERO_QUERY_API_KEY:-}
|
||||
- ZERO_AUTH_REVALIDATE_INTERVAL_SECONDS=${ZERO_AUTH_REVALIDATE_INTERVAL_SECONDS:-60}
|
||||
- ZERO_AUTH_RETRANSFORM_INTERVAL_SECONDS=${ZERO_AUTH_RETRANSFORM_INTERVAL_SECONDS:-60}
|
||||
volumes:
|
||||
- zero_cache_data:/data
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 300s
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:4848/keepalive"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 600s
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ../surfsense_web
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-3000}:3000"
|
||||
env_file:
|
||||
- ../surfsense_web/.env
|
||||
environment:
|
||||
AUTH_TYPE: ${AUTH_TYPE:-LOCAL}
|
||||
ETL_SERVICE: ${ETL_SERVICE:-DOCLING}
|
||||
DEPLOYMENT_MODE: ${DEPLOYMENT_MODE:-self-hosted}
|
||||
SURFSENSE_BACKEND_INTERNAL_URL: http://backend:8000
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
zero-cache:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
name: surfsense-dev-postgres
|
||||
pgadmin_data:
|
||||
name: surfsense-dev-pgadmin
|
||||
redis_data:
|
||||
name: surfsense-dev-redis
|
||||
shared_temp:
|
||||
name: surfsense-dev-shared-temp
|
||||
object_store:
|
||||
name: surfsense-dev-object-store
|
||||
zero_cache_data:
|
||||
name: surfsense-dev-zero-cache
|
||||
whatsapp_sessions:
|
||||
name: surfsense-dev-whatsapp-sessions
|
||||
@@ -0,0 +1,181 @@
|
||||
# =============================================================================
|
||||
# SurfSense — E2E Docker Compose stack
|
||||
# =============================================================================
|
||||
# Hermetic backend stack for Playwright E2E tests:
|
||||
# - db / redis on an internal-only network (no internet egress)
|
||||
# - backend (FastAPI) joins the internal network AND a separate ingress
|
||||
# bridge so the host runner can reach :8000
|
||||
# - celery_worker on the internal network only — zero egress surface
|
||||
#
|
||||
# The backend image is built from surfsense_backend/Dockerfile target=e2e,
|
||||
# which adds tests/ via the `tests-source` additional context (tests/ is
|
||||
# excluded from the main context by .dockerignore so production never ships
|
||||
# test fakes). See surfsense_backend/Dockerfile for stage layout.
|
||||
#
|
||||
# Usage from repo root:
|
||||
# docker compose -f docker/docker-compose.e2e.yml up -d --build --wait
|
||||
# curl -X POST http://localhost:8000/auth/register ...
|
||||
# ( run Playwright on host, pointing at localhost:8000 + localhost:3000 )
|
||||
# docker compose -f docker/docker-compose.e2e.yml down -v
|
||||
# =============================================================================
|
||||
|
||||
name: surfsense-e2e
|
||||
|
||||
x-backend-env: &backend-env
|
||||
DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/surfsense_e2e
|
||||
CELERY_BROKER_URL: redis://redis:6379/0
|
||||
CELERY_RESULT_BACKEND: redis://redis:6379/0
|
||||
REDIS_APP_URL: redis://redis:6379/0
|
||||
CELERY_TASK_DEFAULT_QUEUE: surfsense
|
||||
SECRET_KEY: ci-test-secret-key-not-for-production
|
||||
AUTH_TYPE: LOCAL
|
||||
REGISTRATION_ENABLED: "TRUE"
|
||||
ETL_SERVICE: DOCLING
|
||||
EMBEDDING_MODEL: sentence-transformers/all-MiniLM-L6-v2
|
||||
NEXT_FRONTEND_URL: http://host.docker.internal:3000
|
||||
# Sentinel keys — fakes never read them; turns leaked real calls into 401s.
|
||||
COMPOSIO_API_KEY: e2e-deny-real-call-sentinel
|
||||
COMPOSIO_ENABLED: "TRUE"
|
||||
OPENAI_API_KEY: e2e-deny-real-call-sentinel
|
||||
ANTHROPIC_API_KEY: e2e-deny-real-call-sentinel
|
||||
LITELLM_API_KEY: e2e-deny-real-call-sentinel
|
||||
MICROSOFT_CLIENT_ID: fake-microsoft-client-id
|
||||
MICROSOFT_CLIENT_SECRET: fake-microsoft-client-secret
|
||||
ONEDRIVE_REDIRECT_URI: http://localhost:8000/api/v1/auth/onedrive/connector/callback
|
||||
DROPBOX_APP_KEY: fake-dropbox-app-key
|
||||
DROPBOX_APP_SECRET: fake-dropbox-app-secret
|
||||
DROPBOX_REDIRECT_URI: http://localhost:8000/api/v1/auth/dropbox/connector/callback
|
||||
# Defense-in-depth: even though L3 egress is denied for the worker via
|
||||
# `internal: true`, the backend still has a route via `ingress`. Setting
|
||||
# HTTPS_PROXY to an unreachable port turns any leaked Python outbound HTTP
|
||||
# call into a fast Connection refused. UNLIKE the old runner-shell setup,
|
||||
# this proxy is set on the container env and `uv` is never invoked here,
|
||||
# so there is no interaction with uv's implicit-sync behaviour.
|
||||
HTTPS_PROXY: http://127.0.0.1:1
|
||||
HTTP_PROXY: http://127.0.0.1:1
|
||||
NO_PROXY: localhost,127.0.0.1,0.0.0.0,db,redis,host.docker.internal
|
||||
HF_HUB_OFFLINE: "1"
|
||||
TRANSFORMERS_OFFLINE: "1"
|
||||
# Test-only token-mint endpoint secret (see tests/e2e/run_backend.py).
|
||||
E2E_MINT_SECRET: e2e-mint-secret-not-for-production
|
||||
|
||||
services:
|
||||
db:
|
||||
image: pgvector/pgvector:pg17
|
||||
command: >
|
||||
postgres
|
||||
-c wal_level=logical
|
||||
-c max_wal_senders=10
|
||||
-c max_replication_slots=10
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: surfsense_e2e
|
||||
# Ephemeral storage — every CI run gets a clean DB, no volume cleanup needed.
|
||||
tmpfs:
|
||||
- /var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d surfsense_e2e"]
|
||||
interval: 2s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
networks: [internal]
|
||||
|
||||
redis:
|
||||
image: redis:8-alpine
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 2s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
networks: [internal]
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ../surfsense_backend
|
||||
dockerfile: Dockerfile
|
||||
target: e2e
|
||||
additional_contexts:
|
||||
# tests/ is excluded from the main context by .dockerignore;
|
||||
# the e2e stage's `COPY --from=tests-source` pulls it in here.
|
||||
tests-source: ../surfsense_backend/tests
|
||||
args:
|
||||
EMBEDDING_MODEL: sentence-transformers/all-MiniLM-L6-v2
|
||||
cache_from:
|
||||
- type=gha,scope=surfsense-e2e-backend
|
||||
cache_to:
|
||||
- type=gha,mode=max,scope=surfsense-e2e-backend
|
||||
image: surfsense-e2e-backend:local
|
||||
environment:
|
||||
<<: *backend-env
|
||||
SERVICE_ROLE: api
|
||||
volumes:
|
||||
- shared_temp:/shared_tmp
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
db: { condition: service_healthy }
|
||||
redis: { condition: service_healthy }
|
||||
healthcheck:
|
||||
# Use Python (already in the image) instead of curl/wget to avoid
|
||||
# depending on either tool being installed in the runtime layers.
|
||||
test:
|
||||
- CMD
|
||||
- python
|
||||
- -c
|
||||
- |
|
||||
import sys, urllib.request
|
||||
try:
|
||||
r = urllib.request.urlopen("http://localhost:8000/openapi.json", timeout=2)
|
||||
sys.exit(0 if r.status == 200 else 1)
|
||||
except Exception:
|
||||
sys.exit(1)
|
||||
interval: 3s
|
||||
timeout: 5s
|
||||
retries: 60
|
||||
start_period: 30s
|
||||
networks:
|
||||
- internal # to reach db/redis
|
||||
- ingress # so host can reach :8000
|
||||
|
||||
celery_worker:
|
||||
image: surfsense-e2e-backend:local
|
||||
pull_policy: never
|
||||
# No build: section — reuses the image built by the `backend` service.
|
||||
# Compose v2 builds shared images exactly once across services that
|
||||
# reference the same `image:` tag.
|
||||
environment:
|
||||
<<: *backend-env
|
||||
SERVICE_ROLE: worker
|
||||
volumes:
|
||||
- shared_temp:/shared_tmp
|
||||
depends_on:
|
||||
backend: { condition: service_healthy }
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- "celery -A app.celery_app inspect ping --timeout 2 | grep -q pong"
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
networks: [internal]
|
||||
|
||||
networks:
|
||||
# Internal network: containers attached only to this network have NO route
|
||||
# to the host or the internet. This is the L3 deny-egress mechanism that
|
||||
# replaces the fragile HTTPS_PROXY-on-the-runner approach.
|
||||
internal:
|
||||
driver: bridge
|
||||
internal: true
|
||||
|
||||
# Regular bridge network. Only the `backend` service joins it, solely so
|
||||
# the host can reach :8000 via the published port. celery_worker / db /
|
||||
# redis stay off this network entirely.
|
||||
ingress:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
shared_temp:
|
||||
@@ -0,0 +1,30 @@
|
||||
services:
|
||||
backend:
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: ${SURFSENSE_GPU_DRIVER:-nvidia}
|
||||
count: ${SURFSENSE_GPU_COUNT:-1}
|
||||
capabilities:
|
||||
- gpu
|
||||
|
||||
celery_worker:
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: ${SURFSENSE_GPU_DRIVER:-nvidia}
|
||||
count: ${SURFSENSE_GPU_COUNT:-1}
|
||||
capabilities:
|
||||
- gpu
|
||||
|
||||
celery_beat:
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: ${SURFSENSE_GPU_DRIVER:-nvidia}
|
||||
count: ${SURFSENSE_GPU_COUNT:-1}
|
||||
capabilities:
|
||||
- gpu
|
||||
@@ -0,0 +1,54 @@
|
||||
# =============================================================================
|
||||
# SurfSense — Optional Caddy reverse-proxy overlay
|
||||
# =============================================================================
|
||||
# Usage (from docker/):
|
||||
# PROXY_HTTP_PORT=8080 SURFSENSE_PUBLIC_URL=http://localhost:8080 \
|
||||
# docker compose -f docker-compose.yml -f docker-compose.proxy.yml up -d
|
||||
#
|
||||
# This overlay is for validation and custom deployments. The production
|
||||
# docker-compose.yml includes Caddy by default.
|
||||
# =============================================================================
|
||||
|
||||
services:
|
||||
backend:
|
||||
ports:
|
||||
- "${BACKEND_PORT:-8929}:8000"
|
||||
|
||||
zero-cache:
|
||||
ports:
|
||||
- "${ZERO_CACHE_PORT:-5929}:4848"
|
||||
|
||||
frontend:
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-3929}:3000"
|
||||
|
||||
proxy:
|
||||
image: caddy:2-alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${PROXY_HTTP_PORT:-8080}:80"
|
||||
- "${PROXY_HTTPS_PORT:-8443}:443"
|
||||
volumes:
|
||||
- ./proxy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
environment:
|
||||
SURFSENSE_SITE_ADDRESS: ${SURFSENSE_SITE_ADDRESS:-:80}
|
||||
CERT_EMAIL: ${CERT_EMAIL:-}
|
||||
CERT_ACME_CA: ${CERT_ACME_CA:-https://acme-v02.api.letsencrypt.org/directory}
|
||||
CERT_ACME_DNS: ${CERT_ACME_DNS:-}
|
||||
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-0.0.0.0/0}
|
||||
SURFSENSE_MAX_BODY_SIZE: ${SURFSENSE_MAX_BODY_SIZE:-5GB}
|
||||
depends_on:
|
||||
frontend:
|
||||
condition: service_started
|
||||
backend:
|
||||
condition: service_healthy
|
||||
zero-cache:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
caddy_data:
|
||||
name: surfsense-caddy-data
|
||||
caddy_config:
|
||||
name: surfsense-caddy-config
|
||||
@@ -0,0 +1,52 @@
|
||||
# =============================================================================
|
||||
# SurfSense — Isolated deps for the watch (Phase 6) manual E2E
|
||||
# =============================================================================
|
||||
# Fresh Postgres + Redis ONLY, with their own project name, volumes, and host
|
||||
# ports so they never collide with the day-to-day `surfsense-deps` stack.
|
||||
#
|
||||
# Backend + Celery run on the HOST (tests/e2e/run_backend.py / run_celery.py)
|
||||
# pointed at these ports via env:
|
||||
# DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5442/surfsense
|
||||
# CELERY_BROKER_URL / CELERY_RESULT_BACKEND / REDIS_APP_URL=redis://localhost:6389/0
|
||||
#
|
||||
# Up: docker compose -p surfsense-watch -f docker/docker-compose.watch-e2e.yml up -d
|
||||
# Down: docker compose -p surfsense-watch -f docker/docker-compose.watch-e2e.yml down -v
|
||||
# =============================================================================
|
||||
|
||||
name: surfsense-watch
|
||||
|
||||
services:
|
||||
db:
|
||||
image: pgvector/pgvector:pg17
|
||||
ports:
|
||||
- "5442:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- POSTGRES_DB=surfsense
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d surfsense"]
|
||||
interval: 3s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
redis:
|
||||
image: redis:8-alpine
|
||||
ports:
|
||||
- "6389:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
command: redis-server --appendonly yes
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 3s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
name: surfsense-watch-postgres
|
||||
redis_data:
|
||||
name: surfsense-watch-redis
|
||||
@@ -0,0 +1,308 @@
|
||||
# =============================================================================
|
||||
# SurfSense — Production Docker Compose
|
||||
# Docs: https://docs.surfsense.com/docs/docker-installation
|
||||
# =============================================================================
|
||||
# Usage:
|
||||
# 1. Copy .env.example to .env and edit the required values
|
||||
# 2. docker compose up -d
|
||||
# =============================================================================
|
||||
|
||||
name: surfsense
|
||||
|
||||
services:
|
||||
db:
|
||||
image: pgvector/pgvector:pg17
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./postgresql.conf:/etc/postgresql/postgresql.conf:ro
|
||||
environment:
|
||||
POSTGRES_USER: ${DB_USER:-surfsense}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-surfsense}
|
||||
POSTGRES_DB: ${DB_NAME:-surfsense}
|
||||
command: postgres -c config_file=/etc/postgresql/postgresql.conf
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-surfsense} -d ${DB_NAME:-surfsense}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# Short-lived schema runner. Executes `alembic upgrade head` and verifies
|
||||
# that the `zero_publication` Postgres logical-replication publication
|
||||
# matches the canonical shape, then exits 0. Downstream services gate on this
|
||||
# with `condition: service_completed_successfully` so a failed migration halts
|
||||
# the whole stack instead of booting zero-cache against a drifted publication.
|
||||
migrations:
|
||||
image: ghcr.io/modsetter/surfsense-backend:${SURFSENSE_VERSION:-latest}${SURFSENSE_VARIANT:+-${SURFSENSE_VARIANT}}
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://${DB_USER:-surfsense}:${DB_PASSWORD:-surfsense}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}}
|
||||
PYTHONPATH: /app
|
||||
SERVICE_ROLE: migrate
|
||||
MIGRATION_TIMEOUT: ${MIGRATION_TIMEOUT:-900}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
restart: "no"
|
||||
|
||||
redis:
|
||||
image: redis:8-alpine
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
command: redis-server --appendonly yes
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# otel-collector:
|
||||
# image: otel/opentelemetry-collector-contrib:0.152.1
|
||||
# profiles:
|
||||
# - observability
|
||||
# command: ["--config=/etc/otelcol/config.yaml"]
|
||||
# volumes:
|
||||
# - ./otel-collector/config.yaml:/etc/otelcol/config.yaml:ro
|
||||
# environment:
|
||||
# GRAFANA_CLOUD_OTLP_ENDPOINT: ${GRAFANA_CLOUD_OTLP_ENDPOINT:-}
|
||||
# GRAFANA_CLOUD_INSTANCE_ID: ${GRAFANA_CLOUD_INSTANCE_ID:-}
|
||||
# GRAFANA_CLOUD_API_KEY: ${GRAFANA_CLOUD_API_KEY:-}
|
||||
# ports:
|
||||
# - "${OTEL_GRPC_PORT:-4317}:4317"
|
||||
# - "${OTEL_HTTP_PORT:-4318}:4318"
|
||||
# - "${OTEL_HEALTH_PORT:-13133}:13133"
|
||||
# mem_limit: 2g
|
||||
# restart: unless-stopped
|
||||
# healthcheck:
|
||||
# test: ["CMD", "/otelcol-contrib", "--version"]
|
||||
# interval: 30s
|
||||
# timeout: 5s
|
||||
# retries: 3
|
||||
|
||||
# Single public entry point for the Docker stack. Comment this service out
|
||||
# only if you front SurfSense with your own reverse proxy.
|
||||
proxy:
|
||||
image: caddy:2-alpine
|
||||
# For DNS-01/wildcard certificates, replace image with:
|
||||
# build: ./proxy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${LISTEN_HTTP_PORT:-3929}:80"
|
||||
- "${LISTEN_HTTPS_PORT:-443}:443"
|
||||
volumes:
|
||||
- ./proxy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
environment:
|
||||
SURFSENSE_SITE_ADDRESS: ${SURFSENSE_SITE_ADDRESS:-:80}
|
||||
CERT_EMAIL: ${CERT_EMAIL:-}
|
||||
CERT_ACME_CA: ${CERT_ACME_CA:-https://acme-v02.api.letsencrypt.org/directory}
|
||||
CERT_ACME_DNS: ${CERT_ACME_DNS:-}
|
||||
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-0.0.0.0/0}
|
||||
SURFSENSE_MAX_BODY_SIZE: ${SURFSENSE_MAX_BODY_SIZE:-5GB}
|
||||
depends_on:
|
||||
frontend:
|
||||
condition: service_started
|
||||
backend:
|
||||
condition: service_healthy
|
||||
zero-cache:
|
||||
condition: service_healthy
|
||||
|
||||
backend:
|
||||
image: ghcr.io/modsetter/surfsense-backend:${SURFSENSE_VERSION:-latest}${SURFSENSE_VARIANT:+-${SURFSENSE_VARIANT}}
|
||||
expose:
|
||||
- "8000"
|
||||
volumes:
|
||||
- shared_temp:/shared_tmp
|
||||
- object_store:/app/.local_object_store
|
||||
env_file:
|
||||
- .env
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
environment:
|
||||
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://${DB_USER:-surfsense}:${DB_PASSWORD:-surfsense}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}}
|
||||
CELERY_BROKER_URL: ${REDIS_URL:-redis://redis:6379/0}
|
||||
CELERY_RESULT_BACKEND: ${REDIS_URL:-redis://redis:6379/0}
|
||||
REDIS_APP_URL: ${REDIS_URL:-redis://redis:6379/0}
|
||||
CELERY_TASK_DEFAULT_QUEUE: surfsense
|
||||
PYTHONPATH: /app
|
||||
UVICORN_LOOP: asyncio
|
||||
UNSTRUCTURED_HAS_PATCHED_LOOP: "1"
|
||||
FILE_STORAGE_LOCAL_PATH: /app/.local_object_store
|
||||
NEXT_FRONTEND_URL: ${NEXT_FRONTEND_URL:-${SURFSENSE_PUBLIC_URL:-http://localhost:${LISTEN_HTTP_PORT:-3929}}}
|
||||
BACKEND_URL: ${BACKEND_URL:-${SURFSENSE_PUBLIC_URL:-http://localhost:${LISTEN_HTTP_PORT:-3929}}}
|
||||
WHATSAPP_BRIDGE_URL: ${WHATSAPP_BRIDGE_URL:-http://whatsapp-bridge:9929}
|
||||
# Daytona Sandbox – uncomment and set credentials to enable cloud code execution
|
||||
# DAYTONA_SANDBOX_ENABLED: "TRUE"
|
||||
# DAYTONA_API_KEY: ${DAYTONA_API_KEY:-}
|
||||
# DAYTONA_API_URL: ${DAYTONA_API_URL:-https://app.daytona.io/api}
|
||||
# DAYTONA_TARGET: ${DAYTONA_TARGET:-us}
|
||||
SERVICE_ROLE: api
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=true"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
migrations:
|
||||
condition: service_completed_successfully
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/ready"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 200s
|
||||
|
||||
# whatsapp-bridge:
|
||||
# build: ../surfsense_backend/scripts/whatsapp-bridge
|
||||
# profiles:
|
||||
# - whatsapp
|
||||
# expose:
|
||||
# - "9929"
|
||||
# volumes:
|
||||
# - whatsapp_sessions:/data/sessions
|
||||
# environment:
|
||||
# PORT: 9929
|
||||
# WHATSAPP_MODE: ${WHATSAPP_MODE:-self-chat}
|
||||
# WHATSAPP_SESSION_DIR: /data/sessions
|
||||
# mem_limit: 512m
|
||||
# restart: unless-stopped
|
||||
# healthcheck:
|
||||
# test: ["CMD", "wget", "-qO-", "http://localhost:9929/health"]
|
||||
# interval: 30s
|
||||
# timeout: 5s
|
||||
# retries: 5
|
||||
|
||||
celery_worker:
|
||||
image: ghcr.io/modsetter/surfsense-backend:${SURFSENSE_VERSION:-latest}${SURFSENSE_VARIANT:+-${SURFSENSE_VARIANT}}
|
||||
volumes:
|
||||
- shared_temp:/shared_tmp
|
||||
- object_store:/app/.local_object_store
|
||||
env_file:
|
||||
- .env
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
environment:
|
||||
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://${DB_USER:-surfsense}:${DB_PASSWORD:-surfsense}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}}
|
||||
CELERY_BROKER_URL: ${REDIS_URL:-redis://redis:6379/0}
|
||||
CELERY_RESULT_BACKEND: ${REDIS_URL:-redis://redis:6379/0}
|
||||
REDIS_APP_URL: ${REDIS_URL:-redis://redis:6379/0}
|
||||
CELERY_TASK_DEFAULT_QUEUE: surfsense
|
||||
PYTHONPATH: /app
|
||||
FILE_STORAGE_LOCAL_PATH: /app/.local_object_store
|
||||
SERVICE_ROLE: worker
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
migrations:
|
||||
condition: service_completed_successfully
|
||||
backend:
|
||||
condition: service_healthy
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=true"
|
||||
restart: unless-stopped
|
||||
|
||||
celery_beat:
|
||||
image: ghcr.io/modsetter/surfsense-backend:${SURFSENSE_VERSION:-latest}${SURFSENSE_VARIANT:+-${SURFSENSE_VARIANT}}
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://${DB_USER:-surfsense}:${DB_PASSWORD:-surfsense}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}}
|
||||
CELERY_BROKER_URL: ${REDIS_URL:-redis://redis:6379/0}
|
||||
CELERY_RESULT_BACKEND: ${REDIS_URL:-redis://redis:6379/0}
|
||||
CELERY_TASK_DEFAULT_QUEUE: surfsense
|
||||
PYTHONPATH: /app
|
||||
SERVICE_ROLE: beat
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
migrations:
|
||||
condition: service_completed_successfully
|
||||
celery_worker:
|
||||
condition: service_started
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=true"
|
||||
restart: unless-stopped
|
||||
|
||||
zero-cache:
|
||||
image: rocicorp/zero:1.6.0
|
||||
expose:
|
||||
- "4848"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
environment:
|
||||
ZERO_UPSTREAM_DB: ${ZERO_UPSTREAM_DB:-postgresql://${DB_USER:-surfsense}:${DB_PASSWORD:-surfsense}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}?sslmode=${DB_SSLMODE:-disable}}
|
||||
ZERO_CVR_DB: ${ZERO_CVR_DB:-postgresql://${DB_USER:-surfsense}:${DB_PASSWORD:-surfsense}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}?sslmode=${DB_SSLMODE:-disable}}
|
||||
ZERO_CHANGE_DB: ${ZERO_CHANGE_DB:-postgresql://${DB_USER:-surfsense}:${DB_PASSWORD:-surfsense}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}?sslmode=${DB_SSLMODE:-disable}}
|
||||
ZERO_REPLICA_FILE: /data/zero.db
|
||||
ZERO_ADMIN_PASSWORD: ${ZERO_ADMIN_PASSWORD:-surfsense-zero-admin}
|
||||
ZERO_APP_PUBLICATIONS: ${ZERO_APP_PUBLICATIONS:-zero_publication}
|
||||
ZERO_AUTO_RESET: ${ZERO_AUTO_RESET:-true}
|
||||
ZERO_NUM_SYNC_WORKERS: ${ZERO_NUM_SYNC_WORKERS:-4}
|
||||
ZERO_UPSTREAM_MAX_CONNS: ${ZERO_UPSTREAM_MAX_CONNS:-20}
|
||||
ZERO_CVR_MAX_CONNS: ${ZERO_CVR_MAX_CONNS:-30}
|
||||
ZERO_QUERY_URL: ${ZERO_QUERY_URL:-http://frontend:3000/api/zero/query}
|
||||
ZERO_MUTATE_URL: ${ZERO_MUTATE_URL:-http://frontend:3000/api/zero/mutate}
|
||||
ZERO_QUERY_FORWARD_COOKIES: ${ZERO_QUERY_FORWARD_COOKIES:-true}
|
||||
ZERO_QUERY_API_KEY: ${ZERO_QUERY_API_KEY:-}
|
||||
ZERO_AUTH_REVALIDATE_INTERVAL_SECONDS: ${ZERO_AUTH_REVALIDATE_INTERVAL_SECONDS:-60}
|
||||
ZERO_AUTH_RETRANSFORM_INTERVAL_SECONDS: ${ZERO_AUTH_RETRANSFORM_INTERVAL_SECONDS:-60}
|
||||
volumes:
|
||||
- zero_cache_data:/data
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 300s
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
migrations:
|
||||
condition: service_completed_successfully
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:4848/keepalive"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 600s
|
||||
|
||||
frontend:
|
||||
image: ghcr.io/modsetter/surfsense-web:${SURFSENSE_VERSION:-latest}
|
||||
expose:
|
||||
- "3000"
|
||||
environment:
|
||||
AUTH_TYPE: ${AUTH_TYPE:-LOCAL}
|
||||
ETL_SERVICE: ${ETL_SERVICE:-DOCLING}
|
||||
DEPLOYMENT_MODE: ${DEPLOYMENT_MODE:-self-hosted}
|
||||
SURFSENSE_BACKEND_INTERNAL_URL: http://backend:8000
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=true"
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
zero-cache:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
name: surfsense-postgres
|
||||
redis_data:
|
||||
name: surfsense-redis
|
||||
shared_temp:
|
||||
name: surfsense-shared-temp
|
||||
object_store:
|
||||
name: surfsense-object-store
|
||||
zero_cache_data:
|
||||
name: surfsense-zero-cache
|
||||
caddy_data:
|
||||
name: surfsense-caddy-data
|
||||
caddy_config:
|
||||
name: surfsense-caddy-config
|
||||
whatsapp_sessions:
|
||||
name: surfsense-whatsapp-sessions
|
||||
@@ -0,0 +1,81 @@
|
||||
extensions:
|
||||
health_check:
|
||||
endpoint: 0.0.0.0:13133
|
||||
basicauth/grafana_cloud:
|
||||
client_auth:
|
||||
username: ${env:GRAFANA_CLOUD_INSTANCE_ID}
|
||||
password: ${env:GRAFANA_CLOUD_API_KEY}
|
||||
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
grpc:
|
||||
endpoint: 0.0.0.0:4317
|
||||
http:
|
||||
endpoint: 0.0.0.0:4318
|
||||
|
||||
processors:
|
||||
# Percentage limits are calculated against the collector container memory limit.
|
||||
# Keep docker-compose.yml/Coolify memory limit set for predictability.
|
||||
memory_limiter:
|
||||
check_interval: 1s
|
||||
limit_percentage: 80
|
||||
spike_limit_percentage: 25
|
||||
|
||||
attributes/scrub:
|
||||
actions:
|
||||
- key: http.request.header.authorization
|
||||
action: delete
|
||||
- key: http.request.header.cookie
|
||||
action: delete
|
||||
- key: db.statement
|
||||
action: delete
|
||||
|
||||
tail_sampling:
|
||||
decision_wait: 10s
|
||||
num_traces: 50000
|
||||
expected_new_traces_per_sec: 100
|
||||
policies:
|
||||
- name: errors
|
||||
type: status_code
|
||||
status_code:
|
||||
status_codes: [ERROR]
|
||||
- name: slow-requests
|
||||
type: latency
|
||||
latency:
|
||||
threshold_ms: 500
|
||||
- name: baseline
|
||||
type: probabilistic
|
||||
probabilistic:
|
||||
sampling_percentage: 100
|
||||
|
||||
batch:
|
||||
timeout: 5s
|
||||
send_batch_size: 1024
|
||||
send_batch_max_size: 2048
|
||||
|
||||
exporters:
|
||||
otlp_http/grafana_cloud:
|
||||
endpoint: ${env:GRAFANA_CLOUD_OTLP_ENDPOINT}
|
||||
auth:
|
||||
authenticator: basicauth/grafana_cloud
|
||||
sending_queue:
|
||||
enabled: true
|
||||
queue_size: 10000
|
||||
retry_on_failure:
|
||||
enabled: true
|
||||
initial_interval: 5s
|
||||
max_interval: 30s
|
||||
max_elapsed_time: 300s
|
||||
|
||||
service:
|
||||
extensions: [health_check, basicauth/grafana_cloud]
|
||||
pipelines:
|
||||
traces:
|
||||
receivers: [otlp]
|
||||
processors: [memory_limiter, attributes/scrub, tail_sampling, batch]
|
||||
exporters: [otlp_http/grafana_cloud]
|
||||
metrics:
|
||||
receivers: [otlp]
|
||||
processors: [memory_limiter, batch]
|
||||
exporters: [otlp_http/grafana_cloud]
|
||||
@@ -0,0 +1,20 @@
|
||||
# PostgreSQL configuration for SurfSense
|
||||
# This file is mounted into the PostgreSQL container
|
||||
|
||||
listen_addresses = '*'
|
||||
max_connections = 200
|
||||
shared_buffers = 256MB
|
||||
|
||||
# Enable logical replication (required for Zero-cache real-time sync)
|
||||
wal_level = logical
|
||||
max_replication_slots = 10
|
||||
max_wal_senders = 10
|
||||
|
||||
# Performance settings
|
||||
checkpoint_timeout = 10min
|
||||
max_wal_size = 1GB
|
||||
min_wal_size = 80MB
|
||||
|
||||
# Logging (optional, for debugging)
|
||||
# log_statement = 'all'
|
||||
# log_replication_commands = on
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
# Optional ACME/global settings. These are harmless in the default :80
|
||||
# localhost mode and become active when SURFSENSE_SITE_ADDRESS is a domain.
|
||||
{$CERT_EMAIL}
|
||||
acme_ca {$CERT_ACME_CA:https://acme-v02.api.letsencrypt.org/directory}
|
||||
{$CERT_ACME_DNS}
|
||||
servers {
|
||||
client_ip_headers X-Forwarded-For X-Real-IP
|
||||
trusted_proxies static {$TRUSTED_PROXIES:0.0.0.0/0}
|
||||
}
|
||||
}
|
||||
|
||||
(surfsense_proxy) {
|
||||
request_body {
|
||||
max_size {$SURFSENSE_MAX_BODY_SIZE:5GB}
|
||||
}
|
||||
|
||||
# Frontend-owned auth page (the post-login token handler). More specific than
|
||||
# /auth/*, so Caddy's matcher-specificity sort routes it here, not to backend.
|
||||
reverse_proxy /auth/callback* frontend:3000
|
||||
|
||||
# Backend auth routes (FastAPI Users + OAuth helpers).
|
||||
reverse_proxy /auth/* backend:8000
|
||||
|
||||
# Backend user profile routes (FastAPI Users users router, mounted at /users).
|
||||
reverse_proxy /users/* backend:8000
|
||||
|
||||
# Backend REST, streaming, connector OAuth, and messaging gateway endpoints.
|
||||
# FastAPI already serves /api/v1, so the path is forwarded unchanged.
|
||||
reverse_proxy /api/v1/* backend:8000 {
|
||||
flush_interval -1
|
||||
}
|
||||
|
||||
# Zero sync auth context is a backend (FastAPI) endpoint. More specific than
|
||||
# /zero/*, so Caddy's matcher-specificity sort routes it here, not to zero-cache.
|
||||
reverse_proxy /zero/context backend:8000
|
||||
|
||||
# Zero accepts a single path-component base URL (Zero >= 0.6).
|
||||
# Preserve /zero so browser cacheURL can be ${SURFSENSE_PUBLIC_URL}/zero.
|
||||
reverse_proxy /zero/* zero-cache:4848
|
||||
|
||||
# Next.js app and frontend-owned API routes:
|
||||
# /api/zero/*, /api/search, /api/contact, etc.
|
||||
reverse_proxy /* frontend:3000
|
||||
}
|
||||
|
||||
{$SURFSENSE_SITE_ADDRESS::80} {
|
||||
import surfsense_proxy
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
FROM caddy:2-builder-alpine AS builder
|
||||
|
||||
RUN xcaddy build \
|
||||
--with github.com/caddy-dns/cloudflare \
|
||||
--with github.com/caddy-dns/digitalocean
|
||||
|
||||
FROM caddy:2-alpine
|
||||
|
||||
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
|
||||
COPY Caddyfile /etc/caddy/Caddyfile
|
||||
@@ -0,0 +1,567 @@
|
||||
# =============================================================================
|
||||
# SurfSense — One-line Install Script (Windows / PowerShell)
|
||||
#
|
||||
#
|
||||
# Usage: irm https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/install.ps1 | iex
|
||||
#
|
||||
# To pass flags, save and run locally:
|
||||
# .\install.ps1 -NoWatchtower
|
||||
# .\install.ps1 -WatchtowerInterval 3600
|
||||
# .\install.ps1 -Variant cuda
|
||||
# .\install.ps1 -Variant cuda -GpuCount all
|
||||
#
|
||||
# Handles two cases automatically:
|
||||
# 1. Fresh install — no prior SurfSense data detected
|
||||
# 2. Migration from the legacy all-in-one container (surfsense-data volume)
|
||||
# Downloads and runs migrate-database.sh --yes, then restores the dump
|
||||
# into the new PostgreSQL 17 stack. The user runs one command for both.
|
||||
# =============================================================================
|
||||
|
||||
# NOTE: Do not use [ValidateSet()] (or other validation attributes without a
|
||||
# valid default) on these params. When the script is piped into iex, PowerShell
|
||||
# applies the attributes to variables in the caller's scope, and an empty
|
||||
# $Variant fails ValidateSet with a ValidationMetadataException. Validate
|
||||
# manually below instead.
|
||||
param(
|
||||
[switch]$NoWatchtower,
|
||||
[int]$WatchtowerInterval = 86400,
|
||||
[string]$Variant,
|
||||
[string]$GpuCount,
|
||||
[switch]$Quiet
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# ── Configuration ───────────────────────────────────────────────────────────
|
||||
|
||||
$RepoRaw = "https://raw.githubusercontent.com/MODSetter/SurfSense/main"
|
||||
$InstallDir = ".\surfsense"
|
||||
$OldVolume = "surfsense-data"
|
||||
$DumpFile = ".\surfsense_migration_backup.sql"
|
||||
$KeyFile = ".\surfsense_migration_secret.key"
|
||||
$MigrationDoneFile = "$InstallDir\.migration_done"
|
||||
$MigrationMode = $false
|
||||
$SetupWatchtower = -not $NoWatchtower
|
||||
$WatchtowerContainer = "watchtower"
|
||||
|
||||
if ($Variant -and $Variant -notin @("cpu", "cuda", "cuda126")) {
|
||||
Write-Host "[SurfSense] ERROR: Invalid -Variant '$Variant'. Use 'cpu', 'cuda', or 'cuda126'." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($GpuCount -and $GpuCount -notmatch '^([0-9]+|all)$') {
|
||||
Write-Host "[SurfSense] ERROR: Invalid -GpuCount '$GpuCount'. Use a number or 'all'." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ── Output helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function Write-Info { param([string]$Msg) Write-Host "[SurfSense] " -ForegroundColor Cyan -NoNewline; Write-Host $Msg }
|
||||
function Write-Ok { param([string]$Msg) Write-Host "[SurfSense] " -ForegroundColor Green -NoNewline; Write-Host $Msg }
|
||||
function Write-Warn { param([string]$Msg) Write-Host "[SurfSense] " -ForegroundColor Yellow -NoNewline; Write-Host $Msg }
|
||||
function Write-Step { param([string]$Msg) Write-Host "`n-- $Msg" -ForegroundColor Cyan }
|
||||
function Write-Err { param([string]$Msg) Write-Host "[SurfSense] ERROR: $Msg" -ForegroundColor Red; exit 1 }
|
||||
|
||||
function Show-Banner {
|
||||
Write-Host ""
|
||||
Write-Host @"
|
||||
|
||||
|
||||
███████╗██╗ ██╗██████╗ ███████╗███████╗███████╗███╗ ██╗███████╗███████╗
|
||||
██╔════╝██║ ██║██╔══██╗██╔════╝██╔════╝██╔════╝████╗ ██║██╔════╝██╔════╝
|
||||
███████╗██║ ██║██████╔╝█████╗ ███████╗█████╗ ██╔██╗ ██║███████╗█████╗
|
||||
╚════██║██║ ██║██╔══██╗██╔══╝ ╚════██║██╔══╝ ██║╚██╗██║╚════██║██╔══╝
|
||||
███████║╚██████╔╝██║ ██║██║ ███████║███████╗██║ ╚████║███████║███████╗
|
||||
╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚══════╝╚══════╝╚═╝ ╚═══╝╚══════╝╚══════╝
|
||||
|
||||
|
||||
"@ -ForegroundColor White
|
||||
Write-Host " OSS Alternative to NotebookLM for Teams" -ForegroundColor Yellow
|
||||
Write-Host ("=" * 62) -ForegroundColor Cyan
|
||||
Write-Info "This installer will create $InstallDir\ and start SurfSense with Docker Compose."
|
||||
}
|
||||
|
||||
Show-Banner
|
||||
|
||||
function Invoke-NativeSafe {
|
||||
param([scriptblock]$Command)
|
||||
$previousErrorActionPreference = $ErrorActionPreference
|
||||
try {
|
||||
$ErrorActionPreference = 'Continue'
|
||||
& $Command
|
||||
} finally {
|
||||
$ErrorActionPreference = $previousErrorActionPreference
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-WatchtowerPreference {
|
||||
if ($NoWatchtower -or $Quiet -or -not [Environment]::UserInteractive) {
|
||||
return
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Automatic updates" -ForegroundColor Cyan
|
||||
$choice = Read-Host "Enable automatic daily updates with Watchtower? (may download several GB in the background) [Y/n]"
|
||||
|
||||
switch ($choice) {
|
||||
"" { $script:SetupWatchtower = $true }
|
||||
{ $_ -match '^(?i)y(es)?$' } { $script:SetupWatchtower = $true }
|
||||
{ $_ -match '^(?i)n(o)?$' } { $script:SetupWatchtower = $false }
|
||||
default {
|
||||
Write-Warn "Unrecognized choice '$choice'; enabling Watchtower by default. Use -NoWatchtower to skip it."
|
||||
$script:SetupWatchtower = $true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Resolve-WatchtowerPreference
|
||||
|
||||
# ── Pre-flight checks ──────────────────────────────────────────────────────
|
||||
|
||||
Write-Step "Checking prerequisites"
|
||||
|
||||
if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {
|
||||
Write-Err "Docker is not installed. Install Docker Desktop: https://docs.docker.com/desktop/install/windows-install/"
|
||||
}
|
||||
Write-Ok "Docker found."
|
||||
|
||||
Invoke-NativeSafe { docker info *>$null } | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Err "Docker daemon is not running. Please start Docker Desktop and try again."
|
||||
}
|
||||
Write-Ok "Docker daemon is running."
|
||||
|
||||
Invoke-NativeSafe { docker compose version *>$null } | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Err "Docker Compose is not available. It should be bundled with Docker Desktop."
|
||||
}
|
||||
Write-Ok "Docker Compose found."
|
||||
|
||||
# ── Wait-for-postgres helper ────────────────────────────────────────────────
|
||||
|
||||
function Wait-ForPostgres {
|
||||
param([string]$DbUser)
|
||||
$maxAttempts = 45
|
||||
$attempt = 0
|
||||
|
||||
Write-Info "Waiting for PostgreSQL to accept connections..."
|
||||
do {
|
||||
$attempt++
|
||||
if ($attempt -ge $maxAttempts) {
|
||||
Write-Err "PostgreSQL did not become ready after $($maxAttempts * 2) seconds.`nCheck logs: cd $InstallDir; docker compose logs db"
|
||||
}
|
||||
Start-Sleep -Seconds 2
|
||||
Push-Location $InstallDir
|
||||
Invoke-NativeSafe { docker compose exec -T db pg_isready -U $DbUser -q *>$null } | Out-Null
|
||||
$ready = $LASTEXITCODE -eq 0
|
||||
Pop-Location
|
||||
} while (-not $ready)
|
||||
|
||||
Write-Ok "PostgreSQL is ready."
|
||||
}
|
||||
|
||||
# ── Stack startup helper ────────────────────────────────────────────────────
|
||||
|
||||
function Invoke-StackFailureReport {
|
||||
Write-Host ""
|
||||
Write-Host "[ERROR] Stack did not reach a healthy state." -ForegroundColor Red
|
||||
Write-Host ""
|
||||
Write-Info "Recent logs from migrations / zero-cache / backend:"
|
||||
Push-Location $InstallDir
|
||||
try {
|
||||
Invoke-NativeSafe { docker compose logs --tail=60 migrations zero-cache backend 2>&1 } | Write-Host
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Recovery hints:" -ForegroundColor Yellow
|
||||
Write-Host " 1. Inspect migrations: cd $InstallDir; docker compose logs migrations"
|
||||
Write-Host " 2. Verify publication: cd $InstallDir; docker compose exec db psql -U surfsense -d surfsense -c 'SELECT pubname FROM pg_publication;'"
|
||||
Write-Host " 3. Hard reset zero db: cd $InstallDir; docker compose down; docker volume rm surfsense-zero-cache; docker compose up -d --wait"
|
||||
Write-Host ""
|
||||
exit 1
|
||||
}
|
||||
|
||||
function Invoke-ComposeUpWait {
|
||||
Push-Location $InstallDir
|
||||
try {
|
||||
Invoke-NativeSafe { docker compose up -d --wait }
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Invoke-StackFailureReport
|
||||
}
|
||||
}
|
||||
|
||||
# ── Variant and .env helpers ────────────────────────────────────────────────
|
||||
|
||||
function Set-EnvValue {
|
||||
param([string]$Path, [string]$Key, [string]$Value)
|
||||
$lines = @()
|
||||
if (Test-Path $Path) {
|
||||
$lines = @(Get-Content $Path)
|
||||
}
|
||||
$updated = $false
|
||||
$newLines = foreach ($line in $lines) {
|
||||
if ($line -match "^$([regex]::Escape($Key))=") {
|
||||
$updated = $true
|
||||
"$Key=$Value"
|
||||
} else {
|
||||
$line
|
||||
}
|
||||
}
|
||||
if (-not $updated) {
|
||||
$newLines += "$Key=$Value"
|
||||
}
|
||||
Set-Content -Path $Path -Value $newLines
|
||||
}
|
||||
|
||||
function Remove-EnvValue {
|
||||
param([string]$Path, [string]$Key)
|
||||
if (-not (Test-Path $Path)) { return }
|
||||
$newLines = Get-Content $Path | Where-Object { $_ -notmatch "^$([regex]::Escape($Key))=" }
|
||||
Set-Content -Path $Path -Value $newLines
|
||||
}
|
||||
|
||||
function Test-NvidiaGpu {
|
||||
if (-not (Get-Command nvidia-smi -ErrorAction SilentlyContinue)) { return $false }
|
||||
Invoke-NativeSafe { nvidia-smi *>$null } | Out-Null
|
||||
return ($LASTEXITCODE -eq 0)
|
||||
}
|
||||
|
||||
function Test-NvidiaRuntime {
|
||||
$info = Invoke-NativeSafe { docker info 2>$null }
|
||||
if ($info -match 'nvidia') { return $true }
|
||||
if (Get-Command nvidia-ctk -ErrorAction SilentlyContinue) { return $true }
|
||||
if (Get-Command nvidia-container-runtime -ErrorAction SilentlyContinue) { return $true }
|
||||
return $false
|
||||
}
|
||||
|
||||
function Get-RecommendedVariant {
|
||||
$driver = (Invoke-NativeSafe { nvidia-smi --query-gpu=driver_version --format=csv,noheader 2>$null } | Select-Object -First 1)
|
||||
$major = 0
|
||||
if ($driver -match '^(\d+)') {
|
||||
$major = [int]$Matches[1]
|
||||
}
|
||||
if ($major -gt 0 -and $major -lt 570) {
|
||||
return "cuda126"
|
||||
}
|
||||
return "cuda"
|
||||
}
|
||||
|
||||
function Resolve-Variant {
|
||||
$hasGpu = Test-NvidiaGpu
|
||||
$hasRuntime = $false
|
||||
$recommended = "cpu"
|
||||
|
||||
if ($hasGpu) {
|
||||
$recommended = Get-RecommendedVariant
|
||||
$hasRuntime = Test-NvidiaRuntime
|
||||
}
|
||||
|
||||
if ($Variant) {
|
||||
if ($Variant -eq "cpu") { return "cpu" }
|
||||
if (-not $hasGpu) {
|
||||
Write-Warn "No NVIDIA GPU detected; falling back to CPU variant."
|
||||
return "cpu"
|
||||
}
|
||||
if (-not $hasRuntime) {
|
||||
Write-Warn "NVIDIA GPU detected, but NVIDIA Container Toolkit was not detected; falling back to CPU variant."
|
||||
Write-Warn "Install the toolkit before enabling SurfSense GPU acceleration."
|
||||
return "cpu"
|
||||
}
|
||||
return $Variant
|
||||
}
|
||||
|
||||
if ($hasGpu -and -not $hasRuntime) {
|
||||
Write-Warn "NVIDIA GPU detected, but NVIDIA Container Toolkit was not detected; using CPU variant."
|
||||
}
|
||||
|
||||
if ($hasGpu -and $hasRuntime -and -not $Quiet -and [Environment]::UserInteractive) {
|
||||
Write-Host ""
|
||||
Write-Host "SurfSense detected an NVIDIA GPU." -ForegroundColor Cyan
|
||||
$choice = Read-Host "Use GPU acceleration? [Y/n]"
|
||||
switch ($choice) {
|
||||
"" { return $recommended }
|
||||
{ $_ -match '^(?i)y(es)?$' } { return $recommended }
|
||||
{ $_ -match '^(?i)n(o)?$' } { return "cpu" }
|
||||
default {
|
||||
Write-Warn "Unrecognized choice '$choice'; using CPU variant."
|
||||
return "cpu"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "cpu"
|
||||
}
|
||||
|
||||
function Set-VariantEnv {
|
||||
param([string]$Path, [string]$SelectedVariant, [bool]$AllowExistingUpdate)
|
||||
|
||||
if ((Test-Path $Path) -and -not $AllowExistingUpdate) {
|
||||
Write-Warn ".env already exists - keeping your existing configuration."
|
||||
Write-Info "To change variants later, edit SURFSENSE_VARIANT and COMPOSE_FILE in $Path, then run docker compose up -d --wait."
|
||||
return
|
||||
}
|
||||
|
||||
if ($SelectedVariant -eq "cpu") {
|
||||
Set-EnvValue -Path $Path -Key "SURFSENSE_VARIANT" -Value ""
|
||||
Remove-EnvValue -Path $Path -Key "COMPOSE_FILE"
|
||||
Remove-EnvValue -Path $Path -Key "SURFSENSE_GPU_COUNT"
|
||||
} else {
|
||||
Set-EnvValue -Path $Path -Key "SURFSENSE_VARIANT" -Value $SelectedVariant
|
||||
Set-EnvValue -Path $Path -Key "COMPOSE_FILE" -Value "docker-compose.yml;docker-compose.gpu.yml"
|
||||
if ($GpuCount) {
|
||||
Set-EnvValue -Path $Path -Key "SURFSENSE_GPU_COUNT" -Value $GpuCount
|
||||
}
|
||||
}
|
||||
|
||||
Remove-EnvValue -Path $Path -Key "COMPOSE_PROFILES"
|
||||
}
|
||||
|
||||
$SelectedVariant = Resolve-Variant
|
||||
|
||||
# ── Download files ──────────────────────────────────────────────────────────
|
||||
|
||||
Write-Step "Downloading SurfSense files"
|
||||
Write-Info "Installation directory: $InstallDir"
|
||||
|
||||
New-Item -ItemType Directory -Path "$InstallDir\scripts" -Force | Out-Null
|
||||
New-Item -ItemType Directory -Path "$InstallDir\proxy" -Force | Out-Null
|
||||
|
||||
$Files = @(
|
||||
@{ Src = "docker/docker-compose.yml"; Dest = "docker-compose.yml" }
|
||||
@{ Src = "docker/docker-compose.gpu.yml"; Dest = "docker-compose.gpu.yml" }
|
||||
@{ Src = "docker/.env.example"; Dest = ".env.example" }
|
||||
@{ Src = "docker/proxy/Caddyfile"; Dest = "proxy/Caddyfile" }
|
||||
@{ Src = "docker/postgresql.conf"; Dest = "postgresql.conf" }
|
||||
@{ Src = "docker/scripts/migrate-database.ps1"; Dest = "scripts/migrate-database.ps1" }
|
||||
)
|
||||
|
||||
foreach ($f in $Files) {
|
||||
$destPath = Join-Path $InstallDir $f.Dest
|
||||
Write-Info "Downloading $($f.Dest)..."
|
||||
try {
|
||||
Invoke-WebRequest -Uri "$RepoRaw/$($f.Src)" -OutFile $destPath -UseBasicParsing
|
||||
} catch {
|
||||
Write-Err "Failed to download $($f.Dest). Check your internet connection and try again."
|
||||
}
|
||||
}
|
||||
|
||||
Write-Ok "All files downloaded to $InstallDir/"
|
||||
|
||||
# ── Legacy all-in-one detection ─────────────────────────────────────────────
|
||||
|
||||
$volumeList = Invoke-NativeSafe { docker volume ls --format '{{.Name}}' 2>$null }
|
||||
if (($volumeList -split "`n") -contains $OldVolume -and -not (Test-Path $MigrationDoneFile)) {
|
||||
$MigrationMode = $true
|
||||
|
||||
if (Test-Path $DumpFile) {
|
||||
Write-Step "Migration mode - using existing dump (skipping extraction)"
|
||||
Write-Info "Found existing dump: $DumpFile"
|
||||
Write-Info "Skipping data extraction - proceeding directly to restore."
|
||||
Write-Info "To force a fresh extraction, remove the dump first: Remove-Item $DumpFile"
|
||||
} else {
|
||||
Write-Step "Migration mode - legacy all-in-one container detected"
|
||||
Write-Warn "Volume '$OldVolume' found. Your data will be migrated automatically."
|
||||
Write-Warn "PostgreSQL is being upgraded from version 14 to 17."
|
||||
Write-Warn "Your original data will NOT be deleted."
|
||||
Write-Host ""
|
||||
Write-Info "Running data extraction (migrate-database.ps1 -Yes)..."
|
||||
Write-Info "Full extraction log: ./surfsense-migration.log"
|
||||
Write-Host ""
|
||||
|
||||
$migrateScript = Join-Path $InstallDir "scripts/migrate-database.ps1"
|
||||
& $migrateScript -Yes
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Err "Data extraction failed. See ./surfsense-migration.log for details.`nYou can also run migrate-database.ps1 manually with custom flags."
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Ok "Data extraction complete. Proceeding with installation and restore."
|
||||
}
|
||||
}
|
||||
|
||||
# ── Set up .env ─────────────────────────────────────────────────────────────
|
||||
|
||||
Write-Step "Configuring environment"
|
||||
|
||||
$envPath = Join-Path $InstallDir ".env"
|
||||
$envExamplePath = Join-Path $InstallDir ".env.example"
|
||||
|
||||
if (-not (Test-Path $envPath)) {
|
||||
Copy-Item $envExamplePath $envPath
|
||||
|
||||
if ($MigrationMode -and (Test-Path $KeyFile)) {
|
||||
$SecretKey = (Get-Content $KeyFile -Raw).Trim()
|
||||
Write-Ok "Using SECRET_KEY recovered from legacy container."
|
||||
} else {
|
||||
$bytes = New-Object byte[] 32
|
||||
$rng = [System.Security.Cryptography.RNGCryptoServiceProvider]::new()
|
||||
$rng.GetBytes($bytes)
|
||||
$rng.Dispose()
|
||||
$SecretKey = [Convert]::ToBase64String($bytes)
|
||||
Write-Ok "Generated new random SECRET_KEY."
|
||||
}
|
||||
|
||||
$content = Get-Content $envPath -Raw
|
||||
$content = $content -replace 'SECRET_KEY=replace_me_with_a_random_string', "SECRET_KEY=$SecretKey"
|
||||
Set-Content -Path $envPath -Value $content -NoNewline
|
||||
|
||||
Set-VariantEnv -Path $envPath -SelectedVariant $SelectedVariant -AllowExistingUpdate $false
|
||||
Write-Info "Created $envPath"
|
||||
} else {
|
||||
if ($PSBoundParameters.ContainsKey('Variant')) {
|
||||
Set-VariantEnv -Path $envPath -SelectedVariant $SelectedVariant -AllowExistingUpdate $true
|
||||
Write-Info "Updated SurfSense image variant in existing $envPath"
|
||||
} else {
|
||||
Set-VariantEnv -Path $envPath -SelectedVariant $SelectedVariant -AllowExistingUpdate $false
|
||||
}
|
||||
}
|
||||
|
||||
# ── Start containers ────────────────────────────────────────────────────────
|
||||
|
||||
if ($MigrationMode) {
|
||||
$envContent = Get-Content $envPath
|
||||
$DbUser = ($envContent | Select-String '^DB_USER=' | ForEach-Object { ($_ -split '=',2)[1].Trim('"') }) | Select-Object -First 1
|
||||
$DbPass = ($envContent | Select-String '^DB_PASSWORD=' | ForEach-Object { ($_ -split '=',2)[1].Trim('"') }) | Select-Object -First 1
|
||||
$DbName = ($envContent | Select-String '^DB_NAME=' | ForEach-Object { ($_ -split '=',2)[1].Trim('"') }) | Select-Object -First 1
|
||||
if (-not $DbUser) { $DbUser = "surfsense" }
|
||||
if (-not $DbPass) { $DbPass = "surfsense" }
|
||||
if (-not $DbName) { $DbName = "surfsense" }
|
||||
|
||||
Write-Step "Starting PostgreSQL 17"
|
||||
Push-Location $InstallDir
|
||||
Invoke-NativeSafe { docker compose up -d db } | Out-Null
|
||||
Pop-Location
|
||||
Wait-ForPostgres -DbUser $DbUser
|
||||
|
||||
Write-Step "Restoring database"
|
||||
if (-not (Test-Path $DumpFile)) {
|
||||
Write-Err "Dump file '$DumpFile' not found. The migration script may have failed."
|
||||
}
|
||||
$DumpFilePath = (Resolve-Path $DumpFile).Path
|
||||
Write-Info "Restoring dump into PostgreSQL 17 - this may take a while for large databases..."
|
||||
|
||||
$restoreErrFile = Join-Path $env:TEMP "surfsense_restore_err.log"
|
||||
Push-Location $InstallDir
|
||||
Invoke-NativeSafe { Get-Content -LiteralPath $DumpFilePath | docker compose exec -T -e "PGPASSWORD=$DbPass" db psql -U $DbUser -d $DbName 2>$restoreErrFile | Out-Null } | Out-Null
|
||||
Pop-Location
|
||||
|
||||
$fatalErrors = @()
|
||||
if (Test-Path $restoreErrFile) {
|
||||
$fatalErrors = Get-Content $restoreErrFile |
|
||||
Where-Object { $_ -match '^ERROR:' } |
|
||||
Where-Object { $_ -notmatch 'already exists' } |
|
||||
Where-Object { $_ -notmatch 'multiple primary keys' }
|
||||
}
|
||||
|
||||
if ($fatalErrors.Count -gt 0) {
|
||||
Write-Warn "Restore completed with errors (may be harmless pg_dump header noise):"
|
||||
$fatalErrors | ForEach-Object { Write-Host $_ }
|
||||
Write-Warn "If SurfSense behaves incorrectly, inspect manually."
|
||||
} else {
|
||||
Write-Ok "Database restored with no fatal errors."
|
||||
}
|
||||
|
||||
# Smoke test
|
||||
Push-Location $InstallDir
|
||||
$tableCount = (Invoke-NativeSafe { docker compose exec -T -e "PGPASSWORD=$DbPass" db psql -U $DbUser -d $DbName -t -c "SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public';" 2>$null }).Trim()
|
||||
Pop-Location
|
||||
|
||||
if (-not $tableCount -or $tableCount -eq "0") {
|
||||
Write-Warn "Smoke test: no tables found after restore."
|
||||
Write-Warn "The restore may have failed silently. Check: cd $InstallDir; docker compose logs db"
|
||||
} else {
|
||||
Write-Ok "Smoke test passed: $tableCount table(s) restored successfully."
|
||||
New-Item -Path $MigrationDoneFile -ItemType File -Force | Out-Null
|
||||
}
|
||||
|
||||
Write-Step "Starting all SurfSense services"
|
||||
Invoke-ComposeUpWait
|
||||
Write-Ok "All services started and healthy."
|
||||
|
||||
Remove-Item $KeyFile -ErrorAction SilentlyContinue
|
||||
|
||||
} else {
|
||||
Write-Step "Starting SurfSense"
|
||||
Invoke-ComposeUpWait
|
||||
Write-Ok "All services started and healthy."
|
||||
}
|
||||
|
||||
# ── Watchtower (auto-update) ────────────────────────────────────────────────
|
||||
|
||||
if ($SetupWatchtower) {
|
||||
$wtHours = [math]::Floor($WatchtowerInterval / 3600)
|
||||
Write-Step "Setting up Watchtower (auto-updates every ${wtHours}h)"
|
||||
|
||||
$wtState = Invoke-NativeSafe { docker inspect -f '{{.State.Running}}' $WatchtowerContainer 2>$null }
|
||||
if ($LASTEXITCODE -ne 0) { $wtState = "missing" }
|
||||
|
||||
if ($wtState -eq "true") {
|
||||
Write-Ok "Watchtower is already running - skipping."
|
||||
} else {
|
||||
if ($wtState -ne "missing") {
|
||||
Write-Info "Removing stopped Watchtower container..."
|
||||
Invoke-NativeSafe { docker rm -f $WatchtowerContainer *>$null } | Out-Null
|
||||
}
|
||||
Invoke-NativeSafe {
|
||||
docker run -d `
|
||||
--name $WatchtowerContainer `
|
||||
--restart unless-stopped `
|
||||
-v /var/run/docker.sock:/var/run/docker.sock `
|
||||
nickfedor/watchtower `
|
||||
--label-enable `
|
||||
--interval $WatchtowerInterval *>$null
|
||||
} | Out-Null
|
||||
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Ok "Watchtower started - labeled SurfSense containers will auto-update."
|
||||
} else {
|
||||
Write-Warn "Could not start Watchtower. You can set it up manually or use: docker compose pull; docker compose up -d --wait"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Write-Info "Skipping Watchtower setup (-NoWatchtower flag)."
|
||||
}
|
||||
|
||||
# ── Done ────────────────────────────────────────────────────────────────────
|
||||
|
||||
Write-Host ""
|
||||
$versionDisplay = (Get-Content $envPath | Select-String '^SURFSENSE_VERSION=' | ForEach-Object { ($_ -split '=',2)[1].Trim('"') }) | Select-Object -First 1
|
||||
if (-not $versionDisplay) { $versionDisplay = "latest" }
|
||||
$variantDisplay = (Get-Content $envPath | Select-String '^SURFSENSE_VARIANT=' | ForEach-Object { ($_ -split '=',2)[1].Trim('"') }) | Select-Object -First 1
|
||||
if (-not $variantDisplay) { $variantDisplay = "cpu" }
|
||||
$wtHours = [math]::Floor($WatchtowerInterval / 3600)
|
||||
Write-Step "SurfSense is now installed [$versionDisplay]"
|
||||
|
||||
Write-Info " Frontend: http://localhost:3929"
|
||||
Write-Info " Backend: http://localhost:8929"
|
||||
Write-Info " API Docs: http://localhost:8929/docs"
|
||||
Write-Info ""
|
||||
Write-Info " Config: $InstallDir\.env"
|
||||
Write-Info " Variant: $variantDisplay"
|
||||
Write-Info " Logs: cd $InstallDir; docker compose logs -f"
|
||||
Write-Info " Stop: cd $InstallDir; docker compose down"
|
||||
Write-Info " Update: cd $InstallDir; docker compose pull; docker compose up -d --wait"
|
||||
Write-Info ""
|
||||
|
||||
if ($SetupWatchtower) {
|
||||
Write-Info " Watchtower: auto-updates every ${wtHours}h (disable: docker rm -f $WatchtowerContainer)"
|
||||
} else {
|
||||
Write-Warn " Watchtower skipped. For auto-updates, re-run without -NoWatchtower."
|
||||
}
|
||||
Write-Info ""
|
||||
|
||||
if ($MigrationMode) {
|
||||
Write-Warn " Migration complete! Open frontend and verify your data."
|
||||
Write-Warn " Once verified, clean up the legacy volume and migration files:"
|
||||
Write-Warn " docker volume rm $OldVolume"
|
||||
Write-Warn " Remove-Item $DumpFile"
|
||||
Write-Warn " Remove-Item $MigrationDoneFile"
|
||||
} else {
|
||||
Write-Warn " First startup may take a few minutes while images are pulled."
|
||||
Write-Warn " Edit $InstallDir\.env to configure API keys, OAuth, etc."
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# SurfSense — One-line Install Script
|
||||
#
|
||||
#
|
||||
# Usage: curl -fsSL https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/install.sh | bash
|
||||
#
|
||||
# Flags:
|
||||
# --no-watchtower Skip automatic Watchtower setup
|
||||
# --watchtower-interval=SECS Check interval in seconds (default: 86400 = 24h)
|
||||
# --variant=cpu|cuda|cuda126 Select backend image variant
|
||||
# --gpu Alias for --variant=cuda
|
||||
# --cpu Alias for --variant=cpu
|
||||
# --gpu-count=N|all Number of GPUs to reserve when GPU is enabled
|
||||
# --quiet Skip interactive prompts
|
||||
#
|
||||
# Handles two cases automatically:
|
||||
# 1. Fresh install — no prior SurfSense data detected
|
||||
# 2. Migration from the legacy all-in-one container (surfsense-data volume)
|
||||
# Downloads and runs migrate-database.sh --yes, then restores the dump
|
||||
# into the new PostgreSQL 17 stack. The user runs one command for both.
|
||||
#
|
||||
# If you used custom database credentials in the old all-in-one container, run
|
||||
# migrate-database.sh manually first (with --db-user / --db-password flags),
|
||||
# then re-run this script:
|
||||
# curl -fsSL .../docker/scripts/migrate-database.sh | bash -s -- --db-user X --db-password Y
|
||||
# =============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
main() {
|
||||
|
||||
REPO_RAW="https://raw.githubusercontent.com/MODSetter/SurfSense/main"
|
||||
INSTALL_DIR="./surfsense"
|
||||
OLD_VOLUME="surfsense-data"
|
||||
DUMP_FILE="./surfsense_migration_backup.sql"
|
||||
KEY_FILE="./surfsense_migration_secret.key"
|
||||
MIGRATION_DONE_FILE="${INSTALL_DIR}/.migration_done"
|
||||
MIGRATION_MODE=false
|
||||
SETUP_WATCHTOWER=true
|
||||
WATCHTOWER_INTERVAL=86400
|
||||
WATCHTOWER_CONTAINER="watchtower"
|
||||
WATCHTOWER_EXPLICIT=false
|
||||
REQUESTED_VARIANT=""
|
||||
VARIANT_EXPLICIT=false
|
||||
GPU_COUNT=""
|
||||
QUIET=false
|
||||
|
||||
# ── Parse flags ─────────────────────────────────────────────────────────────
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--no-watchtower) SETUP_WATCHTOWER=false; WATCHTOWER_EXPLICIT=true ;;
|
||||
--watchtower-interval=*) WATCHTOWER_INTERVAL="${arg#*=}" ;;
|
||||
--variant=*) REQUESTED_VARIANT="${arg#*=}"; VARIANT_EXPLICIT=true ;;
|
||||
--gpu) REQUESTED_VARIANT="cuda"; VARIANT_EXPLICIT=true ;;
|
||||
--cpu) REQUESTED_VARIANT="cpu"; VARIANT_EXPLICIT=true ;;
|
||||
--gpu-count=*) GPU_COUNT="${arg#*=}" ;;
|
||||
--quiet) QUIET=true ;;
|
||||
esac
|
||||
done
|
||||
|
||||
CYAN='\033[1;36m'
|
||||
YELLOW='\033[1;33m'
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
info() { printf "${CYAN}[SurfSense]${NC} %s\n" "$1"; }
|
||||
success() { printf "${GREEN}[SurfSense]${NC} %s\n" "$1"; }
|
||||
warn() { printf "${YELLOW}[SurfSense]${NC} %s\n" "$1"; }
|
||||
error() { printf "${RED}[SurfSense]${NC} ERROR: %s\n" "$1" >&2; exit 1; }
|
||||
step() { printf "\n${BOLD}${CYAN}── %s${NC}\n" "$1"; }
|
||||
|
||||
show_banner() {
|
||||
echo ""
|
||||
printf '\033[1;37m'
|
||||
cat << 'EOF'
|
||||
|
||||
|
||||
███████╗██╗ ██╗██████╗ ███████╗███████╗███████╗███╗ ██╗███████╗███████╗
|
||||
██╔════╝██║ ██║██╔══██╗██╔════╝██╔════╝██╔════╝████╗ ██║██╔════╝██╔════╝
|
||||
███████╗██║ ██║██████╔╝█████╗ ███████╗█████╗ ██╔██╗ ██║███████╗█████╗
|
||||
╚════██║██║ ██║██╔══██╗██╔══╝ ╚════██║██╔══╝ ██║╚██╗██║╚════██║██╔══╝
|
||||
███████║╚██████╔╝██║ ██║██║ ███████║███████╗██║ ╚████║███████║███████╗
|
||||
╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚══════╝╚══════╝╚═╝ ╚═══╝╚══════╝╚══════╝
|
||||
|
||||
|
||||
EOF
|
||||
printf "${YELLOW} OSS Alternative to NotebookLM for Teams${NC}\n"
|
||||
printf "${CYAN}══════════════════════════════════════════════════════════════${NC}\n"
|
||||
info "This installer will create ${INSTALL_DIR}/ and start SurfSense with Docker Compose."
|
||||
}
|
||||
|
||||
show_banner
|
||||
|
||||
case "${REQUESTED_VARIANT}" in
|
||||
""|cpu|cuda|cuda126) ;;
|
||||
*) error "Invalid --variant='${REQUESTED_VARIANT}'. Use cpu, cuda, or cuda126." ;;
|
||||
esac
|
||||
|
||||
if [[ -n "${GPU_COUNT}" && ! "${GPU_COUNT}" =~ ^([0-9]+|all)$ ]]; then
|
||||
error "Invalid --gpu-count='${GPU_COUNT}'. Use a number or 'all'."
|
||||
fi
|
||||
|
||||
resolve_watchtower_preference() {
|
||||
if $WATCHTOWER_EXPLICIT || $QUIET || [[ ! -r /dev/tty || ! -w /dev/tty ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local choice
|
||||
echo "" > /dev/tty
|
||||
printf "${BOLD}${CYAN}Automatic updates${NC}\n" > /dev/tty
|
||||
printf "Enable automatic daily updates with Watchtower? (may download several GB in the background) [Y/n]: " > /dev/tty
|
||||
read -r choice < /dev/tty || choice=""
|
||||
|
||||
case "$choice" in
|
||||
""|[Yy]|[Yy][Ee][Ss]) SETUP_WATCHTOWER=true ;;
|
||||
[Nn]|[Nn][Oo]) SETUP_WATCHTOWER=false ;;
|
||||
*) warn "Unrecognized choice '${choice}', enabling Watchtower by default. Use --no-watchtower to skip it." >&2; SETUP_WATCHTOWER=true ;;
|
||||
esac
|
||||
}
|
||||
|
||||
resolve_watchtower_preference
|
||||
|
||||
# ── Pre-flight checks ────────────────────────────────────────────────────────
|
||||
|
||||
step "Checking prerequisites"
|
||||
|
||||
command -v docker >/dev/null 2>&1 \
|
||||
|| error "Docker is not installed. Install it at: https://docs.docker.com/get-docker/"
|
||||
success "Docker found."
|
||||
|
||||
docker info >/dev/null 2>&1 < /dev/null \
|
||||
|| error "Docker daemon is not running. Please start Docker and try again."
|
||||
success "Docker daemon is running."
|
||||
|
||||
if docker compose version >/dev/null 2>&1 < /dev/null; then
|
||||
DC="docker compose"
|
||||
elif command -v docker-compose >/dev/null 2>&1; then
|
||||
DC="docker-compose"
|
||||
else
|
||||
error "Docker Compose is not installed. Install it at: https://docs.docker.com/compose/install/"
|
||||
fi
|
||||
success "Docker Compose found ($DC)."
|
||||
|
||||
# ── Wait-for-postgres helper ─────────────────────────────────────────────────
|
||||
wait_for_pg() {
|
||||
local db_user="$1"
|
||||
local max_attempts=45
|
||||
local attempt=0
|
||||
|
||||
info "Waiting for PostgreSQL to accept connections..."
|
||||
until (cd "${INSTALL_DIR}" && ${DC} exec -T db pg_isready -U "${db_user}" -q 2>/dev/null) < /dev/null; do
|
||||
attempt=$((attempt + 1))
|
||||
if [[ $attempt -ge $max_attempts ]]; then
|
||||
error "PostgreSQL did not become ready after $((max_attempts * 2)) seconds.\nCheck logs: cd ${INSTALL_DIR} && ${DC} logs db"
|
||||
fi
|
||||
printf "."
|
||||
sleep 2
|
||||
done
|
||||
printf "\n"
|
||||
success "PostgreSQL is ready."
|
||||
}
|
||||
|
||||
# ── Stack startup helper ─────────────────────────────────────────────────────
|
||||
|
||||
stack_failure_report() {
|
||||
echo ""
|
||||
echo -e "\033[31m[ERROR]\033[0m Stack did not reach a healthy state."
|
||||
echo ""
|
||||
info "Recent logs from migrations / zero-cache / backend:"
|
||||
(cd "${INSTALL_DIR}" && ${DC} logs --tail=60 migrations zero-cache backend 2>&1) || true
|
||||
echo ""
|
||||
echo "Recovery hints:"
|
||||
echo " 1. Inspect migrations: cd ${INSTALL_DIR} && ${DC} logs migrations"
|
||||
echo " 2. Verify publication: cd ${INSTALL_DIR} && ${DC} exec db psql -U surfsense -d surfsense -c 'SELECT pubname FROM pg_publication;'"
|
||||
echo " 3. Hard reset zero db: cd ${INSTALL_DIR} && ${DC} down && docker volume rm surfsense-zero-cache && ${DC} up -d --wait"
|
||||
echo ""
|
||||
exit 1
|
||||
}
|
||||
|
||||
compose_up_wait() {
|
||||
local service="${1:-}"
|
||||
if [[ -n "$service" ]]; then
|
||||
(cd "${INSTALL_DIR}" && ${DC} up -d --wait "$service") < /dev/null
|
||||
else
|
||||
(cd "${INSTALL_DIR}" && ${DC} up -d --wait) < /dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Variant and .env helpers ─────────────────────────────────────────────────
|
||||
|
||||
set_env_value() {
|
||||
local file="$1"
|
||||
local key="$2"
|
||||
local value="$3"
|
||||
local tmp
|
||||
tmp=$(mktemp)
|
||||
|
||||
if grep -q "^${key}=" "$file" 2>/dev/null; then
|
||||
awk -v key="$key" -v value="$value" 'BEGIN { prefix = key "=" } $0 ~ "^" prefix { print prefix value; next } { print }' "$file" > "$tmp"
|
||||
else
|
||||
cp "$file" "$tmp"
|
||||
printf '\n%s=%s\n' "$key" "$value" >> "$tmp"
|
||||
fi
|
||||
mv "$tmp" "$file"
|
||||
}
|
||||
|
||||
remove_env_value() {
|
||||
local file="$1"
|
||||
local key="$2"
|
||||
local tmp
|
||||
tmp=$(mktemp)
|
||||
awk -v key="$key" 'BEGIN { prefix = key "=" } $0 !~ "^" prefix { print }' "$file" > "$tmp"
|
||||
mv "$tmp" "$file"
|
||||
}
|
||||
|
||||
version_major() {
|
||||
printf '%s' "$1" | cut -d. -f1
|
||||
}
|
||||
|
||||
recommend_cuda_variant() {
|
||||
local driver_version driver_major
|
||||
driver_version=$(nvidia-smi --query-gpu=driver_version --format=csv,noheader 2>/dev/null | head -n 1 | tr -d '[:space:]' || true)
|
||||
driver_major=$(version_major "$driver_version")
|
||||
|
||||
# CUDA 12.8 generally requires an R570+ driver. Use CUDA 12.6 as the
|
||||
# compatibility fallback for older 12.x driver stacks and GPUs.
|
||||
if [[ "$driver_major" =~ ^[0-9]+$ && "$driver_major" -lt 570 ]]; then
|
||||
printf 'cuda126'
|
||||
else
|
||||
printf 'cuda'
|
||||
fi
|
||||
}
|
||||
|
||||
gpu_runtime_available() {
|
||||
docker info 2>/dev/null | grep -qi 'nvidia' \
|
||||
|| command -v nvidia-ctk >/dev/null 2>&1 \
|
||||
|| command -v nvidia-container-runtime >/dev/null 2>&1
|
||||
}
|
||||
|
||||
host_has_nvidia_gpu() {
|
||||
command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi >/dev/null 2>&1
|
||||
}
|
||||
|
||||
resolve_variant() {
|
||||
local detected_variant="cpu"
|
||||
local has_gpu=false
|
||||
local has_runtime=false
|
||||
|
||||
if host_has_nvidia_gpu; then
|
||||
has_gpu=true
|
||||
detected_variant=$(recommend_cuda_variant)
|
||||
if gpu_runtime_available; then
|
||||
has_runtime=true
|
||||
fi
|
||||
fi
|
||||
|
||||
if $VARIANT_EXPLICIT; then
|
||||
if [[ "$REQUESTED_VARIANT" == "cpu" ]]; then
|
||||
printf 'cpu'
|
||||
return 0
|
||||
fi
|
||||
if ! $has_gpu; then
|
||||
warn "No NVIDIA GPU detected; falling back to CPU variant." >&2
|
||||
printf 'cpu'
|
||||
return 0
|
||||
fi
|
||||
if ! $has_runtime; then
|
||||
warn "NVIDIA GPU detected, but NVIDIA Container Toolkit was not detected; falling back to CPU variant." >&2
|
||||
warn "Install the toolkit before enabling SurfSense GPU acceleration." >&2
|
||||
printf 'cpu'
|
||||
return 0
|
||||
fi
|
||||
printf '%s' "$REQUESTED_VARIANT"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if $has_gpu && ! $has_runtime; then
|
||||
warn "NVIDIA GPU detected, but NVIDIA Container Toolkit was not detected; using CPU variant." >&2
|
||||
fi
|
||||
|
||||
if $has_gpu && $has_runtime && ! $QUIET && [[ -r /dev/tty && -w /dev/tty ]]; then
|
||||
local choice
|
||||
echo "" > /dev/tty
|
||||
printf "${BOLD}${CYAN}SurfSense detected an NVIDIA GPU.${NC}\n" > /dev/tty
|
||||
printf "Use GPU acceleration? [Y/n]: " > /dev/tty
|
||||
read -r choice < /dev/tty || choice=""
|
||||
case "$choice" in
|
||||
"") printf '%s' "$detected_variant" ;;
|
||||
[Yy]|[Yy][Ee][Ss]) printf '%s' "$detected_variant" ;;
|
||||
[Nn]|[Nn][Oo]) printf 'cpu' ;;
|
||||
*) warn "Unrecognized choice '${choice}', using CPU variant." >&2; printf 'cpu' ;;
|
||||
esac
|
||||
return 0
|
||||
fi
|
||||
|
||||
printf 'cpu'
|
||||
}
|
||||
|
||||
apply_variant_env() {
|
||||
local env_file="$1"
|
||||
local variant="$2"
|
||||
local allow_existing_update="$3"
|
||||
|
||||
if [[ -f "$env_file" && "$allow_existing_update" != "true" ]]; then
|
||||
warn ".env already exists — keeping your existing configuration."
|
||||
info "To change variants later, edit SURFSENSE_VARIANT and COMPOSE_FILE in ${env_file}, then run ${DC} up -d --wait."
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$variant" == "cpu" ]]; then
|
||||
set_env_value "$env_file" "SURFSENSE_VARIANT" ""
|
||||
remove_env_value "$env_file" "COMPOSE_FILE"
|
||||
remove_env_value "$env_file" "SURFSENSE_GPU_COUNT"
|
||||
else
|
||||
set_env_value "$env_file" "SURFSENSE_VARIANT" "$variant"
|
||||
set_env_value "$env_file" "COMPOSE_FILE" "docker-compose.yml:docker-compose.gpu.yml"
|
||||
if [[ -n "$GPU_COUNT" ]]; then
|
||||
set_env_value "$env_file" "SURFSENSE_GPU_COUNT" "$GPU_COUNT"
|
||||
fi
|
||||
fi
|
||||
|
||||
remove_env_value "$env_file" "COMPOSE_PROFILES"
|
||||
}
|
||||
|
||||
SELECTED_VARIANT=$(resolve_variant)
|
||||
|
||||
# ── Download files ───────────────────────────────────────────────────────────
|
||||
|
||||
step "Downloading SurfSense files"
|
||||
info "Installation directory: ${INSTALL_DIR}"
|
||||
mkdir -p "${INSTALL_DIR}/scripts"
|
||||
mkdir -p "${INSTALL_DIR}/proxy"
|
||||
|
||||
FILES=(
|
||||
"docker/docker-compose.yml:docker-compose.yml"
|
||||
"docker/docker-compose.gpu.yml:docker-compose.gpu.yml"
|
||||
"docker/.env.example:.env.example"
|
||||
"docker/proxy/Caddyfile:proxy/Caddyfile"
|
||||
"docker/postgresql.conf:postgresql.conf"
|
||||
"docker/scripts/migrate-database.sh:scripts/migrate-database.sh"
|
||||
)
|
||||
|
||||
for entry in "${FILES[@]}"; do
|
||||
src="${entry%%:*}"
|
||||
dest="${entry##*:}"
|
||||
info "Downloading ${dest}..."
|
||||
curl -fsSL "${REPO_RAW}/${src}" -o "${INSTALL_DIR}/${dest}" \
|
||||
|| error "Failed to download ${dest}. Check your internet connection and try again."
|
||||
done
|
||||
|
||||
chmod +x "${INSTALL_DIR}/scripts/migrate-database.sh"
|
||||
success "All files downloaded to ${INSTALL_DIR}/"
|
||||
|
||||
# ── Legacy all-in-one detection ──────────────────────────────────────────────
|
||||
# Detect surfsense-data volume → migration mode.
|
||||
# If a dump already exists (from a previous partial run) skip extraction and
|
||||
# go straight to restore — this makes re-runs safe and idempotent.
|
||||
|
||||
if docker volume ls --format '{{.Name}}' 2>/dev/null < /dev/null | grep -q "^${OLD_VOLUME}$" \
|
||||
&& [[ ! -f "${MIGRATION_DONE_FILE}" ]]; then
|
||||
MIGRATION_MODE=true
|
||||
|
||||
if [[ -f "${DUMP_FILE}" ]]; then
|
||||
step "Migration mode — using existing dump (skipping extraction)"
|
||||
info "Found existing dump: ${DUMP_FILE}"
|
||||
info "Skipping data extraction — proceeding directly to restore."
|
||||
info "To force a fresh extraction, remove the dump first: rm ${DUMP_FILE}"
|
||||
else
|
||||
step "Migration mode — legacy all-in-one container detected"
|
||||
warn "Volume '${OLD_VOLUME}' found. Your data will be migrated automatically."
|
||||
warn "PostgreSQL is being upgraded from version 14 to 17."
|
||||
warn "Your original data will NOT be deleted."
|
||||
printf "\n"
|
||||
info "Running data extraction (migrate-database.sh --yes)..."
|
||||
info "Full extraction log: ./surfsense-migration.log"
|
||||
printf "\n"
|
||||
|
||||
# Run extraction non-interactively. On failure the error from
|
||||
# migrate-database.sh is printed and install.sh exits here.
|
||||
bash "${INSTALL_DIR}/scripts/migrate-database.sh" --yes < /dev/null \
|
||||
|| error "Data extraction failed. See ./surfsense-migration.log for details.\nYou can also run migrate-database.sh manually with custom flags:\n bash ${INSTALL_DIR}/scripts/migrate-database.sh --db-user X --db-password Y"
|
||||
|
||||
printf "\n"
|
||||
success "Data extraction complete. Proceeding with installation and restore."
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Set up .env ──────────────────────────────────────────────────────────────
|
||||
|
||||
step "Configuring environment"
|
||||
|
||||
if [ ! -f "${INSTALL_DIR}/.env" ]; then
|
||||
cp "${INSTALL_DIR}/.env.example" "${INSTALL_DIR}/.env"
|
||||
|
||||
if $MIGRATION_MODE && [[ -f "${KEY_FILE}" ]]; then
|
||||
SECRET_KEY=$(cat "${KEY_FILE}" | tr -d '[:space:]')
|
||||
success "Using SECRET_KEY recovered from legacy container."
|
||||
else
|
||||
SECRET_KEY=$(openssl rand -base64 32 2>/dev/null \
|
||||
|| head -c 32 /dev/urandom | base64 | tr -d '\n')
|
||||
success "Generated new random SECRET_KEY."
|
||||
fi
|
||||
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
sed -i '' "s|SECRET_KEY=replace_me_with_a_random_string|SECRET_KEY=${SECRET_KEY}|" "${INSTALL_DIR}/.env"
|
||||
else
|
||||
sed -i "s|SECRET_KEY=replace_me_with_a_random_string|SECRET_KEY=${SECRET_KEY}|" "${INSTALL_DIR}/.env"
|
||||
fi
|
||||
apply_variant_env "${INSTALL_DIR}/.env" "$SELECTED_VARIANT" "false"
|
||||
info "Created ${INSTALL_DIR}/.env"
|
||||
else
|
||||
if $VARIANT_EXPLICIT; then
|
||||
apply_variant_env "${INSTALL_DIR}/.env" "$SELECTED_VARIANT" "true"
|
||||
info "Updated SurfSense image variant in existing ${INSTALL_DIR}/.env"
|
||||
else
|
||||
apply_variant_env "${INSTALL_DIR}/.env" "$SELECTED_VARIANT" "false"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Start containers ─────────────────────────────────────────────────────────
|
||||
|
||||
if $MIGRATION_MODE; then
|
||||
# Read DB credentials from .env (fall back to defaults from docker-compose.yml)
|
||||
DB_USER=$(grep '^DB_USER=' "${INSTALL_DIR}/.env" 2>/dev/null | cut -d= -f2 | tr -d '"' | head -1 || true)
|
||||
DB_PASS=$(grep '^DB_PASSWORD=' "${INSTALL_DIR}/.env" 2>/dev/null | cut -d= -f2 | tr -d '"' | head -1 || true)
|
||||
DB_NAME=$(grep '^DB_NAME=' "${INSTALL_DIR}/.env" 2>/dev/null | cut -d= -f2 | tr -d '"' | head -1 || true)
|
||||
DB_USER="${DB_USER:-surfsense}"
|
||||
DB_PASS="${DB_PASS:-surfsense}"
|
||||
DB_NAME="${DB_NAME:-surfsense}"
|
||||
|
||||
step "Starting PostgreSQL 17"
|
||||
(cd "${INSTALL_DIR}" && ${DC} up -d db) < /dev/null
|
||||
wait_for_pg "${DB_USER}"
|
||||
|
||||
step "Restoring database"
|
||||
[[ -f "${DUMP_FILE}" ]] \
|
||||
|| error "Dump file '${DUMP_FILE}' not found. The migration script may have failed.\n Check: ./surfsense-migration.log\n Or run manually: bash ${INSTALL_DIR}/scripts/migrate-database.sh --yes"
|
||||
info "Restoring dump into PostgreSQL 17 — this may take a while for large databases..."
|
||||
|
||||
RESTORE_ERR="/tmp/surfsense_restore_err.log"
|
||||
(cd "${INSTALL_DIR}" && ${DC} exec -T \
|
||||
-e PGPASSWORD="${DB_PASS}" \
|
||||
db psql -U "${DB_USER}" -d "${DB_NAME}" \
|
||||
>/dev/null 2>"${RESTORE_ERR}") < "${DUMP_FILE}" || true
|
||||
|
||||
# Surface real errors; ignore benign "already exists" noise from pg_dump headers
|
||||
FATAL_ERRORS=$(grep -i "^ERROR:" "${RESTORE_ERR}" \
|
||||
| grep -iv "already exists" \
|
||||
| grep -iv "multiple primary keys" \
|
||||
|| true)
|
||||
|
||||
if [[ -n "${FATAL_ERRORS}" ]]; then
|
||||
warn "Restore completed with errors (may be harmless pg_dump header noise):"
|
||||
printf "%s\n" "${FATAL_ERRORS}"
|
||||
warn "If SurfSense behaves incorrectly, inspect manually:"
|
||||
warn " cd ${INSTALL_DIR} && ${DC} exec db psql -U ${DB_USER} -d ${DB_NAME} < ${DUMP_FILE}"
|
||||
else
|
||||
success "Database restored with no fatal errors."
|
||||
fi
|
||||
|
||||
# Smoke test — verify tables are present
|
||||
TABLE_COUNT=$(
|
||||
cd "${INSTALL_DIR}" && ${DC} exec -T \
|
||||
-e PGPASSWORD="${DB_PASS}" \
|
||||
db psql -U "${DB_USER}" -d "${DB_NAME}" -t \
|
||||
-c "SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public';" \
|
||||
2>/dev/null < /dev/null | tr -d ' \n' || echo "0"
|
||||
)
|
||||
if [[ "${TABLE_COUNT}" == "0" || -z "${TABLE_COUNT}" ]]; then
|
||||
warn "Smoke test: no tables found after restore."
|
||||
warn "The restore may have failed silently. Check: cd ${INSTALL_DIR} && ${DC} logs db"
|
||||
else
|
||||
success "Smoke test passed: ${TABLE_COUNT} table(s) restored successfully."
|
||||
touch "${MIGRATION_DONE_FILE}"
|
||||
fi
|
||||
|
||||
step "Starting all SurfSense services"
|
||||
if ! compose_up_wait; then
|
||||
stack_failure_report
|
||||
fi
|
||||
success "All services started and healthy."
|
||||
|
||||
# Key file is no longer needed — SECRET_KEY is now in .env
|
||||
rm -f "${KEY_FILE}"
|
||||
|
||||
else
|
||||
step "Starting SurfSense"
|
||||
if ! compose_up_wait; then
|
||||
stack_failure_report
|
||||
fi
|
||||
success "All services started and healthy."
|
||||
fi
|
||||
|
||||
# ── Watchtower (auto-update) ─────────────────────────────────────────────────
|
||||
|
||||
if $SETUP_WATCHTOWER; then
|
||||
step "Setting up Watchtower (auto-updates every $((WATCHTOWER_INTERVAL / 3600))h)"
|
||||
|
||||
WT_STATE=$(docker inspect -f '{{.State.Running}}' "${WATCHTOWER_CONTAINER}" 2>/dev/null < /dev/null || echo "missing")
|
||||
|
||||
if [[ "${WT_STATE}" == "true" ]]; then
|
||||
success "Watchtower is already running — skipping."
|
||||
else
|
||||
if [[ "${WT_STATE}" != "missing" ]]; then
|
||||
info "Removing stopped Watchtower container..."
|
||||
docker rm -f "${WATCHTOWER_CONTAINER}" >/dev/null 2>&1 < /dev/null || true
|
||||
fi
|
||||
docker run -d \
|
||||
--name "${WATCHTOWER_CONTAINER}" \
|
||||
--restart unless-stopped \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
nickfedor/watchtower \
|
||||
--label-enable \
|
||||
--interval "${WATCHTOWER_INTERVAL}" >/dev/null 2>&1 < /dev/null \
|
||||
&& success "Watchtower started — labeled SurfSense containers will auto-update." \
|
||||
|| warn "Could not start Watchtower. You can set it up manually or use: docker compose pull && docker compose up -d --wait"
|
||||
fi
|
||||
else
|
||||
info "Skipping Watchtower setup (--no-watchtower flag)."
|
||||
fi
|
||||
|
||||
# ── Done ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
echo ""
|
||||
_version_display=$(grep '^SURFSENSE_VERSION=' "${INSTALL_DIR}/.env" 2>/dev/null | cut -d= -f2 | tr -d '"' | head -1 || true)
|
||||
_version_display="${_version_display:-latest}"
|
||||
_variant_display=$(grep '^SURFSENSE_VARIANT=' "${INSTALL_DIR}/.env" 2>/dev/null | cut -d= -f2 | tr -d '"' | head -1 || true)
|
||||
_variant_display="${_variant_display:-cpu}"
|
||||
step "SurfSense is now installed [${_version_display}]"
|
||||
|
||||
_public_url=$(grep '^SURFSENSE_PUBLIC_URL=' "${INSTALL_DIR}/.env" 2>/dev/null | cut -d= -f2- | tr -d '"' | head -1 || true)
|
||||
_public_url="${_public_url:-http://localhost:3929}"
|
||||
|
||||
info " SurfSense: ${_public_url}"
|
||||
info " Backend: ${_public_url}/api/v1"
|
||||
info " Zero sync: ${_public_url}/zero"
|
||||
info ""
|
||||
info " Config: ${INSTALL_DIR}/.env"
|
||||
info " Variant: ${_variant_display}"
|
||||
info " Logs: cd ${INSTALL_DIR} && ${DC} logs -f"
|
||||
info " Stop: cd ${INSTALL_DIR} && ${DC} down"
|
||||
info " Update: cd ${INSTALL_DIR} && ${DC} pull && ${DC} up -d --wait"
|
||||
info ""
|
||||
|
||||
if $SETUP_WATCHTOWER; then
|
||||
info " Watchtower: auto-updates every $((WATCHTOWER_INTERVAL / 3600))h (disable: docker rm -f ${WATCHTOWER_CONTAINER})"
|
||||
else
|
||||
warn " Watchtower skipped. For auto-updates, re-run without --no-watchtower."
|
||||
fi
|
||||
info ""
|
||||
|
||||
if $MIGRATION_MODE; then
|
||||
warn " Migration complete! Open frontend and verify your data."
|
||||
warn " Once verified, clean up the legacy volume and migration files:"
|
||||
warn " docker volume rm ${OLD_VOLUME}"
|
||||
warn " rm ${DUMP_FILE}"
|
||||
warn " rm ${MIGRATION_DONE_FILE}"
|
||||
else
|
||||
warn " First startup may take a few minutes while images are pulled."
|
||||
warn " Edit ${INSTALL_DIR}/.env to configure API keys, OAuth, etc."
|
||||
fi
|
||||
|
||||
} # end main()
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,343 @@
|
||||
# =============================================================================
|
||||
# SurfSense — Database Migration Script (Windows / PowerShell)
|
||||
#
|
||||
# Extracts data from the legacy all-in-one surfsense-data volume (PostgreSQL 14)
|
||||
# and saves it as a SQL dump + SECRET_KEY file ready for install.ps1 to restore.
|
||||
#
|
||||
# Usage:
|
||||
# .\migrate-database.ps1 [options]
|
||||
#
|
||||
# Options:
|
||||
# -DbUser USER Old PostgreSQL username (default: surfsense)
|
||||
# -DbPassword PASS Old PostgreSQL password (default: surfsense)
|
||||
# -DbName NAME Old PostgreSQL database (default: surfsense)
|
||||
# -Yes Skip all confirmation prompts
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Docker Desktop installed and running
|
||||
# - The legacy surfsense-data volume must exist
|
||||
# - ~500 MB free disk space for the dump file
|
||||
#
|
||||
# What this script does:
|
||||
# 1. Stops any container using surfsense-data (to prevent corruption)
|
||||
# 2. Starts a temporary PG14 container against the old volume
|
||||
# 3. Dumps the database to .\surfsense_migration_backup.sql
|
||||
# 4. Recovers the SECRET_KEY to .\surfsense_migration_secret.key
|
||||
# 5. Exits — leaving installation to install.ps1
|
||||
#
|
||||
# What this script does NOT do:
|
||||
# - Delete the original surfsense-data volume (do this manually after verifying)
|
||||
# - Install the new SurfSense stack (install.ps1 handles that automatically)
|
||||
#
|
||||
# Note:
|
||||
# install.ps1 downloads and runs this script automatically when it detects the
|
||||
# legacy surfsense-data volume. You only need to run this script manually if
|
||||
# you have custom database credentials (-DbUser / -DbPassword / -DbName)
|
||||
# or if the automatic migration inside install.ps1 fails at the extraction step.
|
||||
# =============================================================================
|
||||
|
||||
param(
|
||||
[string]$DbUser = "surfsense",
|
||||
[string]$DbPassword = "surfsense",
|
||||
[string]$DbName = "surfsense",
|
||||
[switch]$Yes
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# ── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
$OldVolume = "surfsense-data"
|
||||
$TempContainer = "surfsense-pg14-migration"
|
||||
$DumpFile = ".\surfsense_migration_backup.sql"
|
||||
$KeyFile = ".\surfsense_migration_secret.key"
|
||||
$PG14Image = "pgvector/pgvector:pg14"
|
||||
$LogFile = ".\surfsense-migration.log"
|
||||
|
||||
# ── Output helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function Write-Info { param([string]$Msg) Write-Host "[SurfSense] " -ForegroundColor Cyan -NoNewline; Write-Host $Msg }
|
||||
function Write-Ok { param([string]$Msg) Write-Host "[SurfSense] " -ForegroundColor Green -NoNewline; Write-Host $Msg }
|
||||
function Write-Warn { param([string]$Msg) Write-Host "[SurfSense] " -ForegroundColor Yellow -NoNewline; Write-Host $Msg }
|
||||
function Write-Step { param([string]$Step, [string]$Msg) Write-Host "`n-- Step ${Step}: $Msg" -ForegroundColor Cyan }
|
||||
function Write-Err { param([string]$Msg) Write-Host "[SurfSense] ERROR: $Msg" -ForegroundColor Red; exit 1 }
|
||||
|
||||
function Log { param([string]$Msg) Add-Content -Path $LogFile -Value $Msg }
|
||||
|
||||
function Invoke-NativeSafe {
|
||||
param([scriptblock]$Command)
|
||||
$previousErrorActionPreference = $ErrorActionPreference
|
||||
try {
|
||||
$ErrorActionPreference = 'Continue'
|
||||
& $Command
|
||||
} finally {
|
||||
$ErrorActionPreference = $previousErrorActionPreference
|
||||
}
|
||||
}
|
||||
|
||||
function Confirm-Action {
|
||||
param([string]$Prompt)
|
||||
if ($Yes) { return }
|
||||
$reply = Read-Host "[SurfSense] $Prompt [y/N]"
|
||||
if ($reply -notmatch '^[Yy]$') {
|
||||
Write-Warn "Aborted."
|
||||
exit 0
|
||||
}
|
||||
}
|
||||
|
||||
# ── Cleanup helper ───────────────────────────────────────────────────────────
|
||||
|
||||
function Remove-TempContainer {
|
||||
$containers = Invoke-NativeSafe { docker ps -a --format '{{.Names}}' 2>$null }
|
||||
if ($containers -and ($containers -split "`n") -contains $TempContainer) {
|
||||
Write-Info "Cleaning up temporary container '$TempContainer'..."
|
||||
Invoke-NativeSafe { docker stop $TempContainer *>$null } | Out-Null
|
||||
Invoke-NativeSafe { docker rm $TempContainer *>$null } | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
# Register cleanup on script exit
|
||||
Register-EngineEvent PowerShell.Exiting -Action {
|
||||
$containers = Invoke-NativeSafe { docker ps -a --format '{{.Names}}' 2>$null }
|
||||
if ($containers -and ($containers -split "`n") -contains "surfsense-pg14-migration") {
|
||||
Invoke-NativeSafe { docker stop "surfsense-pg14-migration" *>$null } | Out-Null
|
||||
Invoke-NativeSafe { docker rm "surfsense-pg14-migration" *>$null } | Out-Null
|
||||
}
|
||||
} | Out-Null
|
||||
|
||||
# ── Wait-for-postgres helper ────────────────────────────────────────────────
|
||||
|
||||
function Wait-ForPostgres {
|
||||
param(
|
||||
[string]$Container,
|
||||
[string]$User,
|
||||
[string]$Label = "PostgreSQL"
|
||||
)
|
||||
$maxAttempts = 45
|
||||
$attempt = 0
|
||||
|
||||
Write-Info "Waiting for $Label to accept connections..."
|
||||
do {
|
||||
$attempt++
|
||||
if ($attempt -ge $maxAttempts) {
|
||||
Write-Err "$Label did not become ready after $($maxAttempts * 2) seconds. Check: docker logs $Container"
|
||||
}
|
||||
Start-Sleep -Seconds 2
|
||||
Invoke-NativeSafe { docker exec $Container pg_isready -U $User -q 2>$null } | Out-Null
|
||||
} while ($LASTEXITCODE -ne 0)
|
||||
|
||||
Write-Ok "$Label is ready."
|
||||
}
|
||||
|
||||
Write-Info "Migrating data from legacy database (PostgreSQL 14 -> 17)"
|
||||
"Migration started at $(Get-Date)" | Out-File $LogFile
|
||||
|
||||
# ── Step 0: Pre-flight checks ───────────────────────────────────────────────
|
||||
|
||||
Write-Step "0" "Pre-flight checks"
|
||||
|
||||
if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {
|
||||
Write-Err "Docker is not installed. Install Docker Desktop: https://docs.docker.com/desktop/install/windows-install/"
|
||||
}
|
||||
|
||||
Invoke-NativeSafe { docker info *>$null } | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Err "Docker daemon is not running. Please start Docker Desktop and try again."
|
||||
}
|
||||
|
||||
$volumeList = Invoke-NativeSafe { docker volume ls --format '{{.Name}}' 2>$null }
|
||||
if (-not (($volumeList -split "`n") -contains $OldVolume)) {
|
||||
Write-Err "Legacy volume '$OldVolume' not found. Are you sure you ran the old all-in-one SurfSense container?"
|
||||
}
|
||||
Write-Ok "Found legacy volume: $OldVolume"
|
||||
|
||||
$oldContainer = (Invoke-NativeSafe { docker ps --filter "volume=$OldVolume" --format '{{.Names}}' 2>$null } | Select-Object -First 1)
|
||||
if ($oldContainer) {
|
||||
Write-Warn "Container '$oldContainer' is running and using the '$OldVolume' volume."
|
||||
Write-Warn "It must be stopped before migration to prevent data file corruption."
|
||||
Confirm-Action "Stop '$oldContainer' now and proceed with data extraction?"
|
||||
Invoke-NativeSafe { docker stop $oldContainer *>$null } | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Err "Failed to stop '$oldContainer'. Try: docker stop $oldContainer"
|
||||
}
|
||||
Write-Ok "Container '$oldContainer' stopped."
|
||||
}
|
||||
|
||||
if (Test-Path $DumpFile) {
|
||||
Write-Warn "Dump file '$DumpFile' already exists."
|
||||
Write-Warn "If a previous extraction succeeded, just run install.ps1 now."
|
||||
Write-Warn "To re-extract, remove the file first: Remove-Item $DumpFile"
|
||||
Write-Err "Aborting to avoid overwriting an existing dump."
|
||||
}
|
||||
|
||||
$staleContainers = Invoke-NativeSafe { docker ps -a --format '{{.Names}}' 2>$null }
|
||||
if ($staleContainers -and ($staleContainers -split "`n") -contains $TempContainer) {
|
||||
Write-Warn "Stale migration container '$TempContainer' found - removing it."
|
||||
Invoke-NativeSafe { docker stop $TempContainer *>$null } | Out-Null
|
||||
Invoke-NativeSafe { docker rm $TempContainer *>$null } | Out-Null
|
||||
}
|
||||
|
||||
$drive = (Get-Item .).PSDrive
|
||||
$freeMB = [math]::Floor($drive.Free / 1MB)
|
||||
if ($freeMB -lt 500) {
|
||||
Write-Warn "Low disk space: $freeMB MB free. At least 500 MB recommended for the dump."
|
||||
Confirm-Action "Continue anyway?"
|
||||
} else {
|
||||
Write-Ok "Disk space: $freeMB MB free."
|
||||
}
|
||||
|
||||
Write-Ok "All pre-flight checks passed."
|
||||
|
||||
# ── Confirmation prompt ──────────────────────────────────────────────────────
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Extraction plan:" -ForegroundColor White
|
||||
Write-Host " Source volume : " -NoNewline; Write-Host "$OldVolume" -ForegroundColor Yellow -NoNewline; Write-Host " (PG14 data at /data/postgres)"
|
||||
Write-Host " Old credentials : user=" -NoNewline; Write-Host "$DbUser" -ForegroundColor Yellow -NoNewline; Write-Host " db=" -NoNewline; Write-Host "$DbName" -ForegroundColor Yellow
|
||||
Write-Host " Dump saved to : " -NoNewline; Write-Host "$DumpFile" -ForegroundColor Yellow
|
||||
Write-Host " SECRET_KEY to : " -NoNewline; Write-Host "$KeyFile" -ForegroundColor Yellow
|
||||
Write-Host " Log file : " -NoNewline; Write-Host "$LogFile" -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
Confirm-Action "Start data extraction? (Your original data will not be deleted or modified.)"
|
||||
|
||||
# ── Step 1: Start temporary PostgreSQL 14 container ──────────────────────────
|
||||
|
||||
Write-Step "1" "Starting temporary PostgreSQL 14 container"
|
||||
|
||||
Write-Info "Pulling $PG14Image..."
|
||||
Invoke-NativeSafe { docker pull $PG14Image *>$null } | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Warn "Could not pull $PG14Image - using cached image if available."
|
||||
}
|
||||
|
||||
$dataUid = Invoke-NativeSafe { docker run --rm -v "${OldVolume}:/data" alpine stat -c '%u' /data/postgres 2>$null }
|
||||
if (-not $dataUid -or $dataUid -eq "0") {
|
||||
Write-Warn "Could not detect data directory UID - falling back to default (may chown files)."
|
||||
$userFlag = @()
|
||||
} else {
|
||||
Write-Info "Data directory owned by UID $dataUid - starting temp container as that user."
|
||||
$userFlag = @("--user", $dataUid)
|
||||
}
|
||||
|
||||
$dockerRunArgs = @(
|
||||
"run", "-d",
|
||||
"--name", $TempContainer,
|
||||
"-v", "${OldVolume}:/data",
|
||||
"-e", "PGDATA=/data/postgres",
|
||||
"-e", "POSTGRES_USER=$DbUser",
|
||||
"-e", "POSTGRES_PASSWORD=$DbPassword",
|
||||
"-e", "POSTGRES_DB=$DbName"
|
||||
) + $userFlag + @($PG14Image)
|
||||
|
||||
Invoke-NativeSafe { docker @dockerRunArgs *>$null } | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Err "Failed to start temporary PostgreSQL 14 container."
|
||||
}
|
||||
|
||||
Write-Ok "Temporary container '$TempContainer' started."
|
||||
Wait-ForPostgres -Container $TempContainer -User $DbUser -Label "PostgreSQL 14"
|
||||
|
||||
# ── Step 2: Dump the database ────────────────────────────────────────────────
|
||||
|
||||
Write-Step "2" "Dumping PostgreSQL 14 database"
|
||||
|
||||
Write-Info "Running pg_dump - this may take a while for large databases..."
|
||||
|
||||
$pgDumpErrFile = Join-Path $env:TEMP "surfsense_pgdump_err.log"
|
||||
Invoke-NativeSafe { docker exec -e "PGPASSWORD=$DbPassword" $TempContainer pg_dump -U $DbUser --no-password $DbName > $DumpFile 2>$pgDumpErrFile } | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
if (Test-Path $pgDumpErrFile) { Get-Content $pgDumpErrFile | Write-Host -ForegroundColor Red }
|
||||
Remove-TempContainer
|
||||
Write-Err "pg_dump failed. See above for details."
|
||||
}
|
||||
|
||||
if (-not (Test-Path $DumpFile) -or (Get-Item $DumpFile).Length -eq 0) {
|
||||
Remove-TempContainer
|
||||
Write-Err "Dump file '$DumpFile' is empty. Something went wrong with pg_dump."
|
||||
}
|
||||
|
||||
$dumpContent = (Get-Content $DumpFile -TotalCount 5) -join "`n"
|
||||
if ($dumpContent -notmatch "PostgreSQL database dump") {
|
||||
Remove-TempContainer
|
||||
Write-Err "Dump file does not contain a valid PostgreSQL dump header - the file may be corrupt."
|
||||
}
|
||||
|
||||
$dumpLines = (Get-Content $DumpFile | Measure-Object -Line).Lines
|
||||
if ($dumpLines -lt 10) {
|
||||
Remove-TempContainer
|
||||
Write-Err "Dump has only $dumpLines lines - suspiciously small. Aborting."
|
||||
}
|
||||
|
||||
$dumpSize = "{0:N1} MB" -f ((Get-Item $DumpFile).Length / 1MB)
|
||||
Write-Ok "Dump complete: $dumpSize ($dumpLines lines) -> $DumpFile"
|
||||
|
||||
Write-Info "Stopping temporary PostgreSQL 14 container..."
|
||||
Invoke-NativeSafe { docker stop $TempContainer *>$null } | Out-Null
|
||||
Invoke-NativeSafe { docker rm $TempContainer *>$null } | Out-Null
|
||||
Write-Ok "Temporary container removed."
|
||||
|
||||
# ── Step 3: Recover SECRET_KEY ───────────────────────────────────────────────
|
||||
|
||||
Write-Step "3" "Recovering SECRET_KEY"
|
||||
|
||||
$recoveredKey = ""
|
||||
|
||||
$keyCheck = Invoke-NativeSafe { docker run --rm -v "${OldVolume}:/data" alpine sh -c 'test -f /data/.secret_key && cat /data/.secret_key' 2>$null }
|
||||
if ($LASTEXITCODE -eq 0 -and $keyCheck) {
|
||||
$recoveredKey = $keyCheck.Trim()
|
||||
Write-Ok "Recovered SECRET_KEY from '$OldVolume'."
|
||||
} else {
|
||||
Write-Warn "No SECRET_KEY file found at /data/.secret_key in '$OldVolume'."
|
||||
Write-Warn "This means the all-in-one container was launched with SECRET_KEY set as an explicit env var."
|
||||
|
||||
if ($Yes) {
|
||||
$bytes = New-Object byte[] 32
|
||||
$rng = [System.Security.Cryptography.RNGCryptoServiceProvider]::new()
|
||||
$rng.GetBytes($bytes)
|
||||
$rng.Dispose()
|
||||
$recoveredKey = [Convert]::ToBase64String($bytes)
|
||||
Write-Warn "Non-interactive mode: generated a new SECRET_KEY automatically."
|
||||
Write-Warn "All active browser sessions will be logged out after migration."
|
||||
Write-Warn "To restore your original key, update SECRET_KEY in .\surfsense\.env afterwards."
|
||||
} else {
|
||||
Write-Warn "Enter the SECRET_KEY from your old container's environment"
|
||||
$recoveredKey = Read-Host "[SurfSense] (press Enter to generate a new one - existing sessions will be invalidated)"
|
||||
if (-not $recoveredKey) {
|
||||
$bytes = New-Object byte[] 32
|
||||
$rng = [System.Security.Cryptography.RNGCryptoServiceProvider]::new()
|
||||
$rng.GetBytes($bytes)
|
||||
$rng.Dispose()
|
||||
$recoveredKey = [Convert]::ToBase64String($bytes)
|
||||
Write-Warn "Generated a new SECRET_KEY. All active browser sessions will be logged out after migration."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Set-Content -Path $KeyFile -Value $recoveredKey -NoNewline
|
||||
Write-Ok "SECRET_KEY saved to $KeyFile"
|
||||
|
||||
# ── Done ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
Write-Host ""
|
||||
Write-Host ("=" * 62) -ForegroundColor Green
|
||||
Write-Host " Data extraction complete!" -ForegroundColor Green
|
||||
Write-Host ("=" * 62) -ForegroundColor Green
|
||||
Write-Host ""
|
||||
|
||||
Write-Ok "Dump file : $DumpFile ($dumpSize)"
|
||||
Write-Ok "Secret key: $KeyFile"
|
||||
Write-Host ""
|
||||
Write-Info "Next step - run install.ps1 from this same directory:"
|
||||
Write-Host ""
|
||||
Write-Host " irm https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/install.ps1 | iex" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Info "install.ps1 will detect the dump, restore your data into PostgreSQL 17,"
|
||||
Write-Info "and start the full SurfSense stack automatically."
|
||||
Write-Host ""
|
||||
Write-Warn "Keep both files until you have verified the migration:"
|
||||
Write-Warn " $DumpFile"
|
||||
Write-Warn " $KeyFile"
|
||||
Write-Warn "Full log saved to: $LogFile"
|
||||
Write-Host ""
|
||||
|
||||
Log "Migration extraction completed successfully at $(Get-Date)"
|
||||
Executable
+335
@@ -0,0 +1,335 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# SurfSense — Database Migration Script
|
||||
#
|
||||
# Extracts data from the legacy all-in-one surfsense-data volume (PostgreSQL 14)
|
||||
# and saves it as a SQL dump + SECRET_KEY file ready for install.sh to restore.
|
||||
#
|
||||
# Usage:
|
||||
# bash migrate-database.sh [options]
|
||||
#
|
||||
# Options:
|
||||
# --db-user USER Old PostgreSQL username (default: surfsense)
|
||||
# --db-password PASS Old PostgreSQL password (default: surfsense)
|
||||
# --db-name NAME Old PostgreSQL database (default: surfsense)
|
||||
# --yes / -y Skip all confirmation prompts
|
||||
# --help / -h Show this help
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Docker installed and running
|
||||
# - The legacy surfsense-data volume must exist
|
||||
# - ~500 MB free disk space for the dump file
|
||||
#
|
||||
# What this script does:
|
||||
# 1. Stops any container using surfsense-data (to prevent corruption)
|
||||
# 2. Starts a temporary PG14 container against the old volume
|
||||
# 3. Dumps the database to ./surfsense_migration_backup.sql
|
||||
# 4. Recovers the SECRET_KEY to ./surfsense_migration_secret.key
|
||||
# 5. Exits — leaving installation to install.sh
|
||||
#
|
||||
# What this script does NOT do:
|
||||
# - Delete the original surfsense-data volume (do this manually after verifying)
|
||||
# - Install the new SurfSense stack (install.sh handles that automatically)
|
||||
#
|
||||
# Note:
|
||||
# install.sh downloads and runs this script automatically when it detects the
|
||||
# legacy surfsense-data volume. You only need to run this script manually if
|
||||
# you have custom database credentials (--db-user / --db-password / --db-name)
|
||||
# or if the automatic migration inside install.sh fails at the extraction step.
|
||||
# =============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Colours ──────────────────────────────────────────────────────────────────
|
||||
CYAN='\033[1;36m'
|
||||
YELLOW='\033[1;33m'
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
# ── Logging — tee everything to a log file ───────────────────────────────────
|
||||
LOG_FILE="./surfsense-migration.log"
|
||||
exec > >(tee -a "${LOG_FILE}") 2>&1
|
||||
|
||||
# ── Output helpers ────────────────────────────────────────────────────────────
|
||||
info() { printf "${CYAN}[SurfSense]${NC} %s\n" "$1"; }
|
||||
success() { printf "${GREEN}[SurfSense]${NC} %s\n" "$1"; }
|
||||
warn() { printf "${YELLOW}[SurfSense]${NC} %s\n" "$1"; }
|
||||
error() { printf "${RED}[SurfSense]${NC} ERROR: %s\n" "$1" >&2; exit 1; }
|
||||
step() { printf "\n${BOLD}${CYAN}── Step %s: %s${NC}\n" "$1" "$2"; }
|
||||
|
||||
# ── Constants ─────────────────────────────────────────────────────────────────
|
||||
OLD_VOLUME="surfsense-data"
|
||||
TEMP_CONTAINER="surfsense-pg14-migration"
|
||||
DUMP_FILE="./surfsense_migration_backup.sql"
|
||||
KEY_FILE="./surfsense_migration_secret.key"
|
||||
PG14_IMAGE="pgvector/pgvector:pg14"
|
||||
|
||||
# ── Defaults ──────────────────────────────────────────────────────────────────
|
||||
OLD_DB_USER="surfsense"
|
||||
OLD_DB_PASSWORD="surfsense"
|
||||
OLD_DB_NAME="surfsense"
|
||||
AUTO_YES=false
|
||||
|
||||
# ── Argument parsing ──────────────────────────────────────────────────────────
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--db-user) OLD_DB_USER="$2"; shift 2 ;;
|
||||
--db-password) OLD_DB_PASSWORD="$2"; shift 2 ;;
|
||||
--db-name) OLD_DB_NAME="$2"; shift 2 ;;
|
||||
--yes|-y) AUTO_YES=true; shift ;;
|
||||
--help|-h)
|
||||
grep '^#' "$0" | grep -v '^#!/' | sed 's/^# \{0,1\}//'
|
||||
exit 0
|
||||
;;
|
||||
*) error "Unknown option: $1 — run with --help for usage." ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Confirmation helper ───────────────────────────────────────────────────────
|
||||
confirm() {
|
||||
if $AUTO_YES; then return 0; fi
|
||||
printf "${YELLOW}[SurfSense]${NC} %s [y/N] " "$1"
|
||||
read -r reply
|
||||
[[ "$reply" =~ ^[Yy]$ ]] || { warn "Aborted."; exit 0; }
|
||||
}
|
||||
|
||||
# ── Cleanup trap — always remove the temp container ──────────────────────────
|
||||
cleanup() {
|
||||
local exit_code=$?
|
||||
if docker ps -a --format '{{.Names}}' 2>/dev/null < /dev/null | grep -q "^${TEMP_CONTAINER}$"; then
|
||||
info "Cleaning up temporary container '${TEMP_CONTAINER}'..."
|
||||
docker stop "${TEMP_CONTAINER}" >/dev/null 2>&1 < /dev/null || true
|
||||
docker rm "${TEMP_CONTAINER}" >/dev/null 2>&1 < /dev/null || true
|
||||
fi
|
||||
if [[ $exit_code -ne 0 ]]; then
|
||||
printf "\n${RED}[SurfSense]${NC} Migration data extraction failed (exit code %s).\n" "${exit_code}" >&2
|
||||
printf "${RED}[SurfSense]${NC} Full log: %s\n" "${LOG_FILE}" >&2
|
||||
printf "${YELLOW}[SurfSense]${NC} Your original data in '${OLD_VOLUME}' is untouched.\n" >&2
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# ── Wait-for-postgres helper ──────────────────────────────────────────────────
|
||||
wait_for_pg() {
|
||||
local container="$1"
|
||||
local user="$2"
|
||||
local label="${3:-PostgreSQL}"
|
||||
local max_attempts=45
|
||||
local attempt=0
|
||||
|
||||
info "Waiting for ${label} to accept connections..."
|
||||
until docker exec "${container}" pg_isready -U "${user}" -q 2>/dev/null < /dev/null; do
|
||||
attempt=$((attempt + 1))
|
||||
if [[ $attempt -ge $max_attempts ]]; then
|
||||
error "${label} did not become ready after $((max_attempts * 2)) seconds. Check: docker logs ${container}"
|
||||
fi
|
||||
printf "."
|
||||
sleep 2
|
||||
done
|
||||
printf "\n"
|
||||
success "${label} is ready."
|
||||
}
|
||||
|
||||
info "Migrating data from legacy database (PostgreSQL 14 → 17)"
|
||||
|
||||
# ── Step 0: Pre-flight checks ─────────────────────────────────────────────────
|
||||
step "0" "Pre-flight checks"
|
||||
|
||||
# Docker CLI
|
||||
command -v docker >/dev/null 2>&1 \
|
||||
|| error "Docker is not installed. Install it at: https://docs.docker.com/get-docker/"
|
||||
|
||||
# Docker daemon
|
||||
docker info >/dev/null 2>&1 < /dev/null \
|
||||
|| error "Docker daemon is not running. Please start Docker and try again."
|
||||
|
||||
# Old volume must exist
|
||||
docker volume ls --format '{{.Name}}' < /dev/null | grep -q "^${OLD_VOLUME}$" \
|
||||
|| error "Legacy volume '${OLD_VOLUME}' not found.\n Are you sure you ran the old all-in-one SurfSense container?"
|
||||
success "Found legacy volume: ${OLD_VOLUME}"
|
||||
|
||||
# Detect and stop any container currently using the old volume
|
||||
# (mounting a live PG volume into a second container causes the new container's
|
||||
# entrypoint to chown the data files, breaking the running container's access)
|
||||
OLD_CONTAINER=$(docker ps --filter "volume=${OLD_VOLUME}" --format '{{.Names}}' < /dev/null | head -n1 || true)
|
||||
if [[ -n "${OLD_CONTAINER}" ]]; then
|
||||
warn "Container '${OLD_CONTAINER}' is running and using the '${OLD_VOLUME}' volume."
|
||||
warn "It must be stopped before migration to prevent data file corruption."
|
||||
confirm "Stop '${OLD_CONTAINER}' now and proceed with data extraction?"
|
||||
docker stop "${OLD_CONTAINER}" >/dev/null 2>&1 < /dev/null \
|
||||
|| error "Failed to stop '${OLD_CONTAINER}'. Try: docker stop ${OLD_CONTAINER}"
|
||||
success "Container '${OLD_CONTAINER}' stopped."
|
||||
fi
|
||||
|
||||
# Bail out if a dump already exists — don't overwrite a previous successful run
|
||||
if [[ -f "${DUMP_FILE}" ]]; then
|
||||
warn "Dump file '${DUMP_FILE}' already exists."
|
||||
warn "If a previous extraction succeeded, just run install.sh now."
|
||||
warn "To re-extract, remove the file first: rm ${DUMP_FILE}"
|
||||
error "Aborting to avoid overwriting an existing dump."
|
||||
fi
|
||||
|
||||
# Clean up any stale temp container from a previous failed run
|
||||
if docker ps -a --format '{{.Names}}' < /dev/null | grep -q "^${TEMP_CONTAINER}$"; then
|
||||
warn "Stale migration container '${TEMP_CONTAINER}' found — removing it."
|
||||
docker stop "${TEMP_CONTAINER}" >/dev/null 2>&1 < /dev/null || true
|
||||
docker rm "${TEMP_CONTAINER}" >/dev/null 2>&1 < /dev/null || true
|
||||
fi
|
||||
|
||||
# Disk space (warn if < 500 MB free)
|
||||
if command -v df >/dev/null 2>&1; then
|
||||
FREE_KB=$(df -k . | awk 'NR==2 {print $4}')
|
||||
FREE_MB=$(( FREE_KB / 1024 ))
|
||||
if [[ $FREE_MB -lt 500 ]]; then
|
||||
warn "Low disk space: ${FREE_MB} MB free. At least 500 MB recommended for the dump."
|
||||
confirm "Continue anyway?"
|
||||
else
|
||||
success "Disk space: ${FREE_MB} MB free."
|
||||
fi
|
||||
fi
|
||||
|
||||
success "All pre-flight checks passed."
|
||||
|
||||
# ── Confirmation prompt ───────────────────────────────────────────────────────
|
||||
printf "\n${BOLD}Extraction plan:${NC}\n"
|
||||
printf " Source volume : ${YELLOW}%s${NC} (PG14 data at /data/postgres)\n" "${OLD_VOLUME}"
|
||||
printf " Old credentials : user=${YELLOW}%s${NC} db=${YELLOW}%s${NC}\n" "${OLD_DB_USER}" "${OLD_DB_NAME}"
|
||||
printf " Dump saved to : ${YELLOW}%s${NC}\n" "${DUMP_FILE}"
|
||||
printf " SECRET_KEY to : ${YELLOW}%s${NC}\n" "${KEY_FILE}"
|
||||
printf " Log file : ${YELLOW}%s${NC}\n\n" "${LOG_FILE}"
|
||||
confirm "Start data extraction? (Your original data will not be deleted or modified.)"
|
||||
|
||||
# ── Step 1: Start temporary PostgreSQL 14 container ──────────────────────────
|
||||
step "1" "Starting temporary PostgreSQL 14 container"
|
||||
|
||||
info "Pulling ${PG14_IMAGE}..."
|
||||
docker pull "${PG14_IMAGE}" >/dev/null 2>&1 < /dev/null \
|
||||
|| warn "Could not pull ${PG14_IMAGE} — using cached image if available."
|
||||
|
||||
# Detect the UID that owns the existing data files and run the temp container
|
||||
# as that user. This prevents the official postgres image entrypoint from
|
||||
# running as root and doing `chown -R postgres /data/postgres`, which would
|
||||
# re-own the files to UID 999 and break any subsequent access by the original
|
||||
# container's postgres process (which may run as a different UID).
|
||||
DATA_UID=$(docker run --rm -v "${OLD_VOLUME}:/data" alpine \
|
||||
stat -c '%u' /data/postgres 2>/dev/null < /dev/null || echo "")
|
||||
if [[ -z "${DATA_UID}" || "${DATA_UID}" == "0" ]]; then
|
||||
warn "Could not detect data directory UID — falling back to default (may chown files)."
|
||||
USER_FLAG=""
|
||||
else
|
||||
info "Data directory owned by UID ${DATA_UID} — starting temp container as that user."
|
||||
USER_FLAG="--user ${DATA_UID}"
|
||||
fi
|
||||
|
||||
docker run -d \
|
||||
--name "${TEMP_CONTAINER}" \
|
||||
-v "${OLD_VOLUME}:/data" \
|
||||
-e PGDATA=/data/postgres \
|
||||
-e POSTGRES_USER="${OLD_DB_USER}" \
|
||||
-e POSTGRES_PASSWORD="${OLD_DB_PASSWORD}" \
|
||||
-e POSTGRES_DB="${OLD_DB_NAME}" \
|
||||
${USER_FLAG} \
|
||||
"${PG14_IMAGE}" >/dev/null < /dev/null
|
||||
|
||||
success "Temporary container '${TEMP_CONTAINER}' started."
|
||||
wait_for_pg "${TEMP_CONTAINER}" "${OLD_DB_USER}" "PostgreSQL 14"
|
||||
|
||||
# ── Step 2: Dump the database ─────────────────────────────────────────────────
|
||||
step "2" "Dumping PostgreSQL 14 database"
|
||||
|
||||
info "Running pg_dump — this may take a while for large databases..."
|
||||
|
||||
if ! docker exec \
|
||||
-e PGPASSWORD="${OLD_DB_PASSWORD}" \
|
||||
"${TEMP_CONTAINER}" \
|
||||
pg_dump -U "${OLD_DB_USER}" --no-password "${OLD_DB_NAME}" \
|
||||
> "${DUMP_FILE}" 2>/tmp/surfsense_pgdump_err < /dev/null; then
|
||||
cat /tmp/surfsense_pgdump_err >&2
|
||||
error "pg_dump failed. See above for details."
|
||||
fi
|
||||
|
||||
# Validate: non-empty file
|
||||
[[ -s "${DUMP_FILE}" ]] \
|
||||
|| error "Dump file '${DUMP_FILE}' is empty. Something went wrong with pg_dump."
|
||||
|
||||
# Validate: looks like a real PG dump
|
||||
grep -q "PostgreSQL database dump" "${DUMP_FILE}" \
|
||||
|| error "Dump file does not contain a valid PostgreSQL dump header — the file may be corrupt."
|
||||
|
||||
# Validate: sanity-check line count
|
||||
DUMP_LINES=$(wc -l < "${DUMP_FILE}" | tr -d ' ')
|
||||
[[ $DUMP_LINES -ge 10 ]] \
|
||||
|| error "Dump has only ${DUMP_LINES} lines — suspiciously small. Aborting."
|
||||
|
||||
DUMP_SIZE=$(du -sh "${DUMP_FILE}" 2>/dev/null | cut -f1)
|
||||
success "Dump complete: ${DUMP_SIZE} (${DUMP_LINES} lines) → ${DUMP_FILE}"
|
||||
|
||||
# Stop the temp container (trap will also handle it on unexpected exit)
|
||||
info "Stopping temporary PostgreSQL 14 container..."
|
||||
docker stop "${TEMP_CONTAINER}" >/dev/null 2>&1 < /dev/null || true
|
||||
docker rm "${TEMP_CONTAINER}" >/dev/null 2>&1 < /dev/null || true
|
||||
success "Temporary container removed."
|
||||
|
||||
# ── Step 3: Recover SECRET_KEY ────────────────────────────────────────────────
|
||||
step "3" "Recovering SECRET_KEY"
|
||||
|
||||
RECOVERED_KEY=""
|
||||
|
||||
if docker run --rm -v "${OLD_VOLUME}:/data" alpine \
|
||||
sh -c 'test -f /data/.secret_key && cat /data/.secret_key' \
|
||||
2>/dev/null < /dev/null | grep -q .; then
|
||||
RECOVERED_KEY=$(
|
||||
docker run --rm -v "${OLD_VOLUME}:/data" alpine \
|
||||
cat /data/.secret_key 2>/dev/null < /dev/null | tr -d '[:space:]'
|
||||
)
|
||||
success "Recovered SECRET_KEY from '${OLD_VOLUME}'."
|
||||
else
|
||||
warn "No SECRET_KEY file found at /data/.secret_key in '${OLD_VOLUME}'."
|
||||
warn "This means the all-in-one container was launched with SECRET_KEY set as an explicit env var."
|
||||
if $AUTO_YES; then
|
||||
# Non-interactive (called from install.sh) — auto-generate rather than hanging on read
|
||||
RECOVERED_KEY=$(openssl rand -base64 32 2>/dev/null \
|
||||
|| head -c 32 /dev/urandom | base64 | tr -d '\n')
|
||||
warn "Non-interactive mode: generated a new SECRET_KEY automatically."
|
||||
warn "All active browser sessions will be logged out after migration."
|
||||
warn "To restore your original key, update SECRET_KEY in ./surfsense/.env afterwards."
|
||||
else
|
||||
printf "${YELLOW}[SurfSense]${NC} Enter the SECRET_KEY from your old container's environment\n"
|
||||
printf "${YELLOW}[SurfSense]${NC} (press Enter to generate a new one — existing sessions will be invalidated): "
|
||||
read -r RECOVERED_KEY
|
||||
if [[ -z "${RECOVERED_KEY}" ]]; then
|
||||
RECOVERED_KEY=$(openssl rand -base64 32 2>/dev/null \
|
||||
|| head -c 32 /dev/urandom | base64 | tr -d '\n')
|
||||
warn "Generated a new SECRET_KEY. All active browser sessions will be logged out after migration."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Save SECRET_KEY to a file for install.sh to pick up
|
||||
printf '%s' "${RECOVERED_KEY}" > "${KEY_FILE}"
|
||||
success "SECRET_KEY saved to ${KEY_FILE}"
|
||||
|
||||
# ── Done ──────────────────────────────────────────────────────────────────────
|
||||
printf "\n${GREEN}${BOLD}"
|
||||
printf "══════════════════════════════════════════════════════════════\n"
|
||||
printf " Data extraction complete!\n"
|
||||
printf "══════════════════════════════════════════════════════════════\n"
|
||||
printf "${NC}\n"
|
||||
|
||||
success "Dump file : ${DUMP_FILE} (${DUMP_SIZE})"
|
||||
success "Secret key: ${KEY_FILE}"
|
||||
printf "\n"
|
||||
info "Next step — run install.sh from this same directory:"
|
||||
printf "\n"
|
||||
printf "${CYAN} curl -fsSL https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/install.sh | bash${NC}\n"
|
||||
printf "\n"
|
||||
info "install.sh will detect the dump, restore your data into PostgreSQL 17,"
|
||||
info "and start the full SurfSense stack automatically."
|
||||
printf "\n"
|
||||
warn "Keep both files until you have verified the migration:"
|
||||
warn " ${DUMP_FILE}"
|
||||
warn " ${KEY_FILE}"
|
||||
warn "Full log saved to: ${LOG_FILE}"
|
||||
printf "\n"
|
||||
Reference in New Issue
Block a user