<# .SYNOPSIS Deploy the Lean 101 Clients app to a Digital Ocean droplet over SSH. .DESCRIPTION Tars the local source tree, uploads it to the droplet, and runs docker compose up --build. No git required on the server. The same script handles first-time setup and subsequent updates. .PARAMETER RemoteHost Hostname or IP of the Digital Ocean droplet. Required. .PARAMETER RemoteUser SSH user. Defaults to 'root'. .PARAMETER RemotePath Absolute path on the droplet. Defaults to '/srv/lean101-clients'. .PARAMETER EnvFile Local path to the production env file. Defaults to '.env.production'. .PARAMETER SshKey Optional path to an SSH private key. .PARAMETER ComposeFile Compose file name on the remote host. Defaults to 'docker-compose.production.yml'. .PARAMETER Seed Run 'python -m app.seed' inside the backend container after the stack is up. .PARAMETER Logs Tail logs for ~60 lines after deploy to verify the stack came up. .PARAMETER SkipBuild Pass --no-build to docker compose (use when only env changed). .EXAMPLE ./deploy/Deploy.ps1 -RemoteHost 209.38.24.231 .EXAMPLE ./deploy/Deploy.ps1 -RemoteHost 209.38.24.231 -Seed -Logs #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string] $RemoteHost, [string] $RemoteUser = "root", [string] $RemotePath = "/srv/lean101-clients", [string] $EnvFile = ".env.production", [string] $SshKey, [string] $ComposeFile = "docker-compose.production.yml", [switch] $Seed, [switch] $Logs, [switch] $SkipBuild ) $ErrorActionPreference = "Stop" Set-StrictMode -Version Latest # ── Helpers ─────────────────────────────────────────────────────────────────── function Write-Step($msg) { Write-Host "==> $msg" -ForegroundColor Cyan } function Write-Warn($msg) { Write-Host "!! $msg" -ForegroundColor Yellow } function Get-RepoRoot { $dir = Split-Path -Parent $PSScriptRoot if (-not $dir) { $dir = (Get-Location).Path } return $dir } $RepoRoot = Get-RepoRoot $SshTarget = "$RemoteUser@$RemoteHost" $SshOpts = @("-o", "StrictHostKeyChecking=accept-new", "-o", "BatchMode=no") if ($SshKey) { $SshOpts += @("-i", $SshKey) } function Invoke-Ssh([string] $cmd) { & ssh @SshOpts $SshTarget $cmd if ($LASTEXITCODE -ne 0) { throw "Remote command failed (exit $LASTEXITCODE): $cmd" } } function Invoke-Scp([string] $local, [string] $remote) { & scp @SshOpts $local "${SshTarget}:${remote}" if ($LASTEXITCODE -ne 0) { throw "scp failed: $local -> $remote" } } # ── Resolve paths ───────────────────────────────────────────────────────────── Push-Location $RepoRoot try { $EnvPath = if ([System.IO.Path]::IsPathRooted($EnvFile)) { $EnvFile } else { Join-Path $RepoRoot $EnvFile } if (-not (Test-Path $EnvPath)) { throw "Env file not found at '$EnvPath'. Copy .env.production.example and fill in secrets." } # ── Connectivity check ────────────────────────────────────────────────────── Write-Step "Checking SSH connectivity to $SshTarget" Invoke-Ssh "echo connected as `$(whoami) on `$(hostname)" # ── Package source files ──────────────────────────────────────────────────── Write-Step "Packaging source files (excluding node_modules, caches, etc.)" $TarFile = Join-Path $env:TEMP "lean101-deploy-$(Get-Date -Format 'yyyyMMdd-HHmmss').tar.gz" $excludes = @( "--exclude=./node_modules", "--exclude=./frontend/node_modules", "--exclude=./frontend/.svelte-kit", "--exclude=./frontend/build", "--exclude=./.git", "--exclude=./__pycache__", "--exclude=./backend/__pycache__", "--exclude=./backend/app/__pycache__", "--exclude=./**/__pycache__", "--exclude=./*.pyc", "--exclude=./.env", "--exclude=./.env.production", "--exclude=./.env.alpha", "--exclude=./data_entry_app.db", "--exclude=./*.db" ) & tar -czf $TarFile @excludes -C $RepoRoot . if ($LASTEXITCODE -ne 0) { throw "tar failed" } $TarSize = [math]::Round((Get-Item $TarFile).Length / 1MB, 1) Write-Host " Archive: $TarFile ($TarSize MB)" # ── Upload env file ───────────────────────────────────────────────────────── Write-Step "Uploading env file" Invoke-Scp $EnvPath "$RemotePath/.env.production" Invoke-Ssh "chmod 600 '$RemotePath/.env.production'" # ── Upload and extract source ──────────────────────────────────────────────── Write-Step "Uploading source archive" Invoke-Scp $TarFile "/tmp/lean101-deploy.tar.gz" Remove-Item $TarFile -Force Write-Step "Extracting on server" Invoke-Ssh "mkdir -p '$RemotePath' && tar -xzf /tmp/lean101-deploy.tar.gz -C '$RemotePath' && rm /tmp/lean101-deploy.tar.gz" # ── Docker compose up ─────────────────────────────────────────────────────── $ComposeArgs = "--env-file .env.production -f $ComposeFile" $BuildFlag = if ($SkipBuild) { "--no-build" } else { "--build" } Write-Step "Bringing stack up (build=$(-not $SkipBuild))" Invoke-Ssh "cd '$RemotePath' && docker compose $ComposeArgs up -d $BuildFlag --remove-orphans" # ── Health check ──────────────────────────────────────────────────────────── Write-Step "Waiting for backend health check" $healthScript = @" set -e cd '$RemotePath' for i in `$(seq 1 30); do status=`$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' lean101-clients-backend 2>/dev/null || echo missing) case "`$status" in healthy|running) echo "backend is `$status"; exit 0 ;; *) printf '.'; sleep 4 ;; esac done echo; echo 'backend did not become healthy in time' >&2; exit 1 "@ Invoke-Ssh $healthScript # ── Optional seed ─────────────────────────────────────────────────────────── if ($Seed) { Write-Step "Seeding reference data" Invoke-Ssh "cd '$RemotePath' && docker compose $ComposeArgs exec -T backend python -m app.seed" } # ── Final status ──────────────────────────────────────────────────────────── Write-Step "Stack status" Invoke-Ssh "cd '$RemotePath' && docker compose $ComposeArgs ps" if ($Logs) { Write-Step "Recent logs (last 60 lines)" Invoke-Ssh "cd '$RemotePath' && docker compose $ComposeArgs logs --tail=60" } Write-Host "" Write-Host "Deployment complete -> https://clients.lean-101.com.au" -ForegroundColor Green } finally { Pop-Location }