<# .SYNOPSIS Deploys the complete Memby Docker Compose stack to MATT-NAS. .DESCRIPTION Packages the local server build context, Docker Compose file and .env.example, then streams them to the NAS over one SSH connection. The remote deployment: - checks Docker and Docker Compose; - installs .env from the local .env.example, overwriting the deployed copy, and keeps the old one alongside as .env.previous; - preserves the named database volume; - pulls Redis and PostgreSQL, then builds the Memby server; - starts each service and waits for its health check; - restores the previous application files if activation fails. Configuration lives in the local .env.example and is included directly in every deployment. It does not need to be committed or pushed before deployment. SSH performs the password prompt directly. The password is never read or stored by this script. This deploys the current local working tree, including uncommitted server changes. .EXAMPLE .\deploy-server.ps1 .EXAMPLE .\deploy-server.ps1 -Destination /share/Docker/Memby-test -HealthTimeoutSeconds 180 #> #Requires -Version 7.2 [CmdletBinding()] param( [Parameter()] [ValidateNotNullOrEmpty()] [string] $SourceDirectory = $PSScriptRoot, [Parameter()] [ValidateNotNullOrEmpty()] [string] $RemoteHost = '10.0.0.213', [Parameter()] [ValidateNotNullOrEmpty()] [string] $RemoteUser = 'ssh', [Parameter()] [ValidateNotNullOrEmpty()] [string] $Destination = '/share/Docker/Memby', [Parameter()] [ValidateRange(30, 600)] [int] $HealthTimeoutSeconds = 120 ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $script:CurrentStep = 0 $script:TotalSteps = 5 function Write-Banner { Write-Host '' Write-Host '╭─ Memby deployment' -ForegroundColor Magenta Write-Host "│ Source $SourceDirectory (local working tree)" -ForegroundColor DarkGray Write-Host "│ Target ${RemoteUser}@${RemoteHost}:$Destination" -ForegroundColor DarkGray Write-Host '╰─' -ForegroundColor Magenta Write-Host '' } function Write-Step { param( [Parameter(Mandatory)] [string] $Message ) $script:CurrentStep++ Write-Host ("● [{0}/{1}] {2}" -f $script:CurrentStep, $script:TotalSteps, $Message) -ForegroundColor Cyan } function Write-Detail { param( [Parameter(Mandatory)] [string] $Message ) Write-Host " ↳ $Message" -ForegroundColor DarkGray } function Write-Success { param( [Parameter(Mandatory)] [string] $Message ) Write-Host "✓ $Message" -ForegroundColor Green } function Get-RequiredCommand { param( [Parameter(Mandatory)] [string] $Name ) $command = Get-Command $Name -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 if (-not $command) { throw "Required command '$Name' was not found in PATH." } return $command.Source } function Assert-SafeRemoteSettings { if ($RemoteHost -notmatch '^[A-Za-z0-9._:-]+$') { throw "RemoteHost contains unsupported characters: '$RemoteHost'." } if ($RemoteUser -notmatch '^[A-Za-z0-9._-]+$') { throw "RemoteUser contains unsupported characters: '$RemoteUser'." } if ($Destination -notmatch '^/[A-Za-z0-9._/-]+$') { throw "Destination must be an absolute Linux path using only letters, numbers, '.', '_', '-', and '/'." } $segments = $Destination.Split('/', [System.StringSplitOptions]::RemoveEmptyEntries) if ($segments -contains '..') { throw "Destination cannot contain '..' path segments." } $broadPaths = @( '/', '/bin', '/boot', '/dev', '/etc', '/home', '/lib', '/lib64', '/opt', '/proc', '/root', '/run', '/sbin', '/srv', '/sys', '/tmp', '/usr', '/var' ) if ($Destination -in $broadPaths) { throw "Destination '$Destination' is too broad to replace safely." } } function New-DeploymentArchive { param( [Parameter(Mandatory)] [string] $RepositoryDirectory, [Parameter(Mandatory)] [string] $ArchivePath ) & $script:TarCommand -cf $ArchivePath -C $RepositoryDirectory server docker-compose.yml .env.example if ($LASTEXITCODE -ne 0) { throw "Unable to create the deployment archive (tar exit code $LASTEXITCODE)." } if (-not (Test-Path -LiteralPath $ArchivePath -PathType Leaf)) { throw 'The deployment archive was not created.' } } function Send-ArchiveOverSsh { param( [Parameter(Mandatory)] [string] $ArchivePath, [Parameter(Mandatory)] [string] $RemoteCommand ) $startInfo = [System.Diagnostics.ProcessStartInfo]::new() $startInfo.FileName = $script:SshCommand $startInfo.UseShellExecute = $false $startInfo.RedirectStandardInput = $true # Force interactive password authentication as requested. OpenSSH reads the # password from the console while stdin carries the tar archive. foreach ($argument in @( '-o', 'PubkeyAuthentication=no', '-o', 'PreferredAuthentications=keyboard-interactive,password', '-o', 'NumberOfPasswordPrompts=3', "$RemoteUser@$RemoteHost", $RemoteCommand )) { [void] $startInfo.ArgumentList.Add($argument) } $process = [System.Diagnostics.Process]::new() $process.StartInfo = $startInfo $archiveStream = $null $processStarted = $false $exitCode = $null try { if (-not $process.Start()) { throw 'SSH could not be started.' } $processStarted = $true $archiveStream = [System.IO.File]::OpenRead($ArchivePath) try { $archiveStream.CopyTo($process.StandardInput.BaseStream) } catch [System.IO.IOException] { # The remote side exited before reading the whole archive — a failed early # step, or a shell that rejected the command. Its own message is already on # screen, so let the exit code below do the explaining rather than burying it # under a broken-pipe stack trace. } try { $process.StandardInput.Close() } catch [System.IO.IOException] { } $process.WaitForExit() $exitCode = $process.ExitCode } catch { if ($processStarted -and -not $process.HasExited) { $process.StandardInput.Close() $process.WaitForExit() } throw } finally { if ($archiveStream) { $archiveStream.Dispose() } $process.Dispose() } if ($exitCode -ne 0) { throw "Remote deployment failed with SSH exit code $exitCode. Review the last remote step above." } } Write-Banner $deploymentTimer = [System.Diagnostics.Stopwatch]::StartNew() Write-Step 'Checking local prerequisites and settings' Assert-SafeRemoteSettings $script:TarCommand = Get-RequiredCommand -Name 'tar' $script:SshCommand = Get-RequiredCommand -Name 'ssh' Write-Detail "SSH $script:SshCommand" Write-Success 'Local prerequisites are ready' Write-Host '' $workDirectory = Join-Path ([System.IO.Path]::GetTempPath()) ("memby-deploy-" + [guid]::NewGuid().ToString('N')) $archivePath = Join-Path $workDirectory 'memby-deployment.tar' try { [void] (New-Item -ItemType Directory -Path $workDirectory) Write-Step 'Selecting the local deployment source' $checkoutDirectory = (Resolve-Path -LiteralPath $SourceDirectory -ErrorAction Stop).Path Write-Success "Using $checkoutDirectory" Write-Host '' Write-Step 'Validating the Compose deployment payload' $requiredPaths = @( (Join-Path $checkoutDirectory 'server'), (Join-Path $checkoutDirectory 'server/Dockerfile'), (Join-Path $checkoutDirectory 'server/go.mod'), (Join-Path $checkoutDirectory 'docker-compose.yml'), (Join-Path $checkoutDirectory '.env.example') ) foreach ($requiredPath in $requiredPaths) { if (-not (Test-Path -LiteralPath $requiredPath)) { throw "Required deployment file is missing: $requiredPath" } } Write-Detail 'server/ build context' Write-Detail 'docker-compose.yml' Write-Detail '.env.example' Write-Success 'Deployment payload is complete' Write-Host '' Write-Step 'Packaging the release' New-DeploymentArchive -RepositoryDirectory $checkoutDirectory -ArchivePath $archivePath $archiveSize = (Get-Item -LiteralPath $archivePath).Length Write-Detail ("Archive size {0:N1} MiB" -f ($archiveSize / 1MB)) Write-Success 'Release archive is ready' Write-Host '' # This template is single-quoted so PowerShell does not expand the remote # shell's variables. Replacement values are validated before insertion. $remoteCommand = @' set -eu destination='__DESTINATION__' health_timeout=__HEALTH_TIMEOUT__ parent=$(dirname "$destination") staging="${destination}.new.$$" backup="${destination}.previous.$$" activated=0 previous_stopped=0 step() { printf '\033[36m ● %s\033[0m\n' "$1" } detail() { printf '\033[90m ↳ %s\033[0m\n' "$1" } success() { printf '\033[32m ✓ %s\033[0m\n' "$1" } failure() { printf '\033[31m ✗ %s\033[0m\n' "$1" >&2 } rollback() { status=$? trap - EXIT if [ "$status" -eq 0 ]; then return fi failure "Deployment failed; cleaning up" rm -rf -- "$staging" if [ "$activated" -eq 1 ]; then if [ -f "$destination/docker-compose.yml" ]; then detail "Stopping the incomplete application release" ( cd "$destination" docker compose down --remove-orphans >/dev/null 2>&1 ) || true fi rm -rf -- "$destination" if [ -e "$backup" ] || [ -L "$backup" ]; then detail "Restoring the previous application release" mv -- "$backup" "$destination" ( cd "$destination" docker compose up -d --build --remove-orphans >/dev/null 2>&1 ) || true failure "Previous application files were restored" fi elif [ "$previous_stopped" -eq 1 ] && [ -f "$destination/docker-compose.yml" ]; then detail "Restarting the previous application release" ( cd "$destination" docker compose up -d --build --remove-orphans >/dev/null 2>&1 ) || true fi exit "$status" } wait_for_service() { service="$1" elapsed=0 detail "Waiting for $service" while [ "$elapsed" -lt "$health_timeout" ]; do container_id=$(docker compose ps --all -q "$service" 2>/dev/null || true) if [ -n "$container_id" ]; then state=$(docker inspect --format '{{.State.Status}}' "$container_id" 2>/dev/null || true) health=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$container_id" 2>/dev/null || true) if [ "$state" = 'running' ] && { [ "$health" = 'healthy' ] || [ "$health" = 'none' ]; }; then success "$service is $state ($health)" return 0 fi if [ "$state" = 'exited' ] || [ "$state" = 'dead' ]; then failure "$service entered state: $state" docker compose logs --no-color --tail 60 "$service" || true return 1 fi fi sleep 2 elapsed=$((elapsed + 2)) done failure "$service did not become healthy within ${health_timeout}s" docker compose logs --no-color --tail 60 "$service" || true return 1 } trap rollback EXIT trap 'exit 130' INT TERM step '[remote 1/8] Checking Docker' if ! command -v docker >/dev/null 2>&1; then failure 'Docker is not installed on the NAS' exit 1 fi docker info >/dev/null 2>&1 || { failure 'Docker is installed but the daemon is unavailable' exit 1 } docker compose version >/dev/null 2>&1 || { failure 'The Docker Compose v2 plugin is not installed' exit 1 } success "$(docker --version)" success "$(docker compose version)" step '[remote 2/8] Extracting the release' # Checked before anything is created: the staging directory, the swap and the backup all # need write access to the parent, and "can't create directory" from BusyBox halfway # through a deployment is a poor way to learn the account cannot write there. mkdir -p -- "$parent" 2>/dev/null || true if [ ! -d "$parent" ]; then failure "$parent does not exist and could not be created by $(id -un)" detail 'Pick a path this account can write to with -Destination' exit 1 fi if [ ! -w "$parent" ]; then failure "$(id -un) cannot write to $parent" detail 'Deploy somewhere this account owns, for example:' detail ' .\deploy-server.ps1 -Destination /share/Docker/Memby' detail "or grant this account write access to $parent" exit 1 fi rm -rf -- "$staging" mkdir -- "$staging" tar -xf - -C "$staging" test -f "$staging/docker-compose.yml" test -f "$staging/server/Dockerfile" success 'Release extracted' step '[remote 3/8] Installing configuration from .env.example' # The local .env.example is the single source of truth for configuration and carries # real values rather than placeholders. Every deployment overwrites the deployed .env # with it, so neither a Git push nor an SSH edit is needed. previous_password='' if [ -f "$destination/.env" ]; then previous_password=$(sed -n 's/^POSTGRES_PASSWORD=//p' "$destination/.env" | head -n 1 | tr -d '\r') # Kept beside the new one purely so a bad edit is recoverable by hand. cp -- "$destination/.env" "$staging/.env.previous" detail 'Previous .env saved as .env.previous' fi cp -- "$staging/.env.example" "$staging/.env" success 'Installed .env from .env.example' new_password=$(sed -n 's/^POSTGRES_PASSWORD=//p' "$staging/.env" | head -n 1 | tr -d '\r') admin_token=$(sed -n 's/^MEMBY_ADMIN_TOKEN=//p' "$staging/.env" | head -n 1 | tr -d '\r') emby_url=$(sed -n 's/^MEMBY_EMBY_URL=//p' "$staging/.env" | head -n 1 | tr -d '\r') configured_port=$(sed -n 's/^MEMBY_PORT=//p' "$staging/.env" | head -n 1 | tr -d '\r') if [ -z "$new_password" ]; then failure 'POSTGRES_PASSWORD is empty in .env.example; Compose will refuse to start' exit 1 fi if [ -z "$emby_url" ]; then failure 'MEMBY_EMBY_URL is empty in .env.example' exit 1 fi if [ -z "$admin_token" ]; then failure 'MEMBY_ADMIN_TOKEN is empty in .env.example; refusing to deploy with /admin disabled' exit 1 fi if [ "$configured_port" != '32768' ]; then failure "MEMBY_PORT must be 32768 for the mserver.sublogue.com reverse proxy (found: ${configured_port:-unset})" exit 1 fi success 'Required gateway configuration is present' if [ -n "$previous_password" ] && [ "$previous_password" != "$new_password" ]; then failure 'POSTGRES_PASSWORD differs from the deployed value' detail 'The PostgreSQL volume is always preserved; restore the deployed password in .env.example' detail 'Change the database credential separately with an explicit migration if required' exit 1 fi ( cd "$staging" docker compose config --quiet ) success 'Compose configuration is valid' step '[remote 4/8] Pulling PostgreSQL and Redis' ( cd "$staging" docker compose pull postgres redis ) success 'Dependency images are ready' step '[remote 5/8] Building memby-server' ( cd "$staging" docker compose build --pull server ) success 'Server image built' step '[remote 6/8] Activating the release' rm -rf -- "$backup" if [ -e "$destination" ] || [ -L "$destination" ]; then if [ -f "$destination/docker-compose.yml" ]; then # Compose projects created by older releases may use a different project # name. Stop them from their original directory before moving it so their # published ports (especially 32768) are released for the new stack. detail 'Stopping the previous Compose application' ( cd "$destination" docker compose down --remove-orphans ) previous_stopped=1 success 'Previous Compose application stopped' fi mv -- "$destination" "$backup" fi mv -- "$staging" "$destination" activated=1 success 'Release activated' step '[remote 7/8] Starting the Compose stack' cd "$destination" docker compose up -d --remove-orphans success 'Compose start command completed' step '[remote 8/8] Waiting for healthy services' wait_for_service postgres wait_for_service redis wait_for_service server published_address=$(docker compose port server 32768 | head -n 1) actual_port=${published_address##*:} if [ "$actual_port" != '32768' ]; then failure "Memby published the wrong NAS port: ${published_address:-none}" exit 1 fi server_container_id=$(docker compose ps -q server) if ! docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$server_container_id" | grep -q '^MEMBY_ADMIN_TOKEN=.'; then failure 'MEMBY_ADMIN_TOKEN was not passed into the running server container' exit 1 fi success 'Runtime configuration includes the admin token' printf '\n' docker compose ps printf '\n' rm -rf -- "$backup" activated=0 trap - EXIT INT TERM success 'Remote deployment is healthy' success 'Memby gateway: https://mserver.sublogue.com' '@ $remoteCommand = $remoteCommand.Replace('__DESTINATION__', $Destination) $remoteCommand = $remoteCommand.Replace('__HEALTH_TIMEOUT__', $HealthTimeoutSeconds.ToString()) # This file is edited on Windows, so the here-string above arrives with whatever line # endings it was saved with. A remote shell reads a trailing carriage return as part # of the token — 'set -eu\r' fails with "illegal option" before anything runs — so the # script is normalised to LF here rather than depending on how it was saved. $remoteCommand = $remoteCommand.Replace("`r`n", "`n").Replace("`r", "`n") Write-Step "Deploying to $RemoteHost" Write-Detail 'One SSH password prompt will appear' Write-Detail 'Remote build output follows' Write-Host '' Send-ArchiveOverSsh -ArchivePath $archivePath -RemoteCommand $remoteCommand $deploymentTimer.Stop() Write-Host '' Write-Success ("Deployment complete in {0:mm\:ss}" -f $deploymentTimer.Elapsed) Write-Host ' Memby gateway: https://mserver.sublogue.com' -ForegroundColor White Write-Host " NAS endpoint: http://${RemoteHost}:32768" -ForegroundColor DarkGray Write-Host " Install path: ${RemoteHost}:$Destination" -ForegroundColor DarkGray } catch { $deploymentTimer.Stop() Write-Host '' Write-Host "✗ Deployment stopped after $($deploymentTimer.Elapsed.ToString('mm\:ss'))" -ForegroundColor Red Write-Host " $($_.Exception.Message)" -ForegroundColor Red throw } finally { if (Test-Path -LiteralPath $workDirectory) { Remove-Item -LiteralPath $workDirectory -Recurse -Force } }