<# .SYNOPSIS Build and deploy the Lean 101 Clients app to a Digital Ocean droplet over SSH. .DESCRIPTION Runs `docker compose` against `docker-compose.production.yml` on the remote host. The same script handles first-time bootstrap and subsequent updates: * On bootstrap (-Bootstrap): creates the remote directory, clones the repo (or updates if already present), uploads the local env file, and brings the stack up with `docker compose ... up -d --build`. * On update (default): SSHes to the host, fetches the requested branch, uploads a refreshed env file (if changed), then runs `docker compose ... up -d --build` followed by a healthcheck. The script never executes destructive commands without asking, except for recreating containers (which preserves the named Postgres volume). .PARAMETER RemoteHost Hostname or IP of the Digital Ocean droplet. Required. .PARAMETER RemoteUser SSH user on the droplet. Defaults to `root`. .PARAMETER RemotePath Absolute path on the droplet where the repo lives. Defaults to `/srv/lean101-clients`. .PARAMETER Branch Git branch to deploy. Defaults to `main`. .PARAMETER RepoUrl Git URL used during bootstrap when the remote directory is empty. Required only with -Bootstrap. .PARAMETER EnvFile Local path to the env file that should land on the droplet as `/.env.production`. Defaults to `.env.production`. .PARAMETER SshKey Optional path to an SSH private key. If omitted, the script relies on ssh-agent / default keys. .PARAMETER ComposeFile Compose file name on the remote host. Defaults to `docker-compose.production.yml`. .PARAMETER Bootstrap Run first-time setup (clone, upload env, build, up). .PARAMETER SkipBuild Pass `--no-build` to docker compose (use when only env changed). .PARAMETER Seed Run `python -m app.seed` inside the backend container after the stack is up. .PARAMETER Logs After deploy, tail logs for ~20 lines so you can verify the stack came up. .EXAMPLE ./deploy/Deploy.ps1 -RemoteHost 203.0.113.10 -Bootstrap -RepoUrl git@github.com:ponzischeme89/data-entry-app.git .EXAMPLE ./deploy/Deploy.ps1 -RemoteHost 203.0.113.10 #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string] $RemoteHost, [string] $RemoteUser = "root", [string] $RemotePath = "/srv/lean101-clients", [string] $Branch = "main", [string] $RepoUrl, [string] $EnvFile = ".env.production", [string] $SshKey, [string] $ComposeFile = "docker-compose.production.yml", [switch] $Bootstrap, [switch] $SkipBuild, [switch] $Seed, [switch] $Logs ) $ErrorActionPreference = "Stop" Set-StrictMode -Version Latest function Write-Step($message) { Write-Host "==> $message" -ForegroundColor Cyan } function Write-Warn($message) { Write-Host "!! $message" -ForegroundColor Yellow } function Resolve-RepoRoot { $scriptDir = Split-Path -Parent $MyInvocation.ScriptName if (-not $scriptDir) { $scriptDir = $PSScriptRoot } return (Resolve-Path (Join-Path $scriptDir "..")).Path } $RepoRoot = Resolve-RepoRoot 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 to $EnvFile and fill in production secrets first." } $sshTarget = "$RemoteUser@$RemoteHost" $sshOpts = @("-o", "StrictHostKeyChecking=accept-new") if ($SshKey) { $sshOpts += @("-i", $SshKey) } function Invoke-Ssh([string] $remoteCommand) { & ssh @sshOpts $sshTarget $remoteCommand if ($LASTEXITCODE -ne 0) { throw "Remote command failed (exit $LASTEXITCODE): $remoteCommand" } } function Invoke-Scp([string] $localPath, [string] $remoteDest) { & scp @sshOpts $localPath "$($sshTarget):$remoteDest" if ($LASTEXITCODE -ne 0) { throw "scp failed for $localPath -> $remoteDest" } } Write-Step "Verifying SSH connectivity to $sshTarget" Invoke-Ssh "echo connected as `$(whoami) on `$(hostname)" Write-Step "Verifying Docker is installed on the droplet" Invoke-Ssh "command -v docker >/dev/null 2>&1 && docker --version && docker compose version" if ($Bootstrap) { if (-not $RepoUrl) { throw "-RepoUrl is required when using -Bootstrap." } Write-Step "Bootstrapping $RemotePath from $RepoUrl ($Branch)" $bootstrapScript = @" set -euo pipefail mkdir -p '$RemotePath' cd '$RemotePath' if [ ! -d .git ]; then git clone --branch '$Branch' '$RepoUrl' . else git remote set-url origin '$RepoUrl' git fetch origin '$Branch' git checkout '$Branch' git reset --hard 'origin/$Branch' fi "@ Invoke-Ssh $bootstrapScript } else { Write-Step "Updating $RemotePath to latest $Branch" $updateScript = @" set -euo pipefail cd '$RemotePath' git fetch origin '$Branch' git checkout '$Branch' git reset --hard 'origin/$Branch' "@ Invoke-Ssh $updateScript } Write-Step "Uploading $EnvFile to $RemotePath/.env.production" Invoke-Scp $envPath "$RemotePath/.env.production" Invoke-Ssh "chmod 600 '$RemotePath/.env.production'" $composeArgs = @( "--env-file", ".env.production", "-f", $ComposeFile ) -join " " $buildFlag = if ($SkipBuild) { "" } else { "--build" } Write-Step "Pulling base images" Invoke-Ssh "cd '$RemotePath' && docker compose $composeArgs pull --ignore-pull-failures || true" Write-Step "Bringing the stack up (build=$([bool](-not $SkipBuild)))" Invoke-Ssh "cd '$RemotePath' && docker compose $composeArgs up -d $buildFlag --remove-orphans" Write-Step "Waiting for backend healthcheck" $healthScript = @" set -e cd '$RemotePath' for attempt 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 docker compose $composeArgs ps backend exit 1 "@ Invoke-Ssh $healthScript if ($Seed) { Write-Step "Seeding reference data" Invoke-Ssh "cd '$RemotePath' && docker compose $composeArgs exec -T backend python -m app.seed" } Write-Step "Final container 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 "Deployment complete." -ForegroundColor Green } finally { Pop-Location }