diff --git a/.gitignore b/.gitignore index 0efc5d4..1daf6f7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ .env -tmp \ No newline at end of file +tmp +migration/accounts.csv +migration/accounts-plan.ndjson \ No newline at end of file diff --git a/README.md b/README.md index 1577e28..08eee94 100644 --- a/README.md +++ b/README.md @@ -48,9 +48,15 @@ On Windows PowerShell: Put the generated 24-character value in `RC_DES_KEY` in `.env`. -## Prepare Linux data directories +## Persistent Stalwart data -The Stalwart container runs as uid/gid `2000`. Create the bind-mounted directories and set the required ownership before starting the stack: +Stalwart configuration, mail, and certificates are stored in Docker-managed volumes. This avoids host filesystem ownership problems after Docker Desktop or WSL restarts. The `stalwart-data-init` service assigns the ownership Stalwart requires before it starts. + +Do not use `docker compose down --volumes` or `docker volume rm` for this project unless you intend to permanently delete Stalwart's configuration and mail data. + +To protect against laptop loss, disk failure, or a Docker Desktop reset, back up the three `eternitymail-stalwart-*` volumes to storage outside Docker Desktop. Docker volumes protect data across normal container recreation, not a destroyed Docker data disk. + +The legacy Linux bind-mount preparation command applies only to deployments which still use `DATA_ROOT` bind mounts: ```bash chmod +x scripts/setup-data-dirs.sh @@ -104,6 +110,38 @@ docker compose down Persistent data is stored under `DATA_ROOT` in the `stalwart`, `roundcube-db`, and `roundcube-config` directories. +## Backup, restore, and server migration + +Backups include Stalwart configuration, certificates, mailboxes, Roundcube's database/configuration, `.env`, and `docker-compose.yml`. The backup stops the mail services briefly so the RocksDB and MariaDB files are consistent, then starts them again. + +On Linux, create a backup outside Docker Desktop or the server's Docker data disk: + +```bash +chmod +x scripts/backup-server.sh scripts/restore-server.sh +./scripts/backup-server.sh /mnt/backup/eternitymail +``` + +On Windows PowerShell: + +```powershell +.\scripts\backup-server.ps1 -Destination D:\Backups\EternityMail +``` + +Restore that backup to Docker Desktop with: + +```powershell +.\scripts\restore-server.ps1 -Archive D:\Backups\EternityMail\eternitymail-.tar.gz +``` + +Copy the resulting `.tar.gz` archive, this repository, and the `ep-roundcube-skin` checkout to the new Linux server. Install Docker Engine with the Compose plugin, then restore from the repository root: + +```bash +sudo ./scripts/restore-server.sh /mnt/backup/eternitymail/eternitymail-.tar.gz +docker compose ps +``` + +Restore refuses to replace an existing deployment unless `--force` (Linux) or `-Force` (PowerShell) is supplied. That option stops the stack and permanently replaces its Stalwart volumes and Roundcube bind-mount data. Keep the same `.env` from the archive, particularly `RC_DES_KEY`, passwords, hostname, and recovery administrator setting. Before moving production traffic, update the new server's DNS, firewall, and TLS configuration, then verify IMAPS and SMTP. + ## Network ports The host bindings are configurable in `.env`: diff --git a/docker-compose.yml b/docker-compose.yml index 2b9d5b7..dc6e47e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,16 @@ services: + stalwart-data-init: + image: alpine:3.21 + container_name: ${PROJECT_NAME:-eternitymail}-stalwart-data-init + restart: "no" + + command: ["sh", "-c", "chown -R 2000:2000 /etc/stalwart /var/lib/stalwart /etc/stalwart/certs"] + + volumes: + - stalwart-etc:/etc/stalwart + - stalwart-data:/var/lib/stalwart + - stalwart-certs:/etc/stalwart/certs + stalwart: image: stalwartlabs/stalwart:v0.16@sha256:93c574e52249c1ebf90061da2c4c0756a7b72abfcc1fec34506a03c2e38b5977 container_name: ${PROJECT_NAME:-eternitymail}-stalwart @@ -16,6 +28,10 @@ services: environment: STALWART_RECOVERY_ADMIN: ${STALWART_RECOVERY_ADMIN} + depends_on: + stalwart-data-init: + condition: service_completed_successfully + ports: - "${STALWART_SMTP_PORT:-25}:25/tcp" - "${STALWART_SUBMISSION_PORT:-587}:587/tcp" @@ -23,9 +39,9 @@ services: - "${STALWART_ADMIN_BIND-127.0.0.1}:${STALWART_ADMIN_PORT:-8081}:8080/tcp" volumes: - - ${DATA_ROOT:-/opt/eternitymail}/stalwart/etc:/etc/stalwart - - ${DATA_ROOT:-/opt/eternitymail}/stalwart/data:/var/lib/stalwart - - ${DATA_ROOT:-/opt/eternitymail}/stalwart/certs:/etc/stalwart/certs:ro + - stalwart-etc:/etc/stalwart + - stalwart-data:/var/lib/stalwart + - stalwart-certs:/etc/stalwart/certs networks: eternitymail: @@ -111,4 +127,13 @@ services: networks: eternitymail: - name: ${PROJECT_NAME:-eternitymail} \ No newline at end of file + name: ${PROJECT_NAME:-eternitymail} + + +volumes: + stalwart-etc: + name: ${PROJECT_NAME:-eternitymail}-stalwart-etc + stalwart-data: + name: ${PROJECT_NAME:-eternitymail}-stalwart-data + stalwart-certs: + name: ${PROJECT_NAME:-eternitymail}-stalwart-certs \ No newline at end of file diff --git a/scripts/README.md b/scripts/README.md index 5d5c6c8..02a1f6b 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -2,6 +2,12 @@ These scripts support the EternityMail Docker Compose deployment. Run them from the repository root. +## Full server backup and restore + +`backup-server.sh` and `backup-server.ps1` create a portable `.tar.gz` backup containing all Stalwart named volumes, Roundcube database/configuration, and deployment settings. They stop the mail services for a consistent backup and start them after completion. + +`restore-server.sh` and `restore-server.ps1` restore that archive to Linux or Docker Desktop. They refuse to overwrite a deployment unless passed `--force` or `-Force`; that option permanently replaces current mail and Roundcube data. See the repository README for migration steps and commands. + ## `install-stalwart-cli.sh` Installs the latest official `stalwart-cli` release on Linux. The upstream installer places the binary in the system executable path, normally `/usr/local/bin`. diff --git a/scripts/backup-server.ps1 b/scripts/backup-server.ps1 new file mode 100644 index 0000000..f9f5c41 --- /dev/null +++ b/scripts/backup-server.ps1 @@ -0,0 +1,32 @@ +#requires -Version 5.1 +param([string]$Destination = (Join-Path (Split-Path $PSScriptRoot -Parent) "backups")) + +$root = Split-Path $PSScriptRoot -Parent +$project = (Get-Content (Join-Path $root '.env') | Where-Object { $_ -match '^PROJECT_NAME=' } | Select-Object -First 1) -replace '^PROJECT_NAME=', '' +if (-not $project) { $project = 'eternitymail' } +$timestamp = (Get-Date).ToUniversalTime().ToString('yyyyMMddTHHmmssZ') +$staging = Join-Path ([IO.Path]::GetTempPath()) "$project-backup-$timestamp" +$archive = Join-Path $Destination "$project-$timestamp.tar.gz" + +New-Item -ItemType Directory -Force -Path $staging, $Destination | Out-Null +try { + Push-Location $root + docker compose stop + New-Item -ItemType Directory -Force -Path "$staging\volumes", "$staging\bind-mounts" | Out-Null + Copy-Item '.env', 'docker-compose.yml' -Destination $staging + foreach ($volume in 'stalwart-etc', 'stalwart-data', 'stalwart-certs') { + docker run --rm -v "${project}-${volume}:/source:ro" -v "${staging}:/backup" alpine:3.21 tar -C /source -czf "/backup/volumes/$volume.tar.gz" . + if ($LASTEXITCODE) { throw "Failed to back up volume $volume" } + } + foreach ($directory in 'roundcube-db', 'roundcube-config') { + docker run --rm -v "/opt/eternitymail/${directory}:/source:ro" -v "${staging}:/backup" alpine:3.21 tar -C /source -czf "/backup/bind-mounts/$directory.tar.gz" . + if ($LASTEXITCODE) { throw "Failed to back up $directory" } + } + docker run --rm -v "${staging}:/backup" alpine:3.21 sh -c 'cd /backup && sha256sum .env docker-compose.yml volumes/*.tar.gz bind-mounts/*.tar.gz > SHA256SUMS && tar -czf /backup/archive.tar.gz .env docker-compose.yml volumes bind-mounts SHA256SUMS' + Move-Item "$staging\archive.tar.gz" $archive + Write-Output "Backup created: $archive" +} finally { + docker compose start 2>$null + Pop-Location -ErrorAction SilentlyContinue + Remove-Item -Recurse -Force $staging -ErrorAction SilentlyContinue +} \ No newline at end of file diff --git a/scripts/backup-server.sh b/scripts/backup-server.sh new file mode 100644 index 0000000..d8b2fee --- /dev/null +++ b/scripts/backup-server.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Creates a consistent, portable backup of Stalwart, Roundcube, and deployment settings. +set -euo pipefail + +root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +destination=${1:-"$root/backups"} +project=${PROJECT_NAME:-eternitymail} +timestamp=$(date -u +%Y%m%dT%H%M%SZ) +staging=$(mktemp -d) +archive="$destination/${project}-${timestamp}.tar.gz" + +cleanup() { + rm -rf "$staging" + docker compose -f "$root/docker-compose.yml" --env-file "$root/.env" start >/dev/null 2>&1 || true +} +trap cleanup EXIT + +[[ -f "$root/.env" ]] || { echo "Missing $root/.env" >&2; exit 1; } +mkdir -p "$destination" + +docker compose -f "$root/docker-compose.yml" --env-file "$root/.env" stop +mkdir -p "$staging/volumes" "$staging/bind-mounts" +cp "$root/.env" "$root/docker-compose.yml" "$staging/" + +for volume in stalwart-etc stalwart-data stalwart-certs; do + docker run --rm -v "${project}-${volume}:/source:ro" -v "$staging:/backup" alpine:3.21 \ + tar -C /source -czf "/backup/volumes/${volume}.tar.gz" . +done + +for directory in roundcube-db roundcube-config; do + docker run --rm -v "/opt/eternitymail/${directory}:/source:ro" -v "$staging:/backup" alpine:3.21 \ + tar -C /source -czf "/backup/bind-mounts/${directory}.tar.gz" . +done + +sha256sum "$staging"/.env "$staging"/docker-compose.yml "$staging"/volumes/*.tar.gz "$staging"/bind-mounts/*.tar.gz > "$staging/SHA256SUMS" +tar -C "$staging" -czf "$archive" . +echo "Backup created: $archive" \ No newline at end of file diff --git a/scripts/restore-server.ps1 b/scripts/restore-server.ps1 new file mode 100644 index 0000000..3969e53 --- /dev/null +++ b/scripts/restore-server.ps1 @@ -0,0 +1,48 @@ +#requires -Version 5.1 +param( + [Parameter(Mandatory = $true)][string]$Archive, + [switch]$Force +) + +$root = Split-Path $PSScriptRoot -Parent +if (-not (Test-Path -LiteralPath $Archive -PathType Leaf)) { throw "Backup archive not found: $Archive" } +if ((Test-Path (Join-Path $root '.env')) -and -not $Force) { + throw 'Refusing to overwrite an existing deployment without -Force.' +} + +$staging = Join-Path ([IO.Path]::GetTempPath()) "eternitymail-restore-$([guid]::NewGuid())" +New-Item -ItemType Directory -Force -Path $staging | Out-Null +try { + docker run --rm -v "${Archive}:/backup/archive.tar.gz:ro" -v "${staging}:/restore" alpine:3.21 tar -C /restore -xzf /backup/archive.tar.gz + if ($LASTEXITCODE) { throw 'Could not extract backup archive.' } + docker run --rm -v "${staging}:/restore:ro" alpine:3.21 sh -c 'cd /restore && sha256sum -c SHA256SUMS' + if ($LASTEXITCODE) { throw 'Backup checksum verification failed.' } + + if ($Force) { + Push-Location $root + docker compose down + docker volume rm eternitymail-stalwart-etc eternitymail-stalwart-data eternitymail-stalwart-certs 2>$null + docker run --rm -v /opt/eternitymail:/data alpine:3.21 sh -c 'rm -rf /data/roundcube-db /data/roundcube-config' + Pop-Location + } + + Copy-Item "$staging\.env" (Join-Path $root '.env') -Force + $project = (Get-Content (Join-Path $root '.env') | Where-Object { $_ -match '^PROJECT_NAME=' } | Select-Object -First 1) -replace '^PROJECT_NAME=', '' + if (-not $project) { $project = 'eternitymail' } + foreach ($volume in 'stalwart-etc', 'stalwart-data', 'stalwart-certs') { + docker volume create "${project}-${volume}" | Out-Null + docker run --rm -v "${project}-${volume}:/destination" -v "${staging}:/restore:ro" alpine:3.21 tar -C /destination -xzf "/restore/volumes/$volume.tar.gz" + if ($LASTEXITCODE) { throw "Could not restore volume $volume" } + } + foreach ($directory in 'roundcube-db', 'roundcube-config') { + docker run --rm -v /opt/eternitymail:/data -v "${staging}:/restore:ro" alpine:3.21 sh -c "mkdir -p /data/$directory && tar -C /data/$directory -xzf /restore/bind-mounts/$directory.tar.gz" + if ($LASTEXITCODE) { throw "Could not restore $directory" } + } + Push-Location $root + docker compose up -d + Pop-Location + Write-Output 'Restore complete. Verify with: docker compose ps' +} finally { + Pop-Location -ErrorAction SilentlyContinue + Remove-Item -Recurse -Force $staging -ErrorAction SilentlyContinue +} \ No newline at end of file diff --git a/scripts/restore-server.sh b/scripts/restore-server.sh new file mode 100644 index 0000000..b07d809 --- /dev/null +++ b/scripts/restore-server.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Restores an archive produced by backup-server.sh. Existing server state requires --force. +set -euo pipefail + +root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +archive=${1:-} +force=${2:-} +project=${PROJECT_NAME:-eternitymail} +staging=$(mktemp -d) + +cleanup() { rm -rf "$staging"; } +trap cleanup EXIT + +[[ -n "$archive" && -f "$archive" ]] || { echo "Usage: $0 /path/to/backup.tar.gz [--force]" >&2; exit 1; } +[[ "$force" == "--force" || ! -e "$root/.env" ]] || { echo "Refusing to overwrite an existing deployment without --force." >&2; exit 1; } + +tar -C "$staging" -xzf "$archive" +(cd "$staging" && sha256sum -c SHA256SUMS) + +if [[ "$force" == "--force" ]]; then + docker compose -f "$root/docker-compose.yml" --env-file "$root/.env" down || true + docker volume rm "${project}-stalwart-etc" "${project}-stalwart-data" "${project}-stalwart-certs" 2>/dev/null || true + rm -rf /opt/eternitymail/roundcube-db /opt/eternitymail/roundcube-config +fi + +cp "$staging/.env" "$root/.env" +project=$(sed -n 's/^PROJECT_NAME=//p' "$root/.env" | head -n1) +project=${project:-eternitymail} +for volume in stalwart-etc stalwart-data stalwart-certs; do + docker volume create "${project}-${volume}" >/dev/null + docker run --rm -v "${project}-${volume}:/destination" -v "$staging:/backup:ro" alpine:3.21 \ + tar -C /destination -xzf "/backup/volumes/${volume}.tar.gz" +done + +for directory in roundcube-db roundcube-config; do + mkdir -p "/opt/eternitymail/${directory}" + docker run --rm -v "/opt/eternitymail/${directory}:/destination" -v "$staging:/backup:ro" alpine:3.21 \ + tar -C /destination -xzf "/backup/bind-mounts/${directory}.tar.gz" +done + +docker compose -f "$root/docker-compose.yml" --env-file "$root/.env" up -d +echo "Restore complete. Verify with: docker compose ps" \ No newline at end of file