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.