<# .SYNOPSIS Lean 101 Website deployment script — ships the Flask site to the 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. Coexists with the clients app on the same droplet — different container name, different host port, different remote path. Host nginx routes by hostname. .PARAMETER RemoteHost Hostname or IP of the Digital Ocean droplet. Required. .PARAMETER RemoteUser SSH user. Defaults to 'root'. .PARAMETER AppName Human-readable app name shown in the banner. Defaults to 'Website'. .PARAMETER AppSlug Lowercase slug used in container/log labels. Defaults to 'website'. .PARAMETER RemotePath Absolute path on the droplet. Defaults to '/srv/lean-101-website-flask' (deliberately distinct from the old /srv/lean101-website SvelteKit dir). .PARAMETER ContainerName Backend container name to inspect. Defaults to 'lean101-website-flask' (must match container_name in docker-compose.yml). .PARAMETER PortEnvKey Env var name in the env file that holds the published port. Defaults to 'WEBSITE_APP_PORT'. .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.yml'. .PARAMETER Logs Tail logs for ~60 lines after deploy. .PARAMETER SkipBuild Pass --no-build to docker compose (use when only env changed). .PARAMETER NoBanner Suppress the ASCII banner. .EXAMPLE ./Deploy.ps1 -RemoteHost 209.38.24.231 .EXAMPLE ./Deploy.ps1 -RemoteHost 209.38.24.231 -Logs #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string] $RemoteHost, [string] $RemoteUser = "root", [string] $AppName = "Website", [string] $AppSlug = "website", [string] $RemotePath = "/srv/lean-101-website-flask", [string] $ContainerName = "lean101-website-flask", [string] $PortEnvKey = "WEBSITE_APP_PORT", [string] $EnvFile = ".env.production", [string] $SshKey, [string] $ComposeFile = "docker-compose.yml", [switch] $Logs, [switch] $SkipBuild, [switch] $NoBanner ) $ErrorActionPreference = "Stop" Set-StrictMode -Version Latest # Palette $Esc = [char]27 $C = @{ Reset = "$Esc[0m"; Dim = "$Esc[2m"; Bold = "$Esc[1m" Magenta = "$Esc[38;5;177m"; Pink = "$Esc[38;5;213m" Cyan = "$Esc[38;5;87m"; Teal = "$Esc[38;5;79m" Green = "$Esc[38;5;120m"; Yellow = "$Esc[38;5;221m" Red = "$Esc[38;5;203m"; Grey = "$Esc[38;5;244m" Blue = "$Esc[38;5;117m" } $Glyph = @{ Step='▸'; OK='✓'; Warn='!'; Info='·'; Fail='✗'; Arrow='→'; Spark='✦' } function Write-Banner { if ($NoBanner) { return } $line = '─' * 62 $title = "Lean 101 Website Deployment" $sub = "App: $AppName Slug: $AppSlug Target: $RemoteUser@$RemoteHost" Write-Host "" Write-Host ("$($C.Magenta)╭$line╮$($C.Reset)") Write-Host ("$($C.Magenta)│$($C.Reset) $($C.Bold)$($C.Cyan)$($Glyph.Spark) $title$($C.Reset)" + (' ' * (60 - $title.Length - 3)) + "$($C.Magenta)│$($C.Reset)") Write-Host ("$($C.Magenta)│$($C.Reset) $($C.Dim)$($C.Grey)$sub$($C.Reset)" + (' ' * [Math]::Max(0, 60 - $sub.Length - 2)) + "$($C.Magenta)│$($C.Reset)") Write-Host ("$($C.Magenta)╰$line╯$($C.Reset)") Write-Host "" } $script:StepIndex = 0 function Write-Step([string]$msg) { $script:StepIndex++ $num = "{0:D2}" -f $script:StepIndex Write-Host ("$($C.Dim)$($C.Grey)[$num]$($C.Reset) $($C.Bold)$($C.Cyan)$($Glyph.Step)$($C.Reset) $($C.Bold)$msg$($C.Reset)") } function Write-Ok([string]$msg) { Write-Host (" $($C.Green)$($Glyph.OK)$($C.Reset) $($C.Green)$msg$($C.Reset)") } function Write-Warn([string]$msg) { Write-Host (" $($C.Yellow)$($Glyph.Warn)$($C.Reset) $($C.Yellow)$msg$($C.Reset)") } function Write-Info([string]$msg) { Write-Host (" $($C.Dim)$($C.Grey)$($Glyph.Info) $msg$($C.Reset)") } function Write-Fail([string]$msg) { Write-Host (" $($C.Red)$($Glyph.Fail)$($C.Reset) $($C.Red)$msg$($C.Reset)") } $Spinner = @('⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏') function Invoke-Spinner { param( [Parameter(Mandatory = $true)] [string] $Label, [Parameter(Mandatory = $true)] [string] $FilePath, [string[]] $ArgList = @(), [string] $StdinFile, [switch] $ShowOutput ) $outFile = [System.IO.Path]::GetTempFileName() $errFile = [System.IO.Path]::GetTempFileName() $startParams = @{ FilePath = $FilePath; NoNewWindow = $true; PassThru = $true RedirectStandardOutput = $outFile; RedirectStandardError = $errFile } if ($ArgList.Count -gt 0) { $startParams.ArgumentList = $ArgList } if ($StdinFile) { $startParams.RedirectStandardInput = $StdinFile } $proc = Start-Process @startParams $start = Get-Date $i = 0 try { while (-not $proc.HasExited) { $elapsed = ((Get-Date) - $start).TotalSeconds $frame = $Spinner[$i % $Spinner.Count] Write-Host ("`r $($C.Cyan)$frame$($C.Reset) $($C.Dim)$($C.Grey)$Label $($C.Reset)$($C.Teal)$('{0,5:0.0}s' -f $elapsed)$($C.Reset) ") -NoNewline Start-Sleep -Milliseconds 90 $i++ } $proc.WaitForExit() $elapsed = ((Get-Date) - $start).TotalSeconds $stdout = if (Test-Path $outFile) { Get-Content $outFile -Raw } else { '' } $stderr = if (Test-Path $errFile) { Get-Content $errFile -Raw } else { '' } if ($proc.ExitCode -eq 0) { Write-Host ("`r $($C.Green)$($Glyph.OK)$($C.Reset) $Label $($C.Dim)$($C.Grey)$('{0,5:0.0}s' -f $elapsed)$($C.Reset)" + (' ' * 12)) if ($ShowOutput -and $stdout) { foreach ($ln in ($stdout -split "`r?`n")) { if ($ln) { Write-Info $ln } } } return $stdout } else { Write-Host ("`r $($C.Red)$($Glyph.Fail)$($C.Reset) $Label $($C.Dim)$($C.Grey)$('{0,5:0.0}s' -f $elapsed) (exit $($proc.ExitCode))$($C.Reset)" + (' ' * 8)) if ($stdout) { foreach ($ln in ($stdout -split "`r?`n")) { if ($ln) { Write-Host " $($C.Dim)$($C.Grey)$ln$($C.Reset)" } } } if ($stderr) { foreach ($ln in ($stderr -split "`r?`n")) { if ($ln) { Write-Host " $($C.Red)$ln$($C.Reset)" } } } throw "$Label failed (exit $($proc.ExitCode))" } } finally { Remove-Item $outFile -Force -ErrorAction SilentlyContinue Remove-Item $errFile -Force -ErrorAction SilentlyContinue } } function Get-RepoRoot { if ($PSScriptRoot) { return $PSScriptRoot } return (Get-Location).Path } function Get-EnvValue([string] $path, [string] $key) { foreach ($line in Get-Content $path) { $trimmed = $line.Trim() if (-not $trimmed -or $trimmed.StartsWith("#")) { continue } if ($trimmed -notmatch "=") { continue } $parts = $trimmed -split "=", 2 if ($parts[0].Trim() -eq $key) { return $parts[1].Trim() } } return $null } $RepoRoot = Get-RepoRoot $SshTarget = "$RemoteUser@$RemoteHost" $SshOpts = @("-o", "StrictHostKeyChecking=accept-new", "-o", "BatchMode=no", "-i", $SshKey) # Resolve env file path early so we can read SSH_PASSWORD before the main flow. $EnvPathEarly = if ([System.IO.Path]::IsPathRooted($EnvFile)) { $EnvFile } else { Join-Path $RepoRoot $EnvFile } function Resolve-PuttyTool([string] $name) { $cmd = Get-Command $name -ErrorAction SilentlyContinue if ($cmd) { return $cmd.Source } foreach ($candidate in @( "$env:ProgramFiles\PuTTY\$name.exe", "${env:ProgramFiles(x86)}\PuTTY\$name.exe", "$env:LOCALAPPDATA\Programs\PuTTY\$name.exe" )) { if ($candidate -and (Test-Path $candidate)) { return $candidate } } return $null } $SshPassword = $null $PlinkPath = $null $PscpPath = $null if (-not $SshKey) { if (-not (Test-Path $EnvPathEarly)) { throw "Env file not found at '$EnvPathEarly'. Create it and add SSH_PASSWORD= (or pass -SshKey to use key auth)." } $SshPassword = Get-EnvValue $EnvPathEarly 'SSH_PASSWORD' if (-not $SshPassword) { throw "SSH_PASSWORD is not set in '$EnvPathEarly'. Add a line `SSH_PASSWORD=` (or pass -SshKey to use key auth instead)." } $PlinkPath = Resolve-PuttyTool 'plink' $PscpPath = Resolve-PuttyTool 'pscp' if (-not $PlinkPath -or -not $PscpPath) { throw "plink/pscp (PuTTY) are required for password auth on Windows but were not found. Install PuTTY: ``winget install --id PuTTY.PuTTY -e`` (or ``choco install putty -y``), then reopen PowerShell." } # Pre-cache the SSH host key so subsequent -batch calls don't fail on first connect. # Use cmd's pipe — most reliable way to feed `y` to plink's host-key prompt on Windows. $cacheOut = & cmd /c "echo y | `"$PlinkPath`" -ssh -pw `"$SshPassword`" `"$SshTarget`" exit 2>&1" # Ignore exit code: the goal is just to write the host key into the PuTTY registry cache. $null = $cacheOut } function Invoke-Ssh([string] $cmd, [string] $Label, [switch] $ShowOutput) { if (-not $Label) { $Label = "ssh $($cmd.Substring(0, [Math]::Min(48, $cmd.Length)))..." } if ($SshPassword) { $argList = @('-ssh', '-pw', $SshPassword, '-batch', $SshTarget, $cmd) Invoke-Spinner -Label $Label -FilePath $PlinkPath -ArgList $argList -ShowOutput:$ShowOutput } else { Invoke-Spinner -Label $Label -FilePath 'ssh' -ArgList (@($SshOpts) + @($SshTarget, $cmd)) -ShowOutput:$ShowOutput } } function Invoke-Scp([string] $local, [string] $remote, [string] $Label) { if (-not $Label) { $Label = "scp $(Split-Path -Leaf $local) $($Glyph.Arrow) $remote" } if ($SshPassword) { $argList = @('-scp', '-pw', $SshPassword, '-batch', $local, "${SshTarget}:${remote}") Invoke-Spinner -Label $Label -FilePath $PscpPath -ArgList $argList } else { Invoke-Spinner -Label $Label -FilePath 'scp' -ArgList (@($SshOpts) + @($local, "${SshTarget}:${remote}")) } } function Try-Ssh([string] $cmd) { if ($SshPassword) { & $PlinkPath -ssh -pw $SshPassword -batch $SshTarget $cmd } else { & ssh @SshOpts $SshTarget $cmd } return $LASTEXITCODE } function Invoke-SshScript([string] $script, [string] $Label) { if (-not $Label) { $Label = "ssh (remote script)" } $tmp = [System.IO.Path]::GetTempFileName() try { # Use LF line endings so bash on the remote doesn't choke on CR. [System.IO.File]::WriteAllText($tmp, ($script -replace "`r`n", "`n"), [System.Text.UTF8Encoding]::new($false)) if ($SshPassword) { $argList = @('-ssh', '-pw', $SshPassword, '-batch', $SshTarget, 'bash -s') Invoke-Spinner -Label $Label -FilePath $PlinkPath -ArgList $argList -StdinFile $tmp -ShowOutput } else { Invoke-Spinner -Label $Label -FilePath 'ssh' -ArgList (@($SshOpts) + @($SshTarget, "bash -s")) -StdinFile $tmp -ShowOutput } } finally { Remove-Item $tmp -Force -ErrorAction SilentlyContinue } } Write-Banner 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 .env.production and fill in RESEND_API_KEY." } $AppPort = Get-EnvValue $EnvPath $PortEnvKey if (-not $AppPort) { $AppPort = "8083" } Write-Step "Preflight" Write-Info "App : $AppName ($AppSlug)" Write-Info "Remote host : $SshTarget" Write-Info "Remote path : $RemotePath" Write-Info "Container : $ContainerName" Write-Info "Env file : $EnvPath" Write-Info "Compose file : $ComposeFile" Write-Info "Port ($PortEnvKey) : $AppPort" Write-Step "Verifying SSH connectivity" Invoke-Ssh "echo connected as `$(whoami) on `$(hostname)" -Label "ssh handshake" -ShowOutput Write-Step "Checking that remote port $AppPort is free for $ContainerName" $portCheckCmd = @' set -e PORT='__APP_PORT__' SELF='__CONTAINER__' OWNER=$(docker ps --format '{{.Names}} {{.Ports}}' | grep -m1 ":${PORT}->" | cut -d' ' -f1 || true) if [ -n "$OWNER" ] && [ "$OWNER" != "$SELF" ]; then echo "Port $PORT is already owned by container: $OWNER" >&2 exit 2 fi '@.Replace('__APP_PORT__', $AppPort).Replace('__CONTAINER__', $ContainerName) try { Invoke-SshScript $portCheckCmd -Label "port $AppPort availability" } catch { throw "Remote port $AppPort is already in use by another container. Change $PortEnvKey in '$EnvPath' or retire the conflicting service first." } Write-Step "Packaging source tree (excluding caches, secrets, logs)" $TarFile = Join-Path $env:TEMP "lean101-website-deploy-$(Get-Date -Format 'yyyyMMdd-HHmmss').tar.gz" $excludes = @( "--exclude=./.git", "--exclude=./.pytest_cache", "--exclude=./__pycache__", "--exclude=./**/__pycache__", "--exclude=./*.pyc", "--exclude=./.venv", "--exclude=./venv", "--exclude=./.env", "--exclude=./.env.production", "--exclude=./.env.*.local", "--exclude=./*.log", "--exclude=./*.err" ) Invoke-Spinner -Label "tar -czf $(Split-Path -Leaf $TarFile)" -FilePath 'tar' -ArgList (@('-czf', $TarFile) + $excludes + @('-C', $RepoRoot, '.')) $TarSize = [math]::Round((Get-Item $TarFile).Length / 1MB, 2) Write-Info "Archive: $TarFile ($($C.Bold)$TarSize MB$($C.Reset))" Write-Step "Ensuring remote path exists" Invoke-Ssh "mkdir -p '$RemotePath'" -Label "mkdir -p $RemotePath" Write-Step "Uploading env file" Invoke-Scp $EnvPath "$RemotePath/.env.production" -Label "scp .env.production $($Glyph.Arrow) $RemotePath/" Invoke-Ssh "chmod 600 '$RemotePath/.env.production'" -Label "chmod 600 .env.production" Write-Step "Uploading source archive ($TarSize MB)" Invoke-Scp $TarFile "/tmp/lean101-website-deploy.tar.gz" -Label "scp archive $($Glyph.Arrow) /tmp/" Remove-Item $TarFile -Force Write-Step "Extracting archive on server" Invoke-Ssh "tar -xzf /tmp/lean101-website-deploy.tar.gz -C '$RemotePath' && rm /tmp/lean101-website-deploy.tar.gz" -Label "untar into $RemotePath" $ComposeArgs = "--env-file .env.production -f $ComposeFile" $BuildFlag = if ($SkipBuild) { "--no-build" } else { "--build" } $buildMsg = if ($SkipBuild) { "without rebuild" } else { "with --build" } Write-Step "Bringing the $AppName stack up $buildMsg" $composeUpCmd = "cd '$RemotePath' && docker compose $ComposeArgs up -d $BuildFlag --remove-orphans" try { Invoke-Ssh $composeUpCmd -Label "docker compose up $BuildFlag" } catch { Write-Warn "docker compose up failed; collecting status and logs" Try-Ssh "cd '$RemotePath' && docker compose $ComposeArgs ps" | Out-Null Try-Ssh "cd '$RemotePath' && docker compose $ComposeArgs logs --tail=120" | Out-Null throw } Write-Step "Waiting for container to be running ($ContainerName)" $runCheck = @" set -e for i in `$(seq 1 30); do status=`$(docker inspect --format='{{.State.Status}}' $ContainerName 2>/dev/null || echo missing) case "`$status" in running) echo "container is running"; exit 0 ;; *) printf '.'; sleep 2 ;; esac done echo; echo "container did not reach running state" >&2; exit 1 "@ Invoke-SshScript $runCheck -Label "container status (up to ~60s)" Write-Step "HTTP readiness check on 127.0.0.1:$AppPort" $curlCmd = "for i in `$(seq 1 20); do code=`$(curl -fsS -o /dev/null -w '%{http_code}' --max-time 5 http://127.0.0.1:$AppPort/ 2>/dev/null || echo 000); if [ `"`$code`" = `"200`" ]; then echo `"HTTP `$code`"; exit 0; fi; sleep 2; done; echo `"never got 200`" >&2; exit 1" Invoke-Ssh $curlCmd -Label "curl http://127.0.0.1:$AppPort/" -ShowOutput Write-Step "Stack status" Invoke-Ssh "cd '$RemotePath' && docker compose $ComposeArgs ps" -Label "docker compose ps" -ShowOutput if ($Logs) { Write-Step "Recent logs (last 60 lines)" Invoke-Ssh "cd '$RemotePath' && docker compose $ComposeArgs logs --tail=60" -Label "docker compose logs --tail=60" -ShowOutput } Write-Step "Next steps for cutover" Write-Info "New container is healthy on $($C.Bold)127.0.0.1:$AppPort$($C.Reset)" Write-Info "Public domain still hits the OLD container until you swap the nginx vhost:" Write-Info " ssh $SshTarget" Write-Info " sed -i.bak 's|proxy_pass http://127.0.0.1:8091;|proxy_pass http://127.0.0.1:$AppPort;|' /etc/nginx/sites-enabled/lean-101.com.au" Write-Info " nginx -t && systemctl reload nginx" Write-Info "Then verify: curl -I https://www.lean-101.com.au/" Write-Info "Once happy: cd /srv/lean101-website && docker compose down" $line = '─' * 62 Write-Host "" Write-Host ("$($C.Green)╭$line╮$($C.Reset)") $done = "$($Glyph.Spark) $AppName deployed to ${SshTarget}:$AppPort" $pad = [Math]::Max(0, 60 - ($done.Length - 2)) Write-Host ("$($C.Green)│$($C.Reset) $($C.Bold)$($C.Green)$done$($C.Reset)" + (' ' * $pad) + "$($C.Green)│$($C.Reset)") Write-Host ("$($C.Green)╰$line╯$($C.Reset)") Write-Host "" } catch { Write-Host "" Write-Fail "Deployment aborted: $($_.Exception.Message)" Write-Host "" throw } finally { Pop-Location }