Wire demo for production deploy + add server config
Fixes images 404'ing under /arrangement/ — Vite's publicDir copies assets to the build root, but the base prefix is only applied to bundled assets (JS/CSS), not to runtime URL strings. assetUrl() helper resolves paths against import.meta.env.BASE_URL so '/images/foo.png' becomes '/arrangement/images/foo.png' in production while staying '/images/foo.png' in dev. - src/demo/shared/assets.ts — assetUrl() helper - providers.ts + DemoNav.tsx — wrap all public asset paths - nginx/parsons-demos.conf — swag site-conf for parsons.tensordesign.com.au (asset cache regex above SPA fallback regex per nginx first-match rule) - docs/reference/client-demo-deploy.md — server runbook (DNS, swag SUBDOMAINS, mount, htpasswd, deploy loop) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
177
docs/reference/client-demo-deploy.md
Normal file
177
docs/reference/client-demo-deploy.md
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
# Client demo deploy — runbook
|
||||||
|
|
||||||
|
How to set up `parsons.tensordesign.com.au` for the first time, and how to push updates after.
|
||||||
|
|
||||||
|
Companion to [`client-demo-hosting-plan.md`](./client-demo-hosting-plan.md), which has the why and the architecture. This file is the actionable how.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## One-time server setup
|
||||||
|
|
||||||
|
You'll do this once. After that, deploys are a single script.
|
||||||
|
|
||||||
|
### 1. DNS
|
||||||
|
|
||||||
|
Point `parsons.tensordesign.com.au` at your home IP (or DDNS hostname). One A-record, same as your other subdomains on `tensordesign.com.au`.
|
||||||
|
|
||||||
|
Verify after propagation (can take minutes):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dig +short parsons.tensordesign.com.au
|
||||||
|
```
|
||||||
|
|
||||||
|
Should return your home IP.
|
||||||
|
|
||||||
|
### 2. swag — add `parsons` to SUBDOMAINS
|
||||||
|
|
||||||
|
In your swag container's environment (compose file or `docker run` flags), add `parsons` to the comma-separated `SUBDOMAINS` list. Then restart the container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose restart swag
|
||||||
|
# or: docker restart swag
|
||||||
|
```
|
||||||
|
|
||||||
|
Watch the logs until you see Let's Encrypt issue the cert:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker logs -f swag
|
||||||
|
# look for: "Certificate for parsons.tensordesign.com.au issued"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Document root on the host
|
||||||
|
|
||||||
|
Pick where the static files live on the host filesystem. Suggested:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /srv/parsons-demos
|
||||||
|
sudo chown -R "$USER:$USER" /srv/parsons-demos
|
||||||
|
```
|
||||||
|
|
||||||
|
Make sure swag has access to it. Either:
|
||||||
|
|
||||||
|
- **Mount it into swag** at `/config/www/parsons-demos/` (preferred — keeps swag's container view tidy):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# in your swag compose service:
|
||||||
|
volumes:
|
||||||
|
- /srv/parsons-demos:/config/www/parsons-demos:ro
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart swag after editing compose.
|
||||||
|
|
||||||
|
- Or symlink inside the existing swag config volume — works but messier.
|
||||||
|
|
||||||
|
### 4. Drop the nginx config in
|
||||||
|
|
||||||
|
The repo has the conf at `nginx/parsons-demos.conf`. Copy it into swag's `site-confs/` directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp nginx/parsons-demos.conf /path/to/swag/config/nginx/site-confs/
|
||||||
|
docker compose exec swag nginx -t # syntax check
|
||||||
|
docker compose exec swag nginx -s reload
|
||||||
|
```
|
||||||
|
|
||||||
|
If `nginx -t` complains, fix before reloading (a bad config will take swag down).
|
||||||
|
|
||||||
|
### 5. Create the htpasswd
|
||||||
|
|
||||||
|
Pick a username (suggestion: `client`) and a strong shared password:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec swag htpasswd -c /config/nginx/.htpasswd-parsons client
|
||||||
|
# prompts for password
|
||||||
|
```
|
||||||
|
|
||||||
|
For additional users later (e.g. one credential per client engagement), drop the `-c`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec swag htpasswd /config/nginx/.htpasswd-parsons another-user
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Verify the auth + 404
|
||||||
|
|
||||||
|
Visit `https://parsons.tensordesign.com.au/` in a fresh browser. You should see:
|
||||||
|
|
||||||
|
1. SSL is valid (no cert warning)
|
||||||
|
2. Browser asks for username + password
|
||||||
|
3. After auth: empty page or 404 (no slices deployed yet — that's fine)
|
||||||
|
|
||||||
|
If you see this far, the server is ready.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Per-deploy workflow
|
||||||
|
|
||||||
|
Once setup is done, the loop is two commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Build the slice
|
||||||
|
npm run demo:build -- --mode arrangement
|
||||||
|
|
||||||
|
# 2. Push it up
|
||||||
|
./scripts/deploy-demo.sh arrangement
|
||||||
|
```
|
||||||
|
|
||||||
|
The deploy script:
|
||||||
|
|
||||||
|
- Verifies `dist-demo/arrangement/` exists and isn't empty (aborts if not — won't rsync a half-built bundle over a working demo)
|
||||||
|
- `rsync -az --delete` to the server (removes stale asset hashes)
|
||||||
|
- Prints the URL to visit
|
||||||
|
|
||||||
|
**Before first deploy:** edit `scripts/deploy-demo.sh` and set:
|
||||||
|
|
||||||
|
- `TARGET_HOST="<your-ssh-user>@tensordesign.com.au"`
|
||||||
|
- `TARGET_BASE="/srv/parsons-demos"` (or wherever you put the document root)
|
||||||
|
|
||||||
|
Make sure SSH key auth works (`ssh "$TARGET_HOST" echo ok` should succeed without a password prompt) so rsync doesn't stall.
|
||||||
|
|
||||||
|
The script lives in `scripts/` which is gitignored, so your server-specific paths won't leak into the repo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding a second slice later
|
||||||
|
|
||||||
|
1. Build the new app under `src/demo/apps/<new-slice>/` (mirror `arrangement/`'s structure).
|
||||||
|
2. `npm run demo:build -- --mode <new-slice>`.
|
||||||
|
3. `./scripts/deploy-demo.sh <new-slice>`.
|
||||||
|
|
||||||
|
The nginx config catches `/<anything>/...` automatically — no server changes needed for new slices.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Optional: landing page at `/`
|
||||||
|
|
||||||
|
Until you have one, `https://parsons.tensordesign.com.au/` returns 404. To add a tiny index listing available slices:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > /srv/parsons-demos/index.html <<'EOF'
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><meta charset="utf-8"><title>Parsons demos</title></head>
|
||||||
|
<body style="font-family: system-ui; max-width: 40em; margin: 4em auto; padding: 0 1em">
|
||||||
|
<h1>Parsons demos</h1>
|
||||||
|
<ul>
|
||||||
|
<li><a href="/arrangement/">Arrangement flow</a></li>
|
||||||
|
</ul>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `<li>` entries as new slices ship.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**Cert didn't issue.** Check swag logs (`docker logs swag`). Common causes: DNS not propagated yet, port 80 blocked by router, `SUBDOMAINS` value missing or misspelled.
|
||||||
|
|
||||||
|
**`nginx -t` fails after dropping the conf.** Most likely a path mismatch — `/config/www/parsons-demos` doesn't exist inside the container because the bind mount isn't set. Check `docker compose config` to confirm the mount is in effect.
|
||||||
|
|
||||||
|
**Auth prompt loops / 401s after correct password.** htpasswd file path mismatch between conf and `htpasswd -c` location. Both must agree.
|
||||||
|
|
||||||
|
**Demo loads but assets 404.** Vite `base` path mismatch. The build must use `--mode <slice>` so assets are prefixed with `/<slice>/`. Re-run the build and check `dist-demo/<slice>/index.html` — script src should look like `/<slice>/assets/index-XXX.js`.
|
||||||
|
|
||||||
|
**`rsync` stalls or asks for password.** SSH key auth not set up. Run `ssh-copy-id <TARGET_HOST>` once.
|
||||||
|
|
||||||
|
**Want to roll back a deploy.** rsync with `--delete` is irreversible — there's no built-in undo. Either keep the previous build locally and re-deploy, or rebuild the previous git commit. For demo-grade work this is fine; if you need versioned deploys later, switch to dated subfolders + a symlink swap.
|
||||||
81
nginx/parsons-demos.conf
Normal file
81
nginx/parsons-demos.conf
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# Parsons demo host — drop into swag's /config/nginx/site-confs/ directory.
|
||||||
|
#
|
||||||
|
# Serves static demo slices at parsons.tensordesign.com.au/<slice>/ behind
|
||||||
|
# basic auth. One server block, one cert (Let's Encrypt via swag), one
|
||||||
|
# htpasswd covering all slices.
|
||||||
|
#
|
||||||
|
# Document root layout (host filesystem):
|
||||||
|
# <host_path>/parsons-demos/
|
||||||
|
# index.html ← optional landing page listing slices
|
||||||
|
# arrangement/
|
||||||
|
# index.html
|
||||||
|
# assets/...
|
||||||
|
# <other-slices>/
|
||||||
|
#
|
||||||
|
# Bind-mount that directory into swag at /config/www/parsons-demos/ — the
|
||||||
|
# `root` directive below assumes that path. Adjust if you mount elsewhere.
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
listen [::]:443 ssl;
|
||||||
|
http2 on;
|
||||||
|
|
||||||
|
server_name parsons.*;
|
||||||
|
|
||||||
|
# swag manages the cert chain via SUBDOMAINS — make sure `parsons` is in
|
||||||
|
# the SUBDOMAINS env var of the swag container so this resolves.
|
||||||
|
include /config/nginx/ssl.conf;
|
||||||
|
|
||||||
|
root /config/www/parsons-demos;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# One credential file covering every slice. Create with:
|
||||||
|
# docker exec -it swag htpasswd -c /config/nginx/.htpasswd-parsons client
|
||||||
|
auth_basic "Parsons demos";
|
||||||
|
auth_basic_user_file /config/nginx/.htpasswd-parsons;
|
||||||
|
|
||||||
|
# Optional: don't auth the root listing if you want it publicly visible.
|
||||||
|
# (Currently auth covers it too — change to `auth_basic off;` to expose.)
|
||||||
|
|
||||||
|
# Root path serves the optional landing index.html if present, else 404.
|
||||||
|
location = / {
|
||||||
|
try_files /index.html =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Long cache for fingerprinted assets — Vite produces hashed filenames so
|
||||||
|
# this is safe. HTML is short-cache so updates land on next refresh.
|
||||||
|
# NOTE: asset + html regex locations must come BEFORE the slice fallback
|
||||||
|
# below, because nginx uses the first matching regex location.
|
||||||
|
location ~* \.(?:js|css|woff2?|ttf|otf|eot|png|jpg|jpeg|gif|svg|webp|ico)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~* \.html$ {
|
||||||
|
expires -1;
|
||||||
|
add_header Cache-Control "no-cache, must-revalidate";
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback per slice. /<slice>/<react-route> resolves to that
|
||||||
|
# slice's index.html so React Router handles the rest. Static assets
|
||||||
|
# (.js/.css/.png/etc.) are handled by the regex blocks above.
|
||||||
|
location ~ ^/(?<slice>[^/]+)/ {
|
||||||
|
try_files $uri $uri/ /$slice/index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Hide hidden files (e.g. .htpasswd if it ever ends up in webroot)
|
||||||
|
location ~ /\. {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTP → HTTPS redirect — swag's default server already covers this for
|
||||||
|
# wildcard subdomains, but include explicitly here in case the default is
|
||||||
|
# customised.
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name parsons.*;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
@@ -1,17 +1,18 @@
|
|||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import { Navigation } from '../../../components/organisms/Navigation';
|
import { Navigation } from '../../../components/organisms/Navigation';
|
||||||
|
import { assetUrl } from '../../shared/assets';
|
||||||
|
|
||||||
const FALogo = () => (
|
const FALogo = () => (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
<Box
|
<Box
|
||||||
component="img"
|
component="img"
|
||||||
src="/brandlogo/logo-full.svg"
|
src={assetUrl('/brandlogo/logo-full.svg')}
|
||||||
alt="Funeral Arranger"
|
alt="Funeral Arranger"
|
||||||
sx={{ height: 28, display: { xs: 'none', md: 'block' } }}
|
sx={{ height: 28, display: { xs: 'none', md: 'block' } }}
|
||||||
/>
|
/>
|
||||||
<Box
|
<Box
|
||||||
component="img"
|
component="img"
|
||||||
src="/brandlogo/logo-short.svg"
|
src={assetUrl('/brandlogo/logo-short.svg')}
|
||||||
alt="Funeral Arranger"
|
alt="Funeral Arranger"
|
||||||
sx={{ height: 28, display: { xs: 'block', md: 'none' } }}
|
sx={{ height: 28, display: { xs: 'block', md: 'none' } }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
17
src/demo/shared/assets.ts
Normal file
17
src/demo/shared/assets.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Resolve a public-asset path against Vite's base URL.
|
||||||
|
*
|
||||||
|
* In dev `import.meta.env.BASE_URL === '/'`, so `assetUrl('/images/foo.png')`
|
||||||
|
* returns `/images/foo.png` unchanged. In production the build sets base to
|
||||||
|
* `/arrangement/` (or whatever `--mode <slice>` was passed), and the same
|
||||||
|
* call returns `/arrangement/images/foo.png` so the bundled assets resolve
|
||||||
|
* correctly under the slice subpath.
|
||||||
|
*
|
||||||
|
* Always pass leading-slash paths — they're relative to the publicDir root.
|
||||||
|
*/
|
||||||
|
export const assetUrl = (path: string): string => {
|
||||||
|
const base = import.meta.env.BASE_URL;
|
||||||
|
const cleanBase = base.endsWith('/') ? base.slice(0, -1) : base;
|
||||||
|
const cleanPath = path.startsWith('/') ? path : `/${path}`;
|
||||||
|
return `${cleanBase}${cleanPath}`;
|
||||||
|
};
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ProviderData } from '../../../components/pages/ProvidersStep';
|
import type { ProviderData } from '../../../components/pages/ProvidersStep';
|
||||||
import type { PackagesStepProvider, ProviderTier } from '../../../components/pages/PackagesStep';
|
import type { PackagesStepProvider, ProviderTier } from '../../../components/pages/PackagesStep';
|
||||||
|
import { assetUrl } from '../assets';
|
||||||
|
|
||||||
export interface DemoProvider extends ProviderData {
|
export interface DemoProvider extends ProviderData {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -13,8 +14,8 @@ export const providers: DemoProvider[] = [
|
|||||||
location: 'Wentworth, NSW',
|
location: 'Wentworth, NSW',
|
||||||
verified: true,
|
verified: true,
|
||||||
tier: 'verified',
|
tier: 'verified',
|
||||||
imageUrl: '/images/venues/hparsons-funeral-home-wollongong/01.jpg',
|
imageUrl: assetUrl('/images/venues/hparsons-funeral-home-wollongong/01.jpg'),
|
||||||
logoUrl: '/images/providers/hparsons-funeral-directors/logo.png',
|
logoUrl: assetUrl('/images/providers/hparsons-funeral-directors/logo.png'),
|
||||||
rating: 4.6,
|
rating: 4.6,
|
||||||
reviewCount: 7,
|
reviewCount: 7,
|
||||||
startingPrice: 1800,
|
startingPrice: 1800,
|
||||||
@@ -28,8 +29,8 @@ export const providers: DemoProvider[] = [
|
|||||||
location: 'Wollongong, NSW',
|
location: 'Wollongong, NSW',
|
||||||
verified: true,
|
verified: true,
|
||||||
tier: 'verified',
|
tier: 'verified',
|
||||||
imageUrl: '/images/venues/rankins-funeral-home-warrawong/01.jpg',
|
imageUrl: assetUrl('/images/venues/rankins-funeral-home-warrawong/01.jpg'),
|
||||||
logoUrl: '/images/providers/rankins-funerals/logo.png',
|
logoUrl: assetUrl('/images/providers/rankins-funerals/logo.png'),
|
||||||
rating: 4.8,
|
rating: 4.8,
|
||||||
reviewCount: 23,
|
reviewCount: 23,
|
||||||
startingPrice: 2450,
|
startingPrice: 2450,
|
||||||
@@ -52,8 +53,8 @@ export const providers: DemoProvider[] = [
|
|||||||
location: 'Kingaroy, QLD',
|
location: 'Kingaroy, QLD',
|
||||||
verified: true,
|
verified: true,
|
||||||
tier: 'verified',
|
tier: 'verified',
|
||||||
imageUrl: '/images/venues/killick-family-funerals-chapel-kingaroy/01.jpg',
|
imageUrl: assetUrl('/images/venues/killick-family-funerals-chapel-kingaroy/01.jpg'),
|
||||||
logoUrl: '/images/providers/killick-family-funerals/logo.png',
|
logoUrl: assetUrl('/images/providers/killick-family-funerals/logo.png'),
|
||||||
rating: 4.9,
|
rating: 4.9,
|
||||||
reviewCount: 15,
|
reviewCount: 15,
|
||||||
startingPrice: 3100,
|
startingPrice: 3100,
|
||||||
@@ -65,8 +66,8 @@ export const providers: DemoProvider[] = [
|
|||||||
location: 'Ourimbah, NSW',
|
location: 'Ourimbah, NSW',
|
||||||
verified: true,
|
verified: true,
|
||||||
tier: 'verified',
|
tier: 'verified',
|
||||||
imageUrl: '/images/venues/mackay-family-garden-estate/01.jpg',
|
imageUrl: assetUrl('/images/venues/mackay-family-garden-estate/01.jpg'),
|
||||||
logoUrl: '/images/providers/mackay-family-funerals/logo.webp',
|
logoUrl: assetUrl('/images/providers/mackay-family-funerals/logo.webp'),
|
||||||
rating: 4.6,
|
rating: 4.6,
|
||||||
reviewCount: 87,
|
reviewCount: 87,
|
||||||
startingPrice: 2800,
|
startingPrice: 2800,
|
||||||
@@ -78,8 +79,8 @@ export const providers: DemoProvider[] = [
|
|||||||
location: 'Bega, NSW',
|
location: 'Bega, NSW',
|
||||||
verified: true,
|
verified: true,
|
||||||
tier: 'verified',
|
tier: 'verified',
|
||||||
imageUrl: '/images/venues/mannings-chapel/01.jpg',
|
imageUrl: assetUrl('/images/venues/mannings-chapel/01.jpg'),
|
||||||
logoUrl: '/images/providers/mannings-funerals/logo.png',
|
logoUrl: assetUrl('/images/providers/mannings-funerals/logo.png'),
|
||||||
rating: 4.7,
|
rating: 4.7,
|
||||||
reviewCount: 31,
|
reviewCount: 31,
|
||||||
startingPrice: 2600,
|
startingPrice: 2600,
|
||||||
|
|||||||
Reference in New Issue
Block a user