docs: update README, AGENT, ARQUITECTURE for 555xx ports, Portainer deploy, absolute paths

This commit is contained in:
Lago
2026-04-17 18:54:30 +02:00
parent 8b0a25f9c3
commit c1c9063996
3 changed files with 97 additions and 62 deletions
+20 -16
View File
@@ -4,12 +4,13 @@ A compact field guide for anyone (human or AI) making changes to this stack. Ski
---
## 1. The portability contract (don't break it)
## 1. The deployment model
1. **No named volumes.** All runtime state bind-mounts to `./data/`. `docker compose down -v` must never be required to migrate: copy the folder, `docker compose up -d`, done.
2. **No absolute paths** in `docker-compose.yml` or any config.
3. **Secrets live in `.env`**; the `.env` ships with dev defaults and is safe to commit until real deploy. Any non-local deployment must rotate every key (see §6).
4. Anything that resets the DB in dev is `Remove-Item -Recurse -Force .\data` — not `docker volume rm`.
1. **Portainer-native.** No `build:` steps. Every service pulls a pre-built image. The `web` service uses `nginx:1.27-alpine` with bind-mounted static files.
2. **Absolute host paths.** All bind mounts point to `/mnt/user/appdata/mc-cars/...`. Runtime state lives under `/mnt/user/appdata/mc-cars/data/`.
3. **No named volumes.** Wiping data means `rm -rf /mnt/user/appdata/mc-cars/data/db` — not `docker volume rm`.
4. **Secrets live in `.env`**; the `.env` ships with dev defaults. Any non-local deployment must rotate every key (see §6).
5. **External ports are in the `555xx` range** to avoid collisions on a busy host (`55521` Kong, `55530` Studio, `55532` Postgres, `55543` Kong TLS, `55580` web).
---
@@ -17,11 +18,11 @@ A compact field guide for anyone (human or AI) making changes to this stack. Ski
Migrations come in two flavours:
### First-boot only (runs iff `./data/db` is empty)
### First-boot only (runs iff `/mnt/user/appdata/mc-cars/data/db` is empty)
- `supabase/migrations/00-run-init.sh` — wrapper that runs `01-init.sql` via `psql` so we can pre-create Supabase service roles (`anon`, `authenticated`, `service_role`, `authenticator`, `supabase_auth_admin`, `supabase_storage_admin`) that the official images otherwise assume already exist on their `supabase/postgres` image. We use plain `postgres:15-alpine`, so we create them ourselves.
- `supabase/migrations/01-init.sql` — vehicles table, RLS, bucket row, seed demo vehicles.
Postgres' official entrypoint only processes `/docker-entrypoint-initdb.d/` on an **empty** data dir. To re-run: wipe `./data/db`.
Postgres' official entrypoint only processes `/docker-entrypoint-initdb.d/` on an **empty** data dir. To re-run: wipe `/mnt/user/appdata/mc-cars/data/db`.
### Post-boot (every time, idempotent)
- `supabase/migrations/post-boot.sql` — seeds the admin `auth.users` row with `must_change_password=true`, creates the `vehicle-photos` bucket row if missing. Parameterized: `psql -v admin_email=... -v admin_password=...`.
@@ -61,7 +62,8 @@ Both are run by the `post-init` service, which gates on `auth.users` and `storag
## 5. Kong traps
- On Windows/WSL2, Docker Desktop's `wslrelay` intercepts `127.0.0.1:8000`. We expose Kong on host **`54321`** (container `8000`). Don't move it back to 8000.
- On Windows/WSL2, Docker Desktop's `wslrelay` intercepts `127.0.0.1:8000`. On the production host Kong is exposed on **`55521`** (container `8000`).
- On a busy Unraid/Docker host all MC Cars ports live in the `555xx` range to avoid collisions.
- Kong needs `KONG_PLUGINS: bundled,request-transformer,cors,key-auth,acl,basic-auth`. Omitting `acl` breaks Studio.
- `kong.yml` has one `consumer` per role with `acl` groups; plugins `key-auth` + `acl` gate which `apikey` (anon vs service_role) can reach which route.
@@ -84,7 +86,9 @@ Password min length is enforced server-side by `GOTRUE_PASSWORD_MIN_LENGTH=10`.
## 7. Frontend conventions
- **Only** the anon key leaves the server. `frontend/Dockerfile` writes `config.js` at container start from `$SUPABASE_URL` / `$SUPABASE_ANON_KEY`. The service_role key is not mounted into `web`.
- **Only** the anon key leaves the server. `frontend/99-config.sh` (bind-mounted into the nginx entrypoint dir) writes `config.js` at container start from `$SUPABASE_URL` / `$SUPABASE_ANON_KEY`. The service_role key is not mounted into `web`.
- The `web` service uses `nginx:1.27-alpine` directly (no `build:`). Static files + nginx.conf + the entrypoint script are bind-mounted read-only. Updating the frontend is `git pull` + restart the container.
- `.gitattributes` enforces `eol=lf` on `*.sh` files so the entrypoint doesn't break with CRLF on Windows checkouts.
- `frontend/app.js` (public) creates a Supabase client with `persistSession: false` — the public site never needs a session.
- `frontend/admin.js` uses `persistSession: true, storageKey: "mccars.auth"` and a single realtime channel `mccars-admin` that subscribes to leads/customers/vehicles.
- `app.js` writes `vehicle_id` (uuid) **and** denormalized `vehicle_label` ("BMW M3") into the lead at submit time, so the admin UI renders even if the vehicle is later deleted.
@@ -95,30 +99,30 @@ Password min length is enforced server-side by `GOTRUE_PASSWORD_MIN_LENGTH=10`.
| Symptom | Cause / Fix |
| ------------------------------------------------------- | ---------------------------------------------------------------- |
| `auth` container restart loop, "role supabase_auth_admin does not exist" | `00-run-init.sh` didn't run. Wipe `./data/db`, retry. |
| `auth` container restart loop, "role supabase_auth_admin does not exist" | `00-run-init.sh` didn't run. Wipe `/mnt/user/appdata/mc-cars/data/db`, retry. |
| `realtime` container exits, "wal_level must be logical" | `db` `command:` missing `wal_level=logical`. |
| 502 on `/realtime/v1/` through Kong | Missing `acl` plugin in `KONG_PLUGINS`, or realtime not listed in `kong.yml` consumers. |
| Admin login works but Leads tab is empty | RLS: session is `anon` instead of `authenticated`. Check that `signInWithPassword` succeeded and token is attached to subsequent requests. |
| Booking form submits but no row appears | anon lacks `INSERT` grant on `public.leads`. Check `02-leads.sql` ran (see `post-init` logs). |
| `.sh` files reject with `\r: command not found` | CRLF line endings. `dos2unix` or re-save as LF. |
| Port 8000 "connection refused" on Windows | Docker Desktop wslrelay. Use `54321`. |
| Port 8000 "connection refused" on Windows | Docker Desktop wslrelay. Use `55521` (production port). |
---
## 9. How to verify a working stack
```powershell
```bash
docker compose ps # all services up (post-init exited 0)
curl http://localhost:54321/rest/v1/vehicles?select=brand -H "apikey: $env:ANON" # 6 demo cars
curl http://localhost:55521/rest/v1/vehicles?select=brand -H "apikey: $ANON" # 6 demo cars
```
In the browser:
1. Open http://localhost:8080, submit the booking form.
2. Open http://localhost:8080/admin.html, log in with `.env` bootstrap creds.
1. Open http://\<host\>:55580, submit the booking form.
2. Open http://\<host\>:55580/admin.html, log in with `.env` bootstrap creds.
3. Rotate the password when prompted.
4. The lead you submitted appears in "Aktive Leads" — in real time.
5. Click **Qualifizieren**. Row disappears from active, a new row appears in **Kunden** with the `lead_id` displayed.
6. Open Supabase Studio at http://localhost:3000 and confirm the `customers.lead_id` FK matches.
6. Open Supabase Studio at http://\<host\>:55530 and confirm the `customers.lead_id` FK matches.
---
+37 -22
View File
@@ -7,9 +7,9 @@ A thorough walkthrough of how the MC Cars system is wired. Complements [AGENT.md
## 1. High-level diagram
```
Browser (localhost)
Browser (<host>)
│ │
:8080 (nginx) :54321 (Kong) :3000 (Studio)
:55580 (nginx) :55521 (Kong) :55530 (Studio)
│ │ │
┌──────┴──────┐ │ │
│ web │ │ │
@@ -32,7 +32,7 @@ A thorough walkthrough of how the MC Cars system is wired. Complements [AGENT.md
│ logical) │ └───────────┘
└──────────┘ ▲
│ │
./data/db ./data/storage
/mnt/.../data/db /mnt/.../data/storage
```
Dashed lifetime services (exit 0 once done): `post-init`.
@@ -46,8 +46,8 @@ Network: single user-defined bridge `mccars`. No host networking, no ingress con
### 2.1 Public visitor reads cars
```
browser → :8080/index.html (nginx, static)
browser → :54321/rest/v1/vehicles?select=*&is_active=eq.true
browser → :55580/index.html (nginx, static)
browser → :55521/rest/v1/vehicles?select=*&is_active=eq.true
→ Kong → rest (PostgREST) → postgres
→ RLS: "vehicles_select_active" (for anon, is_active=true only)
← JSON
@@ -59,7 +59,7 @@ No auth header except the anon `apikey`. Kong strips the key and forwards with t
```
browser → supabase.from('leads').insert({...})
→ :54321/rest/v1/leads (POST, apikey=anon)
→ :55521/rest/v1/leads (POST, apikey=anon)
→ PostgREST → postgres
→ RLS "leads_anon_insert": insert allowed, select denied
← 201 (no body returned to anon)
@@ -71,7 +71,7 @@ The frontend never reads leads back. If the INSERT fails (validation, RLS), the
```
browser(admin.html) → supabase.auth.signInWithPassword
→ :54321/auth/v1/token?grant_type=password
→ :55521/auth/v1/token?grant_type=password
→ Kong → gotrue → postgres(auth.users)
← access_token (JWT, aud=authenticated), refresh_token
← user.raw_user_meta_data.must_change_password (true on bootstrap)
@@ -79,7 +79,7 @@ browser(admin.html) → supabase.auth.signInWithPassword
if must_change_password:
user blocked in "Passwort setzen" screen
supabase.auth.updateUser({password, data:{must_change_password:false}})
→ :54321/auth/v1/user (PUT with Bearer access_token)
→ :55521/auth/v1/user (PUT with Bearer access_token)
→ gotrue updates auth.users
```
@@ -106,8 +106,8 @@ Realtime publication fires:
```
admin.js → supabase.storage.from('vehicle-photos').upload(path, file)
→ :54321/storage/v1/object/vehicle-photos/<path>
→ Kong → storage-api → ./data/storage
→ :55521/storage/v1/object/vehicle-photos/<path>
→ Kong → storage-api → /mnt/.../data/storage
← public URL served via imgproxy for on-the-fly transforms
admin.js → UPDATE vehicles SET photo_url = <public url>
```
@@ -204,7 +204,7 @@ Declarative config in `supabase/kong.yml`. One consumer per role:
Plugins pipeline: `cors``key-auth``acl` → proxy. Public routes (storage public objects) are whitelisted without `key-auth`.
Host port mapping: `54321:8000` (8000 blocked by Docker Desktop's wslrelay on Windows).
Host port mapping: `55521:8000` (8000 blocked by Docker Desktop's wslrelay on Windows; `555xx` range avoids collisions on busy Docker hosts).
---
@@ -212,7 +212,8 @@ Host port mapping: `54321:8000` (8000 blocked by Docker Desktop's wslrelay on Wi
```
frontend/
├── Dockerfile (nginx:alpine, writes config.js at boot)
├── Dockerfile (legacy, not used in Portainer deploy)
├── 99-config.sh (entrypoint: writes config.js at boot)
├── nginx.conf (serves /, gzip, cache headers)
├── index.html app.js (public site, anon key, persistSession:false)
├── admin.html admin.js (admin CRM, persistSession:true)
@@ -222,30 +223,44 @@ frontend/
└── datenschutz.html
```
- `config.js` is generated at container start (`envsubst`) from `SUPABASE_URL` and `SUPABASE_ANON_KEY` only. The service_role key is never mounted into the web container.
- `config.js` is generated at container start by `99-config.sh` (bind-mounted into `/docker-entrypoint.d/`) from `$SUPABASE_URL` and `$SUPABASE_ANON_KEY` only. The service_role key is never mounted into the web container.
- The `web` service uses `nginx:1.27-alpine` directly with bind-mounted files (no `build:` step). This is Portainer-compatible: updating the frontend is `git pull` + container restart.
- `admin.js` is ES modules, imports `@supabase/supabase-js` from `esm.sh` (CDN, pinned version). One Supabase client per page.
- State lives in a single `state` object. Admin re-renders the active tab on realtime events.
- Force-password-change modal is the only state that can preempt the rest of the admin UI after login.
---
## 7. Portability & deployment
## 7. Deployment
**The contract:** everything under `c:\Coding\MC Cars GmbH\` (or whatever you rename it to) is the full stack. Copy it anywhere Docker runs, bring up the stack.
**Host root:** `/mnt/user/appdata/mc-cars`
What's state (under `./data/`):
- `./data/db/` — Postgres cluster
- `./data/storage/` — object bucket files
All bind mounts in `docker-compose.yml` use absolute paths under that root. The compose has no `build:` steps — every image is pulled from a registry, making it Portainer-compatible.
What's state (under `/mnt/user/appdata/mc-cars/data/`):
- `data/db/` — Postgres cluster
- `data/storage/` — object bucket files
What's code/config (committed):
- `docker-compose.yml`, `.env` (dev defaults), `supabase/`, `frontend/`, `AGENT.md`, `ARQUITECTURE.md`, `README.md`
- `docker-compose.yml`, `.env`, `supabase/`, `frontend/`, `AGENT.md`, `ARQUITECTURE.md`, `README.md`, `.gitattributes`
**Portainer deployment:**
1. Clone repo to `/mnt/user/appdata/mc-cars`
2. `chmod +x` the `.sh` files
3. `mkdir -p data/{db,storage}`
4. Portainer → Stacks → Add stack → paste compose + env vars → Deploy
**Nginx Proxy Manager (single public domain):**
- Proxy `/``mccars-web:80` (or `<host>:55580`)
- Custom locations `/auth/v1/`, `/rest/v1/`, `/realtime/v1/`, `/storage/v1/``mccars-kong:8000` (or `<host>:55521`)
- Do **not** expose `/pg/` or Studio publicly
- Update `.env` URLs to `https://cars.yourdomain.com`
For real deployments:
1. Generate a new `JWT_SECRET` and matching `ANON_KEY` / `SERVICE_ROLE_KEY` (see Supabase self-hosting docs).
2. Set a strong `POSTGRES_PASSWORD`, `ADMIN_PASSWORD`.
3. Put Kong behind a TLS reverse-proxy (Caddy/Traefik/nginx) and set `SITE_URL`, `API_EXTERNAL_URL`, `SUPABASE_PUBLIC_URL` to the public https URLs.
4. Wire real SMTP for password-reset mail (`SMTP_*`).
5. Back up `./data/` on a schedule.
3. Wire real SMTP for password-reset mail (`SMTP_*`).
4. Back up `/mnt/user/appdata/mc-cars/data/` on a schedule.
---
+38 -22
View File
@@ -1,6 +1,6 @@
# MC Cars Dockerized Supabase CRM
Self-hosted Supabase stack + bilingual (DE/EN) public website + lead-management admin panel. Everything lives under this folder. Copying the folder to another machine and running `docker compose up -d` reproduces the stack bit-for-bit — all runtime state is under `./data/` bind mounts, no named volumes.
Self-hosted Supabase stack + bilingual (DE/EN) public website + lead-management admin panel. Designed for Portainer deployment — no `build:` steps, all services use pre-built images with bind mounts. The host deployment root is `/mnt/user/appdata/mc-cars`.
## What's inside
@@ -14,19 +14,28 @@ Self-hosted Supabase stack + bilingual (DE/EN) public website + lead-management
| `realtime` | `supabase/realtime:v2.30.23` | Live `postgres_changes` subscriptions |
| `meta` | `supabase/postgres-meta:v0.84.2` | Schema introspection for Studio |
| `post-init` | `postgres:15-alpine` | Idempotent bootstrap: seed admin + migrations |
| `kong` | `kong:2.8.1` | Single API gateway at `:54321` |
| `studio` | `supabase/studio` | Supabase dashboard (`:3000`) |
| `web` | local `nginx:alpine` build | Public site + admin panel (`:8080`) |
| `kong` | `kong:2.8.1` | Single API gateway at `:55521` |
| `studio` | `supabase/studio` | Supabase dashboard (`:55530`) |
| `web` | `nginx:1.27-alpine` | Public site + admin panel (`:55580`) |
## Requirements
- Docker Desktop / Docker Engine with Compose v2
- Free ports: `3000`, `5432`, `8080`, `54321`, `54443`
- Docker Engine with Compose v2 (or Portainer with Stacks)
- Free ports: `55521`, `55530`, `55532`, `55543`, `55580`
## Run
```powershell
cd 'c:\Coding\MC Cars GmbH'
### Via Portainer (recommended)
1. Clone the repo onto the host: `git clone <repo> /mnt/user/appdata/mc-cars`
2. `chmod +x /mnt/user/appdata/mc-cars/frontend/99-config.sh /mnt/user/appdata/mc-cars/supabase/migrations/00-run-init.sh`
3. `mkdir -p /mnt/user/appdata/mc-cars/data/{db,storage}`
4. Portainer → Stacks → Add stack → paste `docker-compose.yml` → paste `.env` into Environment variables → Deploy.
### Via CLI
```bash
cd /mnt/user/appdata/mc-cars
docker compose up -d
```
@@ -34,21 +43,20 @@ First boot pulls ~1.5 GB of images and runs migrations (`01-init.sql`, `post-boo
### Stop / reset
```powershell
```bash
docker compose down # stop, keep data
docker compose down -v # stop + delete named volumes (there are none anymore)
Remove-Item -Recurse -Force .\data # FULL wipe (needed to re-run first-boot migrations)
rm -rf /mnt/user/appdata/mc-cars/data/db # FULL DB wipe (re-runs first-boot migrations)
```
## URLs
| Purpose | URL |
| ------------------------------- | -------------------------------- |
| Public website | http://localhost:8080 |
| Admin panel | http://localhost:8080/admin.html |
| Supabase Studio | http://localhost:3000 |
| API gateway (Kong) | http://localhost:54321 |
| Postgres | `localhost:5432` |
| ------------------------------- | --------------------------------- |
| Public website | http://\<host\>:55580 |
| Admin panel | http://\<host\>:55580/admin.html |
| Supabase Studio | http://\<host\>:55530 |
| API gateway (Kong) | http://\<host\>:55521 |
| Postgres | `<host>:55532` |
> Admin access is deliberately **not** linked from the public site. Bookmark it.
@@ -69,9 +77,9 @@ The admin is seeded with `must_change_password = true` in `raw_user_meta_data`.
- RPCs: `qualify_lead(uuid, text)`, `disqualify_lead(uuid, text)`, `reopen_lead(uuid)` — transactional, `SECURITY INVOKER`, `authenticated` only.
- Realtime: `supabase_realtime` publication broadcasts inserts/updates on leads, customers, vehicles.
## Portability
## Deployment & portability
Runtime state under `./data/`:
Runtime state under `/mnt/user/appdata/mc-cars/data/`:
```
data/
@@ -79,7 +87,13 @@ data/
└── storage/ # vehicle-photos bucket content
```
Everything else (config, migrations, frontend) is in the repo. Zip the folder, scp it, `docker compose up -d` — you have the same stack.
All bind mounts in `docker-compose.yml` use absolute paths under `/mnt/user/appdata/mc-cars`. Clone the repo there, deploy as a Portainer stack, done. No `build:` steps — every service pulls a pre-built image.
To put behind **Nginx Proxy Manager** with a single public domain (`cars.yourdomain.com`):
- Proxy `/``mccars-web:80` (or `<host>:55580`)
- Custom locations `/auth/v1/`, `/rest/v1/`, `/realtime/v1/`, `/storage/v1/``mccars-kong:8000` (or `<host>:55521`)
- Do **not** expose `/pg/` or Studio publicly.
- Update `.env` URLs to `https://cars.yourdomain.com`.
## Project layout
@@ -96,17 +110,19 @@ MC Cars/
│ ├── post-boot.sql # admin user (must_change_password) + bucket row
│ └── 02-leads.sql # leads, customers, RPCs, realtime publication
├── frontend/
│ ├── Dockerfile # nginx + runtime anon-key injection
│ ├── Dockerfile # (legacy, not used in Portainer deploy)
│ ├── 99-config.sh # entrypoint: injects config.js with anon key
│ ├── nginx.conf
│ ├── index.html # public DE/EN site, booking form -> leads
│ ├── admin.html # auth-gated CRM
│ ├── app.js
│ ├── admin.js # realtime + qualify/disqualify + password change
│ ├── config.js # anon-only runtime config
│ ├── config.js # anon-only runtime config (generated at boot)
│ ├── i18n.js
│ ├── styles.css
│ ├── impressum.html
│ └── datenschutz.html
├── .gitattributes # enforces LF on .sh files
├── AGENT.md # findings, conventions, traps
└── ARQUITECTURE.md # architecture deep-dive
```