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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:33:44 +08:00
commit 6ede33ccdb
3841 changed files with 594049 additions and 0 deletions
+545
View File
@@ -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
+150
View File
@@ -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
+275
View File
@@ -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
+181
View File
@@ -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:
+30
View File
@@ -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
+54
View File
@@ -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
+52
View File
@@ -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
+308
View File
@@ -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
+81
View File
@@ -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]
+20
View File
@@ -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
+49
View File
@@ -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
}
+10
View File
@@ -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
+567
View File
@@ -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."
}
+568
View File
@@ -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 "$@"
+343
View File
@@ -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)"
+335
View File
@@ -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"