diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..deda7d3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.git +.gitignore +.env +.env.* +*.log +*.err +__pycache__/ +*.pyc +.venv/ +venv/ +.DS_Store +Thumbs.db +README.md +Deploy.ps1 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..885a994 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxx +CONTACT_TO=alex@lean-101.com +CONTACT_FROM=Lean 101 Website +PORT=5000 diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..d5c0684 --- /dev/null +++ b/.env.production.example @@ -0,0 +1,17 @@ +# Copy to .env.production and fill in. +# .env.production is gitignored; Deploy.ps1 uploads it to the droplet. + +# Resend API key — required for the contact form to actually send. +RESEND_API_KEY= + +# Optional — both have sensible defaults in app.py if omitted. +CONTACT_TO=alex@lean-101.com +CONTACT_FROM=Lean 101 Website + +# Host port the container binds to on the droplet (127.0.0.1:WEBSITE_APP_PORT -> 8000). +# Host nginx vhost for www.lean-101.com.au should proxy_pass to this port. +WEBSITE_APP_PORT=8083 + +# Droplet SSH password — used by Deploy.ps1 via sshpass so you don't have to type it. +# Leave blank and pass -SshKey to Deploy.ps1 if you'd rather use key auth. +SSH_PASSWORD=7Ajyef6UaAXHAnWrtUua diff --git a/.gitignore b/.gitignore index f8d8a22..ff39fa1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,14 @@ -node_modules/ -.svelte-kit/ -build/ - .env +.env.production +.env.*.local *.log *.err .DS_Store Thumbs.db + +__pycache__/ +*.pyc +.venv/ +venv/ diff --git a/Deploy.ps1 b/Deploy.ps1 new file mode 100644 index 0000000..e57203f --- /dev/null +++ b/Deploy.ps1 @@ -0,0 +1,418 @@ +<# +.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 +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..61958bf --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +ENV PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +WORKDIR /app + +COPY requirements.txt . +RUN pip install -r requirements.txt gunicorn + +COPY . . + +EXPOSE 8000 + +CMD ["gunicorn", "-b", "0.0.0.0:8000", "-w", "2", "--access-logfile", "-", "app:app"] diff --git a/README.md b/README.md index aee4790..7819551 100644 --- a/README.md +++ b/README.md @@ -1,165 +1,83 @@ # Lean 101 Website -This repo runs as a server-rendered SvelteKit app with a custom Mongo-backed CMS at `/admin`. +Plain static HTML site with a small Python (Flask) backend for the contact form. -## Stack +## Layout -- `SvelteKit` -- `@sveltejs/adapter-node` -- `MongoDB` via the native `mongodb` driver -- `docker compose` for local and server runtime - -## What changed - -- The public site now reads page content from MongoDB when `MONGODB_URI` and `MONGODB_DB` are set. -- If MongoDB is not configured, the homepage falls back to the local seed file at `src/lib/content/homepage.json`. -- `/admin` is a custom password-protected editor for: - - pages - - movable page sections - - blog posts -- The homepage seed is automatically migrated into the `site_pages` collection the first time the app connects to MongoDB. - -## Project layout - -```text -. -├── Dockerfile -├── docker-compose.yml -├── package.json -├── scripts/ -│ └── deploy-do.sh -├── src/ -│ ├── app.css -│ ├── app.html -│ ├── lib/ -│ │ ├── content/homepage.json -│ │ ├── components/SitePage.svelte -│ │ ├── server/auth.js -│ │ ├── server/content.js -│ │ └── site.js -│ └── routes/ -│ ├── +layout.js -│ ├── +page.server.js -│ ├── +page.svelte -│ ├── [slug]/+page.server.js -│ ├── [slug]/+page.svelte -│ ├── admin/+page.server.js -│ ├── admin/+page.svelte -│ ├── blog/+page.server.js -│ ├── blog/+page.svelte -│ ├── blog/[slug]/+page.server.js -│ ├── blog/[slug]/+page.svelte -│ ├── robots.txt/+server.js -│ └── sitemap.xml/+server.js -└── static/assets/ - ├── lean101-isotipo.png - └── lean101-logotipo.png +``` +index.html # Home (includes contact form at #contact) +services/ # coaching, consulting, digital-solutions pages +resources/ # resources index +assets/ # logos +app.py # Flask app: serves the site + /api/contact (Resend) +requirements.txt +.env.example # copy to .env and fill in (local dev) +Dockerfile # python:3.12-slim + gunicorn +docker-compose.yml # single service: lean101, attaches to shared nginx network +deploy.env.template # synced to server .env by deploy.sh (live values win) +deploy.sh # remote deploy: git clone + compose up + nginx + maintenance ``` -## Environment variables - -Required for the CMS: - -- `MONGODB_URI` -- `MONGODB_DB` -- `ADMIN_PASSWORD` -- `ADMIN_SESSION_SECRET` - -Required for correct canonical/meta URLs: - -- `PUBLIC_SITE_URL` - -Example `.env`: - -```env -PUBLIC_SITE_URL=http://localhost:8080 -MONGODB_URI=mongodb://localhost:27017 -MONGODB_DB=lean101 -ADMIN_PASSWORD=replace-this -ADMIN_SESSION_SECRET=replace-this-with-a-long-random-string -``` - -## Run locally with Docker +## Local development ```bash -docker compose up --build +python -m venv .venv +.venv\Scripts\activate # Windows +# source .venv/bin/activate # macOS/Linux +pip install -r requirements.txt +copy .env.example .env # then edit .env with your Resend key +python app.py ``` -The site will be available at `http://localhost:8080`. +Site at http://127.0.0.1:5000 -The app server listens on port `3000` inside the container and is mapped to `8080` on the host. +## Contact form -## Run locally without Docker +- Form is on the home page in the `#contact` section. +- Submits to `POST /api/contact` (JSON response). +- Server sends the email via [Resend](https://resend.com). Configure: + - `RESEND_API_KEY` — your Resend API key. + - `CONTACT_TO` — where enquiries are sent. + - `CONTACT_FROM` — must be on a domain you've verified in Resend. +- Honeypot field (`website`) silently drops bots. + +The "Let's Talk" buttons on service pages link back to the home `#contact` section, where the real form lives. + +## Deployment + +`Deploy.ps1` runs from your laptop. It tars the repo, scps it to the droplet, and runs `docker compose up --build` over SSH. Host nginx on the droplet terminates TLS for `www.lean-101.com.au` and proxies to `127.0.0.1:WEBSITE_APP_PORT` (default 8083) where this container binds. + +First deploy: + +```powershell +copy .env.production.example .env.production # then edit and set RESEND_API_KEY +./Deploy.ps1 -RemoteHost +``` + +Subsequent deploys: same command. Add `-Logs` to tail logs after, `-SkipBuild` if only the env changed. + +### Cutover (first deploy only) + +The script deploys to `/srv/lean-101-website-flask` with container `lean101-website-flask` on port 8083, deliberately leaving the old SvelteKit container (`/srv/lean101-website`, port 8091) running. After verifying the new container is healthy, swap the nginx vhost on the droplet: ```bash -npm install -npm run dev +ssh root@ +sed -i.bak 's|proxy_pass http://127.0.0.1:8091;|proxy_pass http://127.0.0.1:8083;|' \ + /etc/nginx/sites-enabled/lean-101.com.au +nginx -t && systemctl reload nginx +curl -I https://www.lean-101.com.au/ # should hit the new Flask container + +# Once happy, retire the old stack: +cd /srv/lean101-website && docker compose down ``` -The Vite dev server will run on its normal local port, usually `http://localhost:5173`. +Rollback: `mv /etc/nginx/sites-enabled/lean-101.com.au.bak /etc/nginx/sites-enabled/lean-101.com.au && nginx -s reload`. -## Production build +### Coexistence with the clients app -```bash -npm run build -npm run preview -``` - -`npm run preview` now runs the built Node server with `node build`. - -## Custom CMS - -Visit `/admin`. - -The CMS is intentionally simple, but it now supports structured content: - -- one password -- multiple pages -- reorderable sections within each page -- blog drafts and published posts - -Current collections: - -- `site_pages` -- `blog_posts` - -Current section types: - -- `hero` -- `services` -- `process` -- `outcomes` -- `why` -- `richText` -- `cta` - -Published blog posts are available under `/blog`, and individual pages are available at `/`. - -## DigitalOcean deployment script - -After copying this project folder onto a droplet, you can run: - -```bash -sudo ./scripts/deploy-do.sh --domain yourdomain.com --email you@example.com --with-www -``` - -The script: - -- installs Docker, the compose plugin, nginx, and certbot -- writes `.env` with `PUBLIC_SITE_URL=https://yourdomain.com` -- starts the compose stack -- configures system nginx to reverse proxy to the Docker app on `127.0.0.1:8080` -- requests and installs a Let's Encrypt certificate if `--email` is provided - -If you use the CMS in production, make sure the `.env` file on the server also includes: - -- `MONGODB_URI` -- `MONGODB_DB` -- `ADMIN_PASSWORD` -- `ADMIN_SESSION_SECRET` +The droplet runs host nginx → localhost ports → docker containers. The clients app at `clients.lean-101.com.au` proxies to `127.0.0.1:8082`, this site to `127.0.0.1:8083`. Each app has its own compose project and bridge network; they don't share anything. ## Notes -- The current contact CTA still uses `mailto:hello@lean-101.com` -- The seed homepage remains in `src/lib/content/homepage.json` -- Live page and blog content is stored in MongoDB once the database env vars are configured +- The SvelteKit + Mongo stack was removed in favour of plain HTML so edits land directly. If you need anything from the prior stack, recover it from git history before this commit. +- During the restructure the v3.11 service pages were lost (they were never committed) and restored from the older `html-v3` versions in HEAD. The v3.11 service-page tweaks (e.g. CTA copy rebranded to "Let's Talk") will need to be re-applied. diff --git a/app.py b/app.py new file mode 100644 index 0000000..c64fb9b --- /dev/null +++ b/app.py @@ -0,0 +1,103 @@ +import os +import re +from pathlib import Path + +import resend +from flask import Flask, jsonify, request, send_from_directory +from dotenv import load_dotenv + +load_dotenv() + +SITE_ROOT = Path(__file__).parent.resolve() + +RESEND_API_KEY = os.environ.get("RESEND_API_KEY", "") +CONTACT_TO = os.environ.get("CONTACT_TO", "alex@lean-101.com") +CONTACT_FROM = os.environ.get("CONTACT_FROM", "Lean 101 Website ") + +resend.api_key = RESEND_API_KEY + +app = Flask(__name__, static_folder=None) + +EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") + + +def _clip(value: str, limit: int) -> str: + value = (value or "").strip() + return value[:limit] + + +@app.post("/api/contact") +def contact(): + form = request.form if request.form else request.get_json(silent=True) or {} + + if (form.get("website") or "").strip(): + return jsonify({"ok": True}), 200 + + name = _clip(form.get("name", ""), 200) + email = _clip(form.get("email", ""), 200) + company = _clip(form.get("company", ""), 200) + message = _clip(form.get("message", ""), 5000) + + if not name or not email or not message: + return jsonify({"error": "Please fill in your name, email, and message."}), 400 + if not EMAIL_RE.match(email): + return jsonify({"error": "That email address doesn't look right."}), 400 + + if not RESEND_API_KEY: + app.logger.error("RESEND_API_KEY not configured") + return jsonify({"error": "Email is not configured on the server."}), 500 + + html = ( + f"

Name: {name}

" + f"

Email: {email}

" + f"

Company: {company or '—'}

" + f"

Looking to improve:

" + f"

{message.replace(chr(10), '
')}

" + ) + + try: + resend.Emails.send({ + "from": CONTACT_FROM, + "to": [CONTACT_TO], + "reply_to": email, + "subject": f"New Lean 101 enquiry — {name}" + + (f" ({company})" if company else ""), + "html": html, + }) + except Exception as exc: + app.logger.exception("Resend send failed: %s", exc) + return jsonify({"error": "Couldn't send your message. Please try again."}), 502 + + return jsonify({"ok": True}), 200 + + +@app.route("/", defaults={"path": ""}) +@app.route("/") +def static_files(path: str): + if not path: + return send_from_directory(SITE_ROOT, "index.html") + + target = (SITE_ROOT / path).resolve() + try: + target.relative_to(SITE_ROOT) + except ValueError: + return ("Not found", 404) + + if target.is_dir(): + index = target / "index.html" + if index.exists(): + return send_from_directory(target, "index.html") + return ("Not found", 404) + + if target.exists(): + return send_from_directory(target.parent, target.name) + + html_candidate = target.with_suffix(".html") + if html_candidate.exists(): + return send_from_directory(html_candidate.parent, html_candidate.name) + + return ("Not found", 404) + + +if __name__ == "__main__": + app.run(host="127.0.0.1", port=int(os.environ.get("PORT", "5000")), debug=True) diff --git a/deploy.sh b/deploy.sh deleted file mode 100644 index 4f99a1e..0000000 --- a/deploy.sh +++ /dev/null @@ -1,500 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -REPO_URL="" -BRANCH="main" -REF="" -DEPLOY_PATH="" -COMPOSE_FILE="" -PROJECT_NAME="" -SERVICE_NAME="" -NGINX_SOURCE="" -NGINX_TARGET="" -NGINX_COMPOSE_FILE="" -NGINX_PROJECT_NAME="" -MAINTENANCE_HOST_DIR="" -MAINTENANCE_FLAG_PATH="" -VERIFY_URL="https://www.goodwalk.co.nz/api/health" -SKIP_SITE_CHECK=0 - -usage() { - cat <<'EOF' -Usage: - deploy-from-git.sh --repo-url --branch --deploy-path --compose-file --project-name - deploy-from-git.sh --repo-url [--branch ] [--ref ] --deploy-path --compose-file --project-name [--service ] - deploy-from-git.sh --repo-url [--branch ] [--ref ] --deploy-path --compose-file --project-name \ - [--service ] [--nginx-source ] [--nginx-target ] \ - [--nginx-compose-file ] [--nginx-project-name ] \ - [--maintenance-host-dir ] [--maintenance-flag ] \ - [--verify-url ] [--skip-site-check] - -This script clones or fetches the application repo on the server, exports the -homepage content payload, updates only the main Goodwalk compose project, and -optionally updates the shared nginx stack plus maintenance mode handling. - -Authentication for private HTTPS repos is expected to come from ~/.netrc, -git-credential, or another Git-supported credential mechanism already present -on the server. -EOF -} - -fail() { - echo "[deploy-git] ERROR: $*" >&2 - exit 1 -} - -assert_command() { - command -v "$1" >/dev/null 2>&1 || fail "Required command '$1' is not installed on the server" -} - -run_homepage_export() { - local export_script="$1" - local output_path="$2" - - if command -v node >/dev/null 2>&1; then - node --experimental-strip-types "$export_script" "$output_path" - return - fi - - echo "[deploy-git] Host node not found; exporting homepage content via temporary node:22-alpine container" - docker run --rm \ - -v "$CHECKOUT_DIR:/app" \ - -w /app \ - node:22-alpine \ - node --experimental-strip-types "${export_script#/app/}" "${output_path#/app/}" -} - -while [[ $# -gt 0 ]]; do - case "$1" in - --repo-url) - REPO_URL="${2:-}" - shift 2 - ;; - --branch) - BRANCH="${2:-}" - shift 2 - ;; - --ref) - REF="${2:-}" - shift 2 - ;; - --deploy-path) - DEPLOY_PATH="${2:-}" - shift 2 - ;; - --compose-file) - COMPOSE_FILE="${2:-}" - shift 2 - ;; - --project-name) - PROJECT_NAME="${2:-}" - shift 2 - ;; - --service) - SERVICE_NAME="${2:-}" - shift 2 - ;; - --nginx-source) - NGINX_SOURCE="${2:-}" - shift 2 - ;; - --nginx-target) - NGINX_TARGET="${2:-}" - shift 2 - ;; - --nginx-compose-file) - NGINX_COMPOSE_FILE="${2:-}" - shift 2 - ;; - --nginx-project-name) - NGINX_PROJECT_NAME="${2:-}" - shift 2 - ;; - --maintenance-host-dir) - MAINTENANCE_HOST_DIR="${2:-}" - shift 2 - ;; - --maintenance-flag) - MAINTENANCE_FLAG_PATH="${2:-}" - shift 2 - ;; - --verify-url) - VERIFY_URL="${2:-}" - shift 2 - ;; - --skip-site-check) - SKIP_SITE_CHECK=1 - shift - ;; - -h|--help) - usage - exit 0 - ;; - *) - fail "Unknown argument: $1" - ;; - esac -done - -[[ -n "$REPO_URL" ]] || fail "--repo-url is required" -[[ -n "$BRANCH" ]] || fail "--branch is required" -[[ -n "$DEPLOY_PATH" ]] || fail "--deploy-path is required" -[[ -n "$COMPOSE_FILE" ]] || fail "--compose-file is required" -[[ -n "$PROJECT_NAME" ]] || fail "--project-name is required" -if [[ -n "$SERVICE_NAME" ]]; then - SERVICE_NAME="$(printf '%s' "$SERVICE_NAME" | xargs)" -fi -[[ "$DEPLOY_PATH" != "/" ]] || fail "Refusing to deploy to /" - -nginx_args=("$NGINX_SOURCE" "$NGINX_TARGET" "$NGINX_COMPOSE_FILE" "$NGINX_PROJECT_NAME") -nginx_args_present=0 -for value in "${nginx_args[@]}"; do - if [[ -n "$value" ]]; then - nginx_args_present=1 - break - fi -done - -if (( nginx_args_present )); then - [[ -n "$NGINX_SOURCE" ]] || fail "--nginx-source is required when nginx deployment is enabled" - [[ -n "$NGINX_TARGET" ]] || fail "--nginx-target is required when nginx deployment is enabled" - [[ -n "$NGINX_COMPOSE_FILE" ]] || fail "--nginx-compose-file is required when nginx deployment is enabled" - [[ -n "$NGINX_PROJECT_NAME" ]] || fail "--nginx-project-name is required when nginx deployment is enabled" - [[ -n "$MAINTENANCE_HOST_DIR" ]] || fail "--maintenance-host-dir is required when nginx deployment is enabled" - [[ -n "$MAINTENANCE_FLAG_PATH" ]] || fail "--maintenance-flag is required when nginx deployment is enabled" -fi - -assert_command git -assert_command docker -if docker compose version >/dev/null 2>&1; then - COMPOSE_CMD=(docker compose) -elif command -v docker-compose >/dev/null 2>&1; then - COMPOSE_CMD=(docker-compose) -else - fail "Docker Compose is not installed on the server" -fi - -WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/goodwalk-git-deploy.XXXXXX")" -CHECKOUT_DIR="$WORK_DIR/repo" -PAYLOAD_DIR="$WORK_DIR/payload" -MAINTENANCE_ACTIVE=0 - -clear_maintenance_flag() { - if (( MAINTENANCE_ACTIVE )) && (( nginx_args_present )); then - echo "[deploy-git] Clearing maintenance flag at $MAINTENANCE_FLAG_PATH" - rm -f "$MAINTENANCE_FLAG_PATH" || true - MAINTENANCE_ACTIVE=0 - fi -} - -cleanup() { - clear_maintenance_flag - rm -rf "$WORK_DIR" -} - -copy_checkout_to_payload() { - mkdir -p "$PAYLOAD_DIR" - - if command -v rsync >/dev/null 2>&1; then - rsync -a \ - --exclude '.git' \ - --exclude '.env' \ - --exclude '.env.*' \ - "$CHECKOUT_DIR"/ "$PAYLOAD_DIR"/ - return - fi - - while IFS= read -r -d '' item; do - relative_path="${item#"$CHECKOUT_DIR"/}" - - case "$relative_path" in - .git|.git/*|.env|.env.*) - continue - ;; - esac - - destination="$PAYLOAD_DIR/$relative_path" - - if [[ -d "$item" ]]; then - mkdir -p "$destination" - continue - fi - - mkdir -p "$(dirname "$destination")" - cp -f "$item" "$destination" - done < <(find "$CHECKOUT_DIR" -mindepth 1 -print0) -} - -copy_payload_to_deploy() { - mkdir -p "$DEPLOY_PATH" - - if command -v rsync >/dev/null 2>&1; then - rsync -a \ - --exclude '.env' \ - --exclude '.env.*' \ - "$PAYLOAD_DIR"/ "$DEPLOY_PATH"/ - return - fi - - while IFS= read -r -d '' item; do - relative_path="${item#"$PAYLOAD_DIR"/}" - - if [[ "$relative_path" == ".env" || "$relative_path" == .env.* ]]; then - continue - fi - - destination="$DEPLOY_PATH/$relative_path" - - if [[ -d "$item" ]]; then - mkdir -p "$destination" - continue - fi - - mkdir -p "$(dirname "$destination")" - cp -f "$item" "$destination" - done < <(find "$PAYLOAD_DIR" -mindepth 1 -print0) -} - -merge_env_file() { - local template="$1" - local live="$2" - - [[ -f "$template" ]] || { echo "[deploy-git] No env template at $template, skipping merge"; return 0; } - [[ -f "$live" ]] || { echo "[deploy-git] No live .env at $live, skipping merge"; return 0; } - - local added diffs backup - added="$(mktemp)" - diffs="$(mktemp)" - backup="${live}.bak.$(date -u +%Y%m%dT%H%M%SZ)" - - awk -v live="$live" -v added_log="$added" -v diff_log="$diffs" ' - function trim(s) { sub(/^[ \t]+/,"",s); sub(/[ \t]+$/,"",s); return s } - BEGIN { - while ((getline line < live) > 0) { - if (line ~ /^[ \t]*#/ || line ~ /^[ \t]*$/) continue - eq = index(line, "=") - if (eq == 0) continue - k = trim(substr(line, 1, eq-1)) - v = substr(line, eq+1) - live_keys[k] = v - live_seen[k] = 1 - } - close(live) - } - /^[ \t]*#/ || /^[ \t]*$/ { next } - { - eq = index($0, "=") - if (eq == 0) next - k = trim(substr($0, 1, eq-1)) - v = substr($0, eq+1) - if (!(k in live_seen)) { - print k "=" v >> added_log - } else if (live_keys[k] != v) { - print k " (template=" v " | live=" live_keys[k] ")" >> diff_log - } - } - ' "$template" - - if [[ -s "$added" ]]; then - cp "$live" "$backup" - echo "[deploy-git] Adding env keys present in template but missing from $live:" - sed 's/^/ + /' "$added" - { - printf '\n# Appended by deploy-from-git.sh on %s from deploy.env.template\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" - cat "$added" - } >> "$live" - echo "[deploy-git] Backup of previous .env written to $backup" - else - echo "[deploy-git] .env is up to date with template (no missing keys)" - fi - - if [[ -s "$diffs" ]]; then - echo "[deploy-git] NOTE: these keys exist in both files but values differ. Live values are PRESERVED:" - sed 's/^/ ! /' "$diffs" - echo "[deploy-git] If a live value is stale, edit $live and re-deploy." - fi - - rm -f "$added" "$diffs" -} - -check_site() { - if (( SKIP_SITE_CHECK )) || [[ -z "$VERIFY_URL" ]]; then - return 0 - fi - - echo "[deploy-git] Checking production site: $VERIFY_URL" - - if command -v curl >/dev/null 2>&1; then - local http_code - if http_code="$(curl -fsS -o /dev/null -w '%{http_code}' --max-time 30 -L "$VERIFY_URL" 2>/dev/null)"; then - echo "[deploy-git] Site responded with HTTP $http_code" - else - echo "[deploy-git] WARNING: production site check failed for $VERIFY_URL" >&2 - fi - return 0 - fi - - if command -v wget >/dev/null 2>&1; then - if wget --spider --server-response --timeout=30 "$VERIFY_URL" >/tmp/goodwalk-site-check.$$ 2>&1; then - awk '/^ HTTP\// { code=$2 } END { if (code != "") printf "[deploy-git] Site responded with HTTP %s\n", code }' /tmp/goodwalk-site-check.$$ - else - echo "[deploy-git] WARNING: production site check failed for $VERIFY_URL" >&2 - fi - rm -f /tmp/goodwalk-site-check.$$ - return 0 - fi - - echo "[deploy-git] WARNING: curl/wget not available; skipping site check" >&2 -} - -trap cleanup EXIT - -echo "[deploy-git] Deploying main Goodwalk stack from Git" -echo "[deploy-git] Repo URL: $REPO_URL" -echo "[deploy-git] Branch: $BRANCH" -if [[ -n "$REF" ]]; then - echo "[deploy-git] Requested ref: $REF" -fi -echo "[deploy-git] Target deployment path: $DEPLOY_PATH" -echo "[deploy-git] Compose file: $COMPOSE_FILE" -echo "[deploy-git] Docker project: $PROJECT_NAME" -if [[ -n "$SERVICE_NAME" ]]; then - echo "[deploy-git] Target service: $SERVICE_NAME" -fi -if (( nginx_args_present )); then - echo "[deploy-git] Nginx config source: $NGINX_SOURCE" - echo "[deploy-git] Nginx config target: $NGINX_TARGET" - echo "[deploy-git] Nginx compose file: $NGINX_COMPOSE_FILE" - echo "[deploy-git] Nginx project: $NGINX_PROJECT_NAME" - echo "[deploy-git] Maintenance host dir: $MAINTENANCE_HOST_DIR" - echo "[deploy-git] Maintenance flag path: $MAINTENANCE_FLAG_PATH" -fi - -echo "[deploy-git] Cloning repository into: $CHECKOUT_DIR" -git clone "$REPO_URL" "$CHECKOUT_DIR" -git -C "$CHECKOUT_DIR" fetch --tags --prune origin - -if [[ -n "$REF" ]]; then - git -C "$CHECKOUT_DIR" checkout --detach "$REF" -else - git -C "$CHECKOUT_DIR" checkout -B "$BRANCH" "origin/$BRANCH" -fi - -DEPLOYED_REVISION="$(git -C "$CHECKOUT_DIR" rev-parse HEAD)" -echo "[deploy-git] Using repo revision: $DEPLOYED_REVISION" - -EXPORT_SCRIPT="$CHECKOUT_DIR/scripts/export-homepage-content.mjs" -[[ -f "$EXPORT_SCRIPT" ]] || fail "Homepage export script not found: $EXPORT_SCRIPT" -echo "[deploy-git] Exporting current homepage content for PostgreSQL sync" -run_homepage_export "/app/scripts/export-homepage-content.mjs" "/app/deploy-data/homepage-content.json" - -echo "[deploy-git] Preparing deployment payload" -copy_checkout_to_payload - -[[ -f "$PAYLOAD_DIR/$COMPOSE_FILE" ]] || fail "Compose file missing from repo checkout: $COMPOSE_FILE" - -if [[ -f "$DEPLOY_PATH/.env" ]]; then - echo "[deploy-git] Preserving existing $DEPLOY_PATH/.env" -fi - -echo "[deploy-git] Copying application files into $DEPLOY_PATH" -copy_payload_to_deploy - -[[ -f "$DEPLOY_PATH/$COMPOSE_FILE" ]] || fail "Compose file missing after copy: $DEPLOY_PATH/$COMPOSE_FILE" - -if [[ ! -f "$DEPLOY_PATH/.env" ]]; then - if [[ -f "$DEPLOY_PATH/deploy.env.template" ]]; then - echo "[deploy-git] No remote .env found. Creating $DEPLOY_PATH/.env from deploy.env.template" - cp "$DEPLOY_PATH/deploy.env.template" "$DEPLOY_PATH/.env" - else - fail "Remote .env is missing and deploy.env.template was not present" - fi -fi - -merge_env_file "$DEPLOY_PATH/deploy.env.template" "$DEPLOY_PATH/.env" - -cd "$DEPLOY_PATH" - -echo "[deploy-git] Validating compose configuration" -"${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" config >/dev/null - -if [[ -n "$SERVICE_NAME" ]]; then - AVAILABLE_SERVICES="$("${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" config --services)" - if ! grep -Fxq "$SERVICE_NAME" <<<"$AVAILABLE_SERVICES"; then - fail "Service '$SERVICE_NAME' was not found in $COMPOSE_FILE. Available services: $(tr '\n' ',' <<<"$AVAILABLE_SERVICES" | sed 's/,$//')" - fi -fi - -if (( nginx_args_present )); then - [[ -f "$DEPLOY_PATH/$NGINX_SOURCE" ]] || fail "Nginx config missing from deployment payload: $DEPLOY_PATH/$NGINX_SOURCE" - [[ -f "$NGINX_COMPOSE_FILE" ]] || fail "Nginx compose file was not found on the server: $NGINX_COMPOSE_FILE" - - MAINTENANCE_HTML_SRC="$DEPLOY_PATH/nginx/maintenance.html" - MAINTENANCE_LOGO_SRC="$DEPLOY_PATH/nginx/logo.png" - [[ -f "$MAINTENANCE_HTML_SRC" ]] || fail "Maintenance page missing from deployment payload: $MAINTENANCE_HTML_SRC" - [[ -f "$MAINTENANCE_LOGO_SRC" ]] || fail "Maintenance logo missing from deployment payload: $MAINTENANCE_LOGO_SRC" - - NGINX_CID="$(docker ps -qf name=^nginx$ | head -n1 || true)" - [[ -n "$NGINX_CID" ]] || fail "Shared nginx container is not running (expected name 'nginx'). Bring it up before deploying." - - if ! docker inspect -f '{{range .Mounts}}{{.Source}}|{{.Destination}}{{println}}{{end}}' "$NGINX_CID" \ - | grep -Fxq "${MAINTENANCE_HOST_DIR}|/var/www/maintenance"; then - fail "nginx container is missing the maintenance bind mount. - Expected: ${MAINTENANCE_HOST_DIR}:/var/www/maintenance:ro - One-time setup on the droplet: - mkdir -p ${MAINTENANCE_HOST_DIR}/m - # add this volume to ${NGINX_COMPOSE_FILE}: - # - ${MAINTENANCE_HOST_DIR}:/var/www/maintenance:ro - ${COMPOSE_CMD[*]} -p ${NGINX_PROJECT_NAME} -f ${NGINX_COMPOSE_FILE} up -d" - fi - - FLAG_DIR="$(dirname "$MAINTENANCE_FLAG_PATH")" - [[ -d "$FLAG_DIR" ]] || fail "Maintenance flag directory does not exist on host: $FLAG_DIR" - - echo "[deploy-git] Writing maintenance assets to host bind dir: $MAINTENANCE_HOST_DIR" - mkdir -p "$MAINTENANCE_HOST_DIR/m" - install -m 0644 "$MAINTENANCE_HTML_SRC" "$MAINTENANCE_HOST_DIR/maintenance.html" - install -m 0644 "$MAINTENANCE_LOGO_SRC" "$MAINTENANCE_HOST_DIR/m/logo.png" - - echo "[deploy-git] Updating shared nginx config (pre-rebuild) so maintenance routing is active" - mkdir -p "$(dirname "$NGINX_TARGET")" - cp "$DEPLOY_PATH/$NGINX_SOURCE" "$NGINX_TARGET" - - echo "[deploy-git] Validating nginx configuration" - "${COMPOSE_CMD[@]}" -p "$NGINX_PROJECT_NAME" -f "$NGINX_COMPOSE_FILE" exec -T nginx nginx -t - - echo "[deploy-git] Reloading shared nginx so the new config (incl. maintenance routing) is live" - "${COMPOSE_CMD[@]}" -p "$NGINX_PROJECT_NAME" -f "$NGINX_COMPOSE_FILE" exec -T nginx nginx -s reload - - echo "[deploy-git] Engaging maintenance page via host flag: $MAINTENANCE_FLAG_PATH" - : > "$MAINTENANCE_FLAG_PATH" - MAINTENANCE_ACTIVE=1 -fi - -if [[ -n "$SERVICE_NAME" ]]; then - echo "[deploy-git] Stopping only the Goodwalk service: $SERVICE_NAME" - "${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" stop "$SERVICE_NAME" || true - - echo "[deploy-git] Rebuilding and starting only the Goodwalk service: $SERVICE_NAME" - "${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" up -d --build "$SERVICE_NAME" -else - echo "[deploy-git] Stopping only the Goodwalk project containers" - "${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" stop || true - - echo "[deploy-git] Rebuilding and starting only the Goodwalk project containers" - "${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" up -d --build --remove-orphans -fi - -echo "[deploy-git] Current Goodwalk container status" -"${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" ps - -if [[ -z "$SERVICE_NAME" || "$SERVICE_NAME" == "app" || "$SERVICE_NAME" == "db" ]]; then - echo "[deploy-git] Syncing homepage content into PostgreSQL" - "${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" exec -T app node scripts/sync-homepage-content.mjs -fi - -clear_maintenance_flag -check_site - -echo "[deploy-git] Remote deployment finished" -echo "[deploy-git] Deployed revision: $DEPLOYED_REVISION" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1a2fdb7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +services: + lean101: + build: . + image: lean-101-website:latest + container_name: lean101-website-flask + restart: unless-stopped + env_file: .env.production + ports: + - "127.0.0.1:${WEBSITE_APP_PORT:-8083}:8000" diff --git a/index.html b/index.html new file mode 100644 index 0000000..9e89e3f --- /dev/null +++ b/index.html @@ -0,0 +1,2267 @@ + + + + + +Lean 101 — Smarter Improvement Solutions + + + + + + + + + + + + + + + +
+ Smarter Improvement Solutions +

+ Continuous improvement,
+ made simple. +

+

+ Practical consulting, coaching and analytics to help businesses eliminate waste, + improve performance, and build a culture that keeps improving — long after we've gone. +

+ + + +
+ Team collaborating on a process improvement workshop +
+
+
+
Efficiency
+
+35%
+
+
+
Cost savings
+
−27%
+
+
+
Lead time
+
−44%
+
+
+
Engagement
+
+41%
+
+
+
+ + +
+
+
+
What we do
+

Three services. One outcome: improvement that lasts.

+

+ Frameworks aren't the goal — results are. Every engagement combines + consulting, capability building, and digital solutions — sized to what your team can sustain. +

+
+ +
+
+
+ Consulting + Consultant mapping out a process on a whiteboard +
+
+

Process Excellence & Transformation

+

+ We diagnose where work is breaking down, design optimised processes, and + run the change with you — not just for you. +

+ + Learn more + + +
+
+ +
+
+ Coaching & Training + Coach working with a small team in a workshop setting +
+
+

Building capability and culture

+

+ Tailored coaching and bespoke training that turns Lean theory into everyday + practice. Your team owns the improvement. +

+ + Learn more + + +
+
+ +
+
+ Digital Solutions + Custom software being developed on a laptop +
+
+

Custom apps, dashboards & automation

+

+ From Power BI dashboards to custom web apps that replace clunky spreadsheets — we build tailored software that streamlines your operations and makes data work for you. +

+ + Learn more + + +
+
+
+
+
+ + +
+
+
+
How it works
+

A clear path from waste to flow.

+

+ Every engagement runs four cycles — adapted to your scale, industry and team capability. The cycles repeat as the business evolves; what stays constant is the partnership underneath. +

+
+ +
+
+
+
Step 01
+

Diagnose

+

+ Process mapping, audits and a maturity assessment. We walk your operation alongside you, talk to the people doing the work, and quantify where the friction sits. +

+
+ By the end: a clear map of where time, quality and cost are leaking — and which fix moves the dial first. +
+
+ + + +
+
Step 02
+

Design & pilot

+

+ We co-design the future state with your team, then run a focused pilot in one area. Real data, real people, real results — before we touch the rest of the business. +

+
+ By the end: a proven future state, validated with hard data on a focused pilot. +
+
+ + + +
+
Step 03
+

Roll out

+

+ Once the pilot proves the gains, we scale the approach across other teams, sites or business units — adapted to each context, with the same disciplined method. +

+
+ By the end: the gains scaled across teams, sites or business units — same method, each context. +
+
+ + + +
+
Step 04
+

Sustain

+

+ Coaching, dashboards and train-the-trainer programs so the improvement becomes how your team operates — without us in the room. +

+
+ By the end: the improvement runs without us — your team owns it, the data proves it. +
+
+
+ + +
+
+ + +
+
+ The Partnership +

The four-step cycle happens inside an ongoing partnership. We stay alongside you across cycles — same team, same methods, learning your business as it evolves. Continuous improvement, made continuous.

+
+
+
+
+
+ + +
+
+
+
+
What good looks like
+

The five things continuous improvement moves.

+

+ Industry benchmarks for organisations that commit to a structured improvement program. Whatever your industry, these are the levers that shift. +

+
+ + Let’s Talk + + +
+ + +
+
+ + +
+
+
+
+
Why Lean 101
+

One framework. Customised to your business.

+

+ Lean 101 is a one-on-one partnership. We bring globally recognised + frameworks — Lean, Six Sigma, continuous improvement best practice — + and adapt them to how your business actually runs. Every company, + every industry is different, so we work alongside you to solve the + problems that matter to you — not deliver a generic playbook. +

+
+ +
    +
  • +
    + +
    +
    +

    Globally recognised frameworks, practically applied

    +

    Lean, Six Sigma, and continuous improvement best practice — adapted to how your business actually runs, not lifted from a generic playbook.

    +
    +
  • +
  • +
    + +
    +
    +

    Customised to each business, not off the shelf

    +

    Every business is different. Focused coaching, a single process redesign, or full transformation — we shape the engagement around your priorities, capacity, and budget.

    +
    +
  • +
  • +
    + +
    +
    +

    We build capability, not dependency

    +

    Our success metric is the day you no longer need us. Train-the-trainer, embedded dashboards, and clear playbooks make the improvement stick.

    +
    +
  • +
  • +
    + +
    +
    +

    Data-led from day one

    +

    Power BI dashboards and automated reporting come standard. You see what's working, what isn't, and what to do next — in real time.

    +
    +
  • +
+
+
+
+ + +
+
+
+

Let's find your first improvement.

+

Tell us a little about your business and what you're looking to improve. We'll get back to you to talk through whether — and how — we can help.

+ +
+
+ + +
+ + + +
+ +

+
+
+
+
+
+ + + + + + + + + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..77994b0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +Flask>=3.0 +resend>=2.0 +python-dotenv>=1.0 diff --git a/resources/index.html b/resources/index.html new file mode 100644 index 0000000..09584c5 --- /dev/null +++ b/resources/index.html @@ -0,0 +1,952 @@ + + + + + +Resources — Lean 101 + + + + + + + + + + + + + + + + +
+
+ Resources +

Tools, methods and the reading behind the work.

+

+ A growing library of templates, training, book summaries and short posts — + the same material we draw on with clients, made available so you can use it on your own. +

+
+
+ + +
+
+
+ Principles +

The why of Lean.

+

+ The philosophy underneath the techniques — flow, pull, respect for + people, capability over dependency, kaizen culture. The ideas that + explain why the methods work, and what to hold onto when the methods + don’t fit the situation. +

+
+
+
+ Coming soon +

Principle explainers are in production.

+

Long-form pages on the underlying ideas of Lean — what they are, where they came from, and how they show up in real improvement work.

+
+
+
+
+ + +
+
+
+ Techniques +

The how of Lean.

+

+ Plain-English explanations of specific methods we use — 5S, Kanban, + Value Stream Mapping, A3, PDCA, Poka-Yoke, Standard Work, Gemba walks. + One page per technique, with what it is, how to run it, and where it + tends to go wrong. +

+
+
+
+ Coming soon +

Technique pages in production.

+

Each page covers one technique end-to-end — the mechanics, the typical pitfalls, and a worked example. The first pages will land here as they’re finished.

+
+
+
+
+ + +
+
+
+ Reading +

Books and articles behind the work.

+

+ Distilled summaries of the books and articles that shaped how we think + about improvement — what’s still useful, what hasn’t aged + well, and where the ideas show up in the work we do today. +

+
+
+
+ Coming soon +

First summaries on the way.

+

Starting with the books that come up most in our work — The Toyota Way, The Goal, Learning to See, Out of the Crisis. Each summary will link out to the source.

+
+
+
+
+ + +
+
+
+ Templates +

Worksheets, checklists and audits.

+

+ Practical artefacts you can download and use on your own work — 5S audit + checklists, waste walk worksheets, CI readiness self-assessments, and + more as we publish them. +

+
+
+
+ Coming soon +

The first templates are on their way.

+

We’re preparing the first set of templates for download. Follow Lean 101 on LinkedIn to be notified when they go live.

+
+
+
+
+ + +
+
+

Have a problem worth solving?

+

Tell us a little about your business and what you're looking to improve. We'll get back to you to talk through whether — and how — we can help.

+ + Let’s Talk + +
+
+ + + + + + + + diff --git a/lean-101-website-html-v3/services/coaching.html b/services/coaching.html similarity index 100% rename from lean-101-website-html-v3/services/coaching.html rename to services/coaching.html diff --git a/lean-101-website-html-v3/services/consulting.html b/services/consulting.html similarity index 100% rename from lean-101-website-html-v3/services/consulting.html rename to services/consulting.html diff --git a/lean-101-website-html-v3/services/digital-solutions.html b/services/digital-solutions.html similarity index 100% rename from lean-101-website-html-v3/services/digital-solutions.html rename to services/digital-solutions.html