From f051c19aca5d3979265dbe4894f59c8a46ae7428 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 26 Feb 2026 16:15:47 +0530 Subject: [PATCH] feat: add database migration script and update installation instructions for legacy all-in-one users --- docker/scripts/install.sh | 14 + docker/scripts/migrate-database.sh | 433 ++++++++++++++++++ .../content/docs/docker-installation.mdx | 10 + surfsense_web/content/docs/how-to/meta.json | 2 +- .../docs/how-to/migrate-from-allinone.mdx | 226 +++++++++ 5 files changed, 684 insertions(+), 1 deletion(-) create mode 100755 docker/scripts/migrate-database.sh create mode 100644 surfsense_web/content/docs/how-to/migrate-from-allinone.mdx diff --git a/docker/scripts/install.sh b/docker/scripts/install.sh index 7a12c591a..89008a7d9 100644 --- a/docker/scripts/install.sh +++ b/docker/scripts/install.sh @@ -21,6 +21,20 @@ error() { printf "${RED}[SurfSense]${NC} %s\n" "$1" >&2; exit 1; } command -v docker >/dev/null 2>&1 || error "Docker is not installed. Please install Docker first: https://docs.docker.com/get-docker/" +# Detect legacy all-in-one volume — must migrate before installing +if docker volume ls --format '{{.Name}}' 2>/dev/null | grep -q '^surfsense-data$'; then + printf "${RED}[SurfSense]${NC} Legacy volume 'surfsense-data' detected.\n" >&2 + printf "${YELLOW}[SurfSense]${NC} You appear to be upgrading from the old all-in-one SurfSense container.\n" >&2 + printf "${YELLOW}[SurfSense]${NC} The database has been upgraded from PostgreSQL 14 to 17 and your data\n" >&2 + printf "${YELLOW}[SurfSense]${NC} must be migrated before running the new stack.\n" >&2 + printf "\n" >&2 + printf "${YELLOW}[SurfSense]${NC} Run the migration script first:\n" >&2 + printf "${CYAN}[SurfSense]${NC} curl -fsSL https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/migrate-database.sh | bash\n" >&2 + printf "\n" >&2 + printf "${YELLOW}[SurfSense]${NC} See the full guide at: https://surfsense.net/docs/how-to/migrate-from-allinone\n" >&2 + exit 1 +fi + if docker compose version >/dev/null 2>&1; then DC="docker compose" elif command -v docker-compose >/dev/null 2>&1; then diff --git a/docker/scripts/migrate-database.sh b/docker/scripts/migrate-database.sh new file mode 100755 index 000000000..a3421c83c --- /dev/null +++ b/docker/scripts/migrate-database.sh @@ -0,0 +1,433 @@ +#!/usr/bin/env bash +# ============================================================================= +# SurfSense — Database Migration Script +# +# Migrates data from the legacy all-in-one surfsense-data volume (PostgreSQL 14) +# to the new multi-container surfsense-postgres volume (PostgreSQL 17) using +# a logical pg_dump / psql restore — safe across major PG versions. +# +# 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) +# --install-dir DIR New installation directory (default: ./surfsense) +# --yes / -y Skip all confirmation prompts +# --help / -h Show this help +# +# Prerequisites: +# - Docker and Docker Compose installed and running +# - The legacy surfsense-data volume must exist +# - ~500 MB free disk space for the dump file +# +# What this script does NOT do: +# - Delete the original surfsense-data volume (you must do this manually +# after verifying the migration succeeded) +# ============================================================================= + +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 ───────────────────────────────────────────────────────────────── +REPO_RAW="https://raw.githubusercontent.com/MODSetter/SurfSense/main" +OLD_VOLUME="surfsense-data" +NEW_PG_VOLUME="surfsense-postgres" +TEMP_CONTAINER="surfsense-pg14-migration" +DUMP_FILE="./surfsense_migration_backup.sql" +PG14_IMAGE="postgres:14" + +# ── Defaults ────────────────────────────────────────────────────────────────── +OLD_DB_USER="surfsense" +OLD_DB_PASSWORD="surfsense" +OLD_DB_NAME="surfsense" +INSTALL_DIR="./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 ;; + --install-dir) INSTALL_DIR="$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 | grep -q "^${TEMP_CONTAINER}$"; then + info "Cleaning up temporary container '${TEMP_CONTAINER}'..." + docker stop "${TEMP_CONTAINER}" >/dev/null 2>&1 || true + docker rm "${TEMP_CONTAINER}" >/dev/null 2>&1 || true + fi + if [[ $exit_code -ne 0 ]]; then + printf "\n${RED}[SurfSense]${NC} Migration 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 ────────────────────────────────────────────────── +# $1 = container name/id $2 = db user $3 = label for messages +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; do + attempt=$((attempt + 1)) + if [[ $attempt -ge $max_attempts ]]; then + error "${label} did not become ready after $((max_attempts * 2)) seconds.\nCheck logs: docker logs ${container}" + fi + printf "." + sleep 2 + done + printf "\n" + success "${label} is ready." +} + +# ── Banner ──────────────────────────────────────────────────────────────────── +printf "\n${BOLD}${CYAN}" +cat << 'EOF' + ____ __ ____ +/ ___| _ _ _ __ / _|/ ___| ___ _ __ ___ ___ +\___ \| | | | '__| |_\___ \ / _ \ '_ \/ __|/ _ \ + ___) | |_| | | | _|___) | __/ | | \__ \ __/ +|____/ \__,_|_| |_| |____/ \___|_| |_|___/\___| + +EOF +printf "${NC}" +printf "${CYAN} Database Migration: All-in-One → Multi-Container (PG 14 → 17)${NC}\n" +printf "${CYAN}══════════════════════════════════════════════════════════════${NC}\n\n" + +# ── 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 \ + || error "Docker daemon is not running. Please start Docker and try again." + +# Docker Compose +if docker compose version >/dev/null 2>&1; then + DC="docker compose" +elif command -v docker-compose >/dev/null 2>&1; then + DC="docker-compose" +else + error "Docker Compose not found. Install it at: https://docs.docker.com/compose/install/" +fi +info "Docker Compose: ${DC}" + +# OS detection (needed for sed -i portability) +case "$(uname -s)" in + Darwin*) OS_TYPE="darwin" ;; + Linux*) OS_TYPE="linux" ;; + CYGWIN*|MINGW*|MSYS*) OS_TYPE="windows" ;; + *) OS_TYPE="unknown" ;; +esac +info "OS: ${OS_TYPE}" + +# Old volume must exist +docker volume ls --format '{{.Name}}' | 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}" + +# New PG volume must NOT already exist +if docker volume ls --format '{{.Name}}' | grep -q "^${NEW_PG_VOLUME}$"; then + warn "Volume '${NEW_PG_VOLUME}' already exists." + warn "If migration already succeeded, you do not need to run this script again." + warn "If a previous run failed partway, remove the partial volume first:" + warn " docker volume rm ${NEW_PG_VOLUME}" + error "Aborting to avoid overwriting existing data." +fi +success "Target volume '${NEW_PG_VOLUME}' does not yet exist — safe to proceed." + +# Clean up any stale temp container from a previous failed run +if docker ps -a --format '{{.Names}}' | grep -q "^${TEMP_CONTAINER}$"; then + warn "Stale migration container '${TEMP_CONTAINER}' found — removing it." + docker stop "${TEMP_CONTAINER}" >/dev/null 2>&1 || true + docker rm "${TEMP_CONTAINER}" >/dev/null 2>&1 || 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}Migration plan:${NC}\n" +printf " Source volume : ${YELLOW}%s${NC} (PG14 data at /data/postgres)\n" "${OLD_VOLUME}" +printf " Target volume : ${YELLOW}%s${NC} (PG17 multi-container stack)\n" "${NEW_PG_VOLUME}" +printf " Old credentials : user=${YELLOW}%s${NC} db=${YELLOW}%s${NC}\n" "${OLD_DB_USER}" "${OLD_DB_NAME}" +printf " Install dir : ${YELLOW}%s${NC}\n" "${INSTALL_DIR}" +printf " Dump saved to : ${YELLOW}%s${NC}\n" "${DUMP_FILE}" +printf " Log file : ${YELLOW}%s${NC}\n\n" "${LOG_FILE}" +confirm "Start migration? (Your original data will not be deleted.)" + +# ── 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 \ + || warn "Could not pull ${PG14_IMAGE} — using cached image if available." + +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}" \ + "${PG14_IMAGE}" >/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..." + +# Run pg_dump and capture stderr separately to detect real failures +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/pg_dump_err; then + cat /tmp/pg_dump_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 now (trap will also handle it on unexpected exit) +info "Stopping temporary PostgreSQL 14 container..." +docker stop "${TEMP_CONTAINER}" >/dev/null 2>&1 || true +docker rm "${TEMP_CONTAINER}" >/dev/null 2>&1 || 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 | grep -q .; then + RECOVERED_KEY=$( + docker run --rm -v "${OLD_VOLUME}:/data" alpine \ + cat /data/.secret_key 2>/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 was launched with SECRET_KEY set as an explicit environment variable." + 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 + +# ── Step 4: Set up the new installation ─────────────────────────────────────── +step "4" "Setting up new SurfSense installation" + +if [[ -f "${INSTALL_DIR}/docker-compose.yml" ]]; then + warn "Directory '${INSTALL_DIR}' already exists — skipping file download." +else + info "Creating installation directory: ${INSTALL_DIR}" + mkdir -p "${INSTALL_DIR}/scripts" + + FILES=( + "docker/docker-compose.yml:docker-compose.yml" + "docker/.env.example:.env.example" + "docker/postgresql.conf:postgresql.conf" + "docker/scripts/init-electric-user.sh:scripts/init-electric-user.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 ${src}. Check your internet connection." + done + + chmod +x "${INSTALL_DIR}/scripts/init-electric-user.sh" + success "Compose files downloaded to ${INSTALL_DIR}/" +fi + +# Create .env from example if it does not exist +if [[ ! -f "${INSTALL_DIR}/.env" ]]; then + cp "${INSTALL_DIR}/.env.example" "${INSTALL_DIR}/.env" + info "Created ${INSTALL_DIR}/.env from .env.example" +fi + +# Write the recovered SECRET_KEY into .env (handles both placeholder and pre-set values) +if [[ "${OS_TYPE}" == "darwin" ]]; then + sed -i '' "s|SECRET_KEY=replace_me_with_a_random_string|SECRET_KEY=${RECOVERED_KEY}|" "${INSTALL_DIR}/.env" + sed -i '' "s|^SECRET_KEY=.*|SECRET_KEY=${RECOVERED_KEY}|" "${INSTALL_DIR}/.env" +else + sed -i "s|SECRET_KEY=replace_me_with_a_random_string|SECRET_KEY=${RECOVERED_KEY}|" "${INSTALL_DIR}/.env" + sed -i "s|^SECRET_KEY=.*|SECRET_KEY=${RECOVERED_KEY}|" "${INSTALL_DIR}/.env" +fi +success "SECRET_KEY written to ${INSTALL_DIR}/.env" + +# ── Step 5: Start PostgreSQL 17 (new stack) ─────────────────────────────────── +step "5" "Starting PostgreSQL 17" + +(cd "${INSTALL_DIR}" && ${DC} up -d db) + +# Resolve the running container name for direct docker exec calls +PG17_CONTAINER=$(cd "${INSTALL_DIR}" && ${DC} ps -q db 2>/dev/null | head -n1 || true) +if [[ -z "${PG17_CONTAINER}" ]]; then + # Fallback to the predictable compose container name + PG17_CONTAINER="surfsense-db-1" +fi +info "PostgreSQL 17 container: ${PG17_CONTAINER}" + +wait_for_pg "${PG17_CONTAINER}" "${OLD_DB_USER}" "PostgreSQL 17" + +# ── Step 6: Restore the dump ────────────────────────────────────────────────── +step "6" "Restoring database into PostgreSQL 17" + +info "Running psql restore — this may take a while for large databases..." + +RESTORE_ERR_FILE="/tmp/surfsense_restore_err.log" + +docker exec -i \ + -e PGPASSWORD="${OLD_DB_PASSWORD}" \ + "${PG17_CONTAINER}" \ + psql -U "${OLD_DB_USER}" -d "${OLD_DB_NAME}" \ + < "${DUMP_FILE}" \ + 2>"${RESTORE_ERR_FILE}" || true # psql exits non-zero on warnings; check below + +# Surface any real (non-benign) errors +FATAL_ERRORS=$(grep -i "^ERROR:" "${RESTORE_ERR_FILE}" \ + | grep -iv "already exists" \ + | grep -iv "multiple primary keys" \ + || true) + +if [[ -n "${FATAL_ERRORS}" ]]; then + warn "Restore completed with the following errors:" + printf "%s\n" "${FATAL_ERRORS}" + confirm "These may be harmless (e.g. pre-existing system objects). Continue?" +else + success "Restore completed with no fatal errors." +fi + +# Smoke test — verify tables exist in the restored database +TABLE_COUNT=$( + docker exec \ + -e PGPASSWORD="${OLD_DB_PASSWORD}" \ + "${PG17_CONTAINER}" \ + psql -U "${OLD_DB_USER}" -d "${OLD_DB_NAME}" -t \ + -c "SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public';" \ + 2>/dev/null | tr -d ' \n' || echo "0" +) + +if [[ "${TABLE_COUNT}" == "0" || -z "${TABLE_COUNT}" ]]; then + warn "Smoke test: no tables found in the restored database." + warn "The restore may have failed silently. Inspect the dump and restore manually:" + warn " docker exec -i ${PG17_CONTAINER} psql -U ${OLD_DB_USER} -d ${OLD_DB_NAME} < ${DUMP_FILE}" + confirm "Continue starting the rest of the stack anyway?" +else + success "Smoke test passed: ${TABLE_COUNT} table(s) found in the restored database." +fi + +# ── Step 7: Start all remaining services ────────────────────────────────────── +step "7" "Starting all SurfSense services" + +(cd "${INSTALL_DIR}" && ${DC} up -d) +success "All services started." + +# ── Done ────────────────────────────────────────────────────────────────────── +printf "\n${GREEN}${BOLD}" +printf "══════════════════════════════════════════════════════════════\n" +printf " Migration complete!\n" +printf "══════════════════════════════════════════════════════════════\n" +printf "${NC}\n" + +success " Frontend : http://localhost:3000" +success " Backend : http://localhost:8000" +success " API Docs : http://localhost:8000/docs" +printf "\n" +info " Config : ${INSTALL_DIR}/.env" +info " Logs : cd ${INSTALL_DIR} && ${DC} logs -f" +printf "\n" +warn "Next steps:" +warn " 1. Open http://localhost:3000 and verify your data is intact." +warn " 2. Once satisfied, remove the legacy volume (IRREVERSIBLE):" +warn " docker volume rm ${OLD_VOLUME}" +warn " 3. Delete the dump file once you no longer need it as a backup:" +warn " rm ${DUMP_FILE}" +warn " Full migration log saved to: ${LOG_FILE}" +printf "\n" diff --git a/surfsense_web/content/docs/docker-installation.mdx b/surfsense_web/content/docs/docker-installation.mdx index 4ca525d7c..486f79b6a 100644 --- a/surfsense_web/content/docs/docker-installation.mdx +++ b/surfsense_web/content/docs/docker-installation.mdx @@ -171,6 +171,16 @@ All services start automatically with `docker compose up -d`. --- +## Migrating from the All-in-One Container + + +If you were previously using `docker-compose.quickstart.yml` (the legacy all-in-one `surfsense` container), your data lives in a `surfsense-data` volume and requires a **one-time migration** before switching to the current setup. PostgreSQL has been upgraded from version 14 to 17, so a simple volume swap will not work. + +See the full step-by-step guide: [Migrate from the All-in-One Container](/docs/how-to/migrate-from-allinone). + + +--- + ## Updating **Option 1 — Watchtower (recommended):** diff --git a/surfsense_web/content/docs/how-to/meta.json b/surfsense_web/content/docs/how-to/meta.json index 9051b0585..97ea22261 100644 --- a/surfsense_web/content/docs/how-to/meta.json +++ b/surfsense_web/content/docs/how-to/meta.json @@ -1,5 +1,5 @@ { "title": "How to", - "pages": ["electric-sql", "realtime-collaboration"], + "pages": ["electric-sql", "realtime-collaboration", "migrate-from-allinone"], "defaultOpen": false } diff --git a/surfsense_web/content/docs/how-to/migrate-from-allinone.mdx b/surfsense_web/content/docs/how-to/migrate-from-allinone.mdx new file mode 100644 index 000000000..74d3d0d0b --- /dev/null +++ b/surfsense_web/content/docs/how-to/migrate-from-allinone.mdx @@ -0,0 +1,226 @@ +--- +title: Migrate from the All-in-One Container +description: How to migrate your data from the legacy surfsense all-in-one Docker image to the current multi-container setup +--- + +The original SurfSense all-in-one image (`ghcr.io/modsetter/surfsense:latest`, run via `docker-compose.quickstart.yml`) stored all data — PostgreSQL, Redis, and configuration — in a single Docker volume named `surfsense-data`. The current setup uses separate named volumes and has upgraded PostgreSQL from **version 14 to 17**. + +Because PostgreSQL data files are not compatible between major versions, a **logical dump and restore** is required. This is a one-time migration. + + +This guide only applies to users who ran the legacy `docker-compose.quickstart.yml` (the all-in-one `surfsense` container). If you were already using `docker/docker-compose.yml`, you do not need to migrate. + + + +If you try to run `install.sh` while the old `surfsense-data` volume exists, the script will detect it and stop with instructions to migrate first. + + +--- + +## Option A — Migration Script (recommended) + +A single script handles the entire process automatically: it dumps your PostgreSQL 14 data, recovers your `SECRET_KEY`, sets up the new stack, and restores into PostgreSQL 17. + +**Prerequisites:** Docker running, ~500 MB free disk space, internet access. + +```bash +curl -fsSL https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/migrate-database.sh | bash +``` + +Or download and inspect it first (recommended): + +```bash +curl -fsSL https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/migrate-database.sh -o migrate-database.sh +# Review the script, then run: +bash migrate-database.sh +``` + +### Options + +| Flag | Description | Default | +|------|-------------|---------| +| `--db-user USER` | Old PostgreSQL username | `surfsense` | +| `--db-password PASS` | Old PostgreSQL password | `surfsense` | +| `--db-name NAME` | Old PostgreSQL database | `surfsense` | +| `--install-dir DIR` | New installation directory | `./surfsense` | +| `--yes` / `-y` | Skip confirmation prompts | — | + +If you customised the database credentials in your old all-in-one container, pass them explicitly: + +```bash +bash migrate-database.sh --db-user myuser --db-password mypass --db-name mydb +``` + +### What the script does + +1. Checks prerequisites and confirms the `surfsense-data` volume exists +2. Starts a temporary `postgres:14` container against the old data +3. Runs `pg_dump` and validates the dump file (size + header check) +4. Recovers your `SECRET_KEY` from the old volume (or prompts if not found) +5. Downloads the new compose files into `./surfsense/` (skips if already present) +6. Writes the recovered `SECRET_KEY` into `./surfsense/.env` +7. Starts the new `db` service (PostgreSQL 17), waits for readiness +8. Restores the dump with `psql` and runs a smoke test +9. Starts all remaining services + +The original `surfsense-data` volume is **never deleted** — you remove it manually after verifying the migration. + +### After the script completes + +1. Open [http://localhost:3000](http://localhost:3000) and confirm your data is intact. +2. Once satisfied, remove the old volume: + ```bash + docker volume rm surfsense-data + ``` +3. Delete the backup dump once you no longer need it: + ```bash + rm ./surfsense_migration_backup.sql + ``` + +--- + +## Option B — Manual Steps + +Use these steps if the migration script doesn't work on your platform (e.g. Windows without WSL2), or if you want full control over each step. + +### Before you start + +- Confirm the old volume exists: `docker volume ls | grep surfsense-data` +- Have ~500 MB free disk space for the SQL dump. + +### Step 1 — Start a temporary PostgreSQL 14 container + +```bash +docker run -d --name surfsense-pg14-temp \ + -v surfsense-data:/data \ + -e PGDATA=/data/postgres \ + -e POSTGRES_USER=surfsense \ + -e POSTGRES_PASSWORD=surfsense \ + -e POSTGRES_DB=surfsense \ + postgres:14 +``` + +Wait ~10 seconds, then confirm it is healthy: + +```bash +docker exec surfsense-pg14-temp pg_isready -U surfsense +``` + +### Step 2 — Dump the database + +```bash +docker exec -e PGPASSWORD=surfsense surfsense-pg14-temp \ + pg_dump -U surfsense surfsense > surfsense_backup.sql +``` + +Verify the dump is valid: + +```bash +wc -l surfsense_backup.sql +grep "PostgreSQL database dump" surfsense_backup.sql +``` + +### Step 3 — Recover your SECRET\_KEY + +```bash +docker run --rm -v surfsense-data:/data alpine cat /data/.secret_key +``` + +Copy the printed value for the next step. + +### Step 4 — Set up the new stack + +```bash +git clone https://github.com/MODSetter/SurfSense.git +cd SurfSense/docker +cp .env.example .env +``` + +Set `SECRET_KEY` in `.env` to the value recovered above. + +### Step 5 — Start PostgreSQL 17 + +```bash +docker compose up -d db +``` + +Wait until ready: + +```bash +docker compose exec db pg_isready -U surfsense +``` + +### Step 6 — Restore the database + +```bash +docker compose exec -T db \ + psql -U surfsense -d surfsense < surfsense_backup.sql +``` + +Harmless notices like `ERROR: role "surfsense" already exists` are expected. + +### Step 7 — Start all services + +```bash +docker compose up -d +``` + +### Step 8 — Clean up + +After verifying everything works: + +```bash +# Remove temporary PG14 container +docker stop surfsense-pg14-temp && docker rm surfsense-pg14-temp + +# Remove old volume (irreversible — only after confirming migration success) +docker volume rm surfsense-data +``` + +--- + +## Troubleshooting + +### Script exits with "surfsense-postgres already exists" + +A previous migration attempt partially completed. Remove the incomplete volume and retry: + +```bash +docker volume rm surfsense-postgres +bash migrate-database.sh +``` + +### PostgreSQL 14 container fails to start + +Check the container logs: + +```bash +docker logs surfsense-pg14-temp +``` + +If you see permission errors, the data directory may need ownership correction. Run: + +```bash +docker exec surfsense-pg14-temp chown -R postgres:postgres /data/postgres +``` + +Then restart the container. + +### Empty or corrupt dump file + +If `surfsense_backup.sql` is smaller than expected, run the dump command again with verbose output: + +```bash +docker exec -e PGPASSWORD=surfsense surfsense-pg14-temp \ + pg_dump -U surfsense surfsense -v 2>&1 | head -40 +``` + +### Cannot find `/data/.secret_key` + +If the all-in-one was launched with `SECRET_KEY` set explicitly as an environment variable, the key was never written to the volume. Set the same value manually in `docker/.env`. If it is lost, generate a new one: + +```bash +openssl rand -base64 32 +``` + +Note: a new key invalidates all existing browser sessions — users will need to log in again.