From 4f1a2cbac340da77f4f45927cb67e5d1275590df Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Sun, 17 May 2026 22:45:45 +1200 Subject: [PATCH] html vers, deploy --- .dockerignore | 14 + .env.example | 4 + .env.production.example | 17 + .gitignore | 11 +- Deploy.ps1 | 418 +++ Dockerfile | 16 + README.md | 206 +- app.py | 103 + deploy.sh | 500 ---- docker-compose.yml | 9 + index.html | 2267 +++++++++++++++++ requirements.txt | 3 + resources/index.html | 952 +++++++ .../services => services}/coaching.html | 0 .../services => services}/consulting.html | 0 .../digital-solutions.html | 0 16 files changed, 3872 insertions(+), 648 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .env.production.example create mode 100644 Deploy.ps1 create mode 100644 Dockerfile create mode 100644 app.py delete mode 100644 deploy.sh create mode 100644 docker-compose.yml create mode 100644 index.html create mode 100644 requirements.txt create mode 100644 resources/index.html rename {lean-101-website-html-v3/services => services}/coaching.html (100%) rename {lean-101-website-html-v3/services => services}/consulting.html (100%) rename {lean-101-website-html-v3/services => services}/digital-solutions.html (100%) 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