Deployment Script, Postgres migration, UX improvements

This commit is contained in:
2026-05-08 23:07:01 +12:00
parent 9afc3170ff
commit cfc193b713
37 changed files with 4390 additions and 2715 deletions
+102 -139
View File
@@ -1,219 +1,182 @@
<#
.SYNOPSIS
Build and deploy the Lean 101 Clients app to a Digital Ocean droplet over SSH.
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:
Tars the local source tree, uploads it to the droplet, and runs
docker compose up --build. No git required on the server.
* 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).
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 on the droplet. Defaults to `root`.
SSH user. 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.
Absolute path on the droplet. Defaults to '/srv/lean101-clients'.
.PARAMETER EnvFile
Local path to the env file that should land on the droplet as
`<RemotePath>/.env.production`. Defaults to `.env.production`.
Local path to the production env file. Defaults to '.env.production'.
.PARAMETER SshKey
Optional path to an SSH private key. If omitted, the script relies on
ssh-agent / default keys.
Optional path to an SSH private key.
.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).
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.
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.
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 203.0.113.10 -Bootstrap -RepoUrl git@github.com:ponzischeme89/data-entry-app.git
./deploy/Deploy.ps1 -RemoteHost 209.38.24.231
.EXAMPLE
./deploy/Deploy.ps1 -RemoteHost 203.0.113.10
./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] $Branch = "main",
[string] $RepoUrl,
[string] $EnvFile = ".env.production",
[string] $RemoteUser = "root",
[string] $RemotePath = "/srv/lean101-clients",
[string] $EnvFile = ".env.production",
[string] $SshKey,
[string] $ComposeFile = "docker-compose.production.yml",
[switch] $Bootstrap,
[switch] $SkipBuild,
[string] $ComposeFile = "docker-compose.production.yml",
[switch] $Seed,
[switch] $Logs
[switch] $Logs,
[switch] $SkipBuild
)
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
function Write-Step($message) {
Write-Host "==> $message" -ForegroundColor Cyan
# ── 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
}
function Write-Warn($message) {
Write-Host "!! $message" -ForegroundColor Yellow
$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 Resolve-RepoRoot {
$scriptDir = Split-Path -Parent $MyInvocation.ScriptName
if (-not $scriptDir) { $scriptDir = $PSScriptRoot }
return (Resolve-Path (Join-Path $scriptDir "..")).Path
function Invoke-Scp([string] $local, [string] $remote) {
& scp @SshOpts $local "${SshTarget}:${remote}"
if ($LASTEXITCODE -ne 0) { throw "scp failed: $local -> $remote" }
}
$RepoRoot = Resolve-RepoRoot
# ── 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 to $EnvFile and fill in production secrets first."
$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."
}
$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"
# ── Connectivity check ──────────────────────────────────────────────────────
Write-Step "Checking 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"
# ── Package source files ────────────────────────────────────────────────────
Write-Step "Packaging source files (excluding node_modules, caches, etc.)"
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
}
$TarFile = Join-Path $env:TEMP "lean101-deploy-$(Get-Date -Format 'yyyyMMdd-HHmmss').tar.gz"
Write-Step "Uploading $EnvFile to $RemotePath/.env.production"
Invoke-Scp $envPath "$RemotePath/.env.production"
$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'"
$composeArgs = @(
"--env-file", ".env.production",
"-f", $ComposeFile
) -join " "
# ── Upload and extract source ────────────────────────────────────────────────
Write-Step "Uploading source archive"
Invoke-Scp $TarFile "/tmp/lean101-deploy.tar.gz"
Remove-Item $TarFile -Force
$buildFlag = if ($SkipBuild) { "" } else { "--build" }
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"
Write-Step "Pulling base images"
Invoke-Ssh "cd '$RemotePath' && docker compose $composeArgs pull --ignore-pull-failures || true"
# ── Docker compose up ───────────────────────────────────────────────────────
$ComposeArgs = "--env-file .env.production -f $ComposeFile"
$BuildFlag = if ($SkipBuild) { "--no-build" } else { "--build" }
Write-Step "Bringing the stack up (build=$([bool](-not $SkipBuild)))"
Invoke-Ssh "cd '$RemotePath' && docker compose $composeArgs up -d $buildFlag --remove-orphans"
Write-Step "Bringing stack up (build=$(-not $SkipBuild))"
Invoke-Ssh "cd '$RemotePath' && docker compose $ComposeArgs up -d $BuildFlag --remove-orphans"
Write-Step "Waiting for backend healthcheck"
# ── Health check ────────────────────────────────────────────────────────────
Write-Step "Waiting for backend health check"
$healthScript = @"
set -e
cd '$RemotePath'
for attempt in `$(seq 1 30); do
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;;
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
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"
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"
# ── 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"
Invoke-Ssh "cd '$RemotePath' && docker compose $ComposeArgs logs --tail=60"
}
Write-Host "Deployment complete." -ForegroundColor Green
Write-Host ""
Write-Host "Deployment complete -> https://clients.lean-101.com.au" -ForegroundColor Green
}
finally {
Pop-Location