Add backup and restore scripts
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user