- compose.yaml: new spa service. Image trm/spa:${SPA_TAG:-main}.
expose: 80 (internal-only; reverse proxy fronts it). Read-only
volume mount ${SPA_CONFIG_FILE:-./spa-config.json} ->
/usr/share/nginx/html/config.json for the runtime-config override.
Healthcheck via wget against localhost.
- spa-config.example.json: dev-default-equivalent file with env=stage.
Operators copy to spa-config.json and edit per environment.
- .gitignore: spa-config.json never committed (per-environment).
- .env.example: new spa section documenting SPA_TAG and
SPA_CONFIG_FILE; reframes the directus internal-only note as a
shared design (SPA also internal-only).
- README.md:
- Services in the stack: SPA moved from Planned to Currently.
- Step 8 in first-deploy checklist: "Verify the SPA loads".
- Network model: spell out the four routes the proxy must wire
(/api, /ws-business, /ws-live, default -> SPA). Same-origin
non-negotiable callout.
- New "Runtime config override (SPA)" section: copy/edit/mount
workflow + when not to use absolute URLs.
- **postgres** — PostgreSQL 16 + TimescaleDB + PostGIS via `timescale/timescaledb-ha`. Schema authority is split: the `positions` hypertable is owned by [`trm/processor`](https://git.dev.microservices.al/trm/processor)'s migration runner; everything else is owned by `trm/directus` via its snapshot YAML. Internal-only, persisted via named volume.
- **processor** — consumes telemetry from Redis, writes to Postgres. Image built by [`trm/processor`](https://git.dev.microservices.al/trm/processor)'s Gitea workflow.
- **directus** — business-plane API + admin UI + schema authority. Image built by [`trm/directus`](https://git.dev.microservices.al/trm/directus)'s Gitea workflow. Boot pipeline runs db-init pre-schema → bootstrap → schema-apply → db-init post-schema → start; first boot on a fresh DB takes ~60–90 s.
Planned (will be added as they land):
- **react-spa** — front-end SPA (static bundle, served via reverse proxy).
- **spa** — React + TypeScript front-end (static bundle served by nginx). Image built by [`trm/spa`](https://git.dev.microservices.al/trm/spa)'s Gitea workflow. Internal-only; the reverse proxy routes the public domain root to it. Runtime config is overridden via a host-path volume mount onto `/usr/share/nginx/html/config.json` — one image, multiple environments.
See `../docs/wiki/` for the full architecture.
@@ -75,7 +72,9 @@ Optional:
| Variable | When to set |
|---|---|
| `TCP_INGESTION_PORT` | Change if `5027` is already in use on the host. GPS devices need a real host port — this one is published. |
| `DIRECTUS_CORS_ENABLED` / `DIRECTUS_CORS_ORIGIN` | Set to `true` and the SPA's origin URL once the React SPA is deployed. |
| `SPA_TAG` | A specific commit SHA for production reproducibility; `main` for stage's auto-tracking. |
| `SPA_CONFIG_FILE` | Path on the host to the SPA's runtime config file (default `./spa-config.json`). See "Runtime config override" below. |
| `DIRECTUS_CORS_ENABLED` / `DIRECTUS_CORS_ORIGIN` | Leave disabled — same-origin proxy means CORS isn't in play. Enable only if you ever need cross-origin access from a partner integration. |
> **Directus is internal-only by design.** It listens on `8055` inside the `trm_default` Compose network and is **not** published to the host. Wire a reverse proxy (Traefik / Caddy / nginx) on the host or attached to the network and forward your public domain to `http://directus:8055`. The proxy handles TLS, optional WAF / rate-limit, and any auth-header rewriting. Set `DIRECTUS_PUBLIC_URL` to the proxy-served URL (e.g. `https://directus.stage.example.com`) so password-reset emails and OAuth redirects work. The dev compose in `trm/directus` does host-publish `8055` for local iteration; this stage/prod stack deliberately does not.
@@ -131,6 +130,17 @@ Admin UI → **Settings → Data Model**. You should see 12 user collections: `o
> **Schema-as-code reminder:** do NOT add or remove collections via the admin UI on this stage instance. Schema changes flow through `trm/directus` (admin-UI edit → `pnpm run schema:snapshot` → commit → CI dry-run → image rebuild → redeploy). Edits made directly here will be DROPPED on the next image rebuild — schema-apply enforces the committed snapshot. See `docs/wiki/entities/directus.md` "destructive-apply hazard."
### 8. Verify the SPA loads
Browse to the public root (e.g. `https://stage.trmtracking.org/`). Expected behaviour:
- Unauthenticated → redirected to `/login`.
- Submit valid Directus credentials → redirected to `/`. Operator sees the placeholder home page (live-monitoring map lands in Phase 2).
- Hard refresh on `/` while authenticated stays on `/` (session cookie carries the session — no client-side state needed across reloads).
- Sign out → redirect back to `/login`. Refresh stays on `/login`.
If `/config.json` returns a 404 or a malformed JSON, the SPA renders an explicit "Runtime config error" panel listing what's missing — that's a sign the volume mount didn't pick up the override file. Re-check the `SPA_CONFIG_FILE` path and that the file is reachable from inside the container.
---
## Deploy via Portainer (Repository Stack)
@@ -177,11 +187,34 @@ To pin a specific build for production, set the relevant `*_TAG` variable in `.e
## Network model
- One internal Compose network (`trm_default`).
- **Redis**, **postgres**, **directus**, and**processor** are not bound to host ports — only reachable from other services in the stack via service-name DNS (`redis://redis:6379`, `postgres:5432`, `http://directus:8055`).
- **Redis**, **postgres**, **directus**, **processor**, and **spa** are not bound to host ports — only reachable from other services in the stack via service-name DNS (`redis://redis:6379`, `postgres:5432`, `http://directus:8055`, `http://spa:80`).
- **tcp-ingestion**'s TCP port (`5027` by default) is bound to the host so GPS devices can reach it. This is the only host-published port in the stack.
- **Directus admin UI / API access** goes through a reverse proxy (Traefik / Caddy / nginx) on the host or attached to the `trm_default` network. The proxy handles TLS, public-DNS routing, and any WAF / rate-limit / auth-header policy. Wire your proxy to forward your domain to `http://directus:8055`. The proxy itself is not part of this stack — add it as a sibling stack in Portainer or run it on the host.
- **HTTP/WS access** goes through a reverse proxy (Traefik / Caddy / nginx) on the host or attached to the `trm_default` network. **Same-origin is non-negotiable** — the SPA's session cookie and the Processor's WebSocket upgrade both rely on browser-attached cookies that only flow within one origin. Route everything under one public domain:
- everything else → `http://spa:80` (the SPA's nginx falls through to `index.html` for any unmatched path so client-side routes resolve)
The proxy itself is not part of this stack — add it as a sibling stack in Portainer or run it on the host.
- Other Redis / Postgres instances on the same host can keep using their default ports freely; this stack does not collide with them. The default postgres-on-host you may already have running on `5432` is untouched — this stack's postgres is internal-only.
## Runtime config override (SPA)
The SPA reads `/config.json` at first paint to discover its backend URLs and feature toggles. The image bakes a default suitable for dev (relative paths matching the Vite proxy); stage and prod override via a host-path volume mount.
1. Copy the example file:
```bash
cp spa-config.example.json spa-config.json
```
2. Edit `spa-config.json`:
- Set `env` to `"stage"` or `"prod"`.
- Add `googleMapsKey` if operators have a Google Maps Tiles API key (optional; if absent the Google tile sources are hidden in the UI).
- URLs stay relative (`/api`, `/ws-live`, `/ws-business`) — the reverse proxy routes them to the right backends. Only switch to absolute URLs if the SPA ever needs to call cross-origin (which breaks cookie auth — avoid).
3. Keep `spa-config.json` outside source control. The repo's `.gitignore` already excludes it.
4. In Portainer, ensure `SPA_CONFIG_FILE` points at the on-host path of the file. Stack redeploy mounts it read-only into the SPA container.
Changing the file does not require a SPA image rebuild — just `docker compose up -d spa` (or a stack redeploy in Portainer) to pick up the new file. The SPA refetches `/config.json` on every page load.
## Environment variables
See `.env.example` for the documented set with defaults and explanations.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.