diff --git a/AGENT.md b/AGENT.md index b31594d..962dce1 100644 --- a/AGENT.md +++ b/AGENT.md @@ -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://\:55580, submit the booking form. +2. Open http://\: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://\:55530 and confirm the `customers.lead_id` FK matches. --- diff --git a/ARQUITECTURE.md b/ARQUITECTURE.md index c5bbf26..a739ee9 100644 --- a/ARQUITECTURE.md +++ b/ARQUITECTURE.md @@ -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 () │ │ - :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/ - → Kong → storage-api → ./data/storage + → :55521/storage/v1/object/vehicle-photos/ + → Kong → storage-api → /mnt/.../data/storage ← public URL served via imgproxy for on-the-fly transforms admin.js → UPDATE vehicles SET photo_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 `:55580`) +- Custom locations `/auth/v1/`, `/rest/v1/`, `/realtime/v1/`, `/storage/v1/` → `mccars-kong:8000` (or `: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. --- diff --git a/README.md b/README.md index 81f27d4..3b12883 100644 --- a/README.md +++ b/README.md @@ -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 /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 -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) +```bash +docker compose down # stop, keep data +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` | +| Purpose | URL | +| ------------------------------- | --------------------------------- | +| Public website | http://\:55580 | +| Admin panel | http://\:55580/admin.html | +| Supabase Studio | http://\:55530 | +| API gateway (Kong) | http://\:55521 | +| Postgres | `: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 `:55580`) +- Custom locations `/auth/v1/`, `/rest/v1/`, `/realtime/v1/`, `/storage/v1/` → `mccars-kong:8000` (or `: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 ```