- 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.
13 KiB
TRM Deploy
Deployment configuration for the TRM platform. This repo holds the multi-service compose.yaml and per-environment overrides; service code lives in sibling repos under git.dev.microservices.al/trm/.
Layout
deploy/
├── compose.yaml ← Portainer's Compose path
├── .env.example ← documented variables; copy to .env locally
├── .gitignore
└── README.md
Services in the stack
Currently:
- redis — telemetry queue + future Phase 2 connection registry. Internal-only, persisted via named volume.
- tcp-ingestion — Teltonika telemetry TCP server. Image built by
trm/tcp-ingestion's Gitea workflow. - postgres — PostgreSQL 16 + TimescaleDB + PostGIS via
timescale/timescaledb-ha. Schema authority is split: thepositionshypertable is owned bytrm/processor's migration runner; everything else is owned bytrm/directusvia its snapshot YAML. Internal-only, persisted via named volume. - processor — consumes telemetry from Redis, writes to Postgres. Image built by
trm/processor's Gitea workflow. - directus — business-plane API + admin UI + schema authority. Image built by
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. - spa — React + TypeScript front-end (static bundle served by nginx). Image built by
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.
First-deploy checklist
Run through this once per environment before clicking deploy. It covers the security-critical secrets (which must NOT use the compose.yaml placeholder defaults) and the Portainer setup.
1. Generate per-environment secrets
These values are unique per environment — never reuse them across stage/prod, and never reuse them after a compromise. Run on any machine with openssl and uuidgen (Linux/macOS/WSL):
echo "POSTGRES_PASSWORD=$(openssl rand -base64 32 | tr -d '/+=')"
echo "DIRECTUS_KEY=$(uuidgen)"
echo "DIRECTUS_SECRET=$(openssl rand -hex 64)"
echo "DIRECTUS_ADMIN_PASSWORD=$(openssl rand -base64 24 | tr -d '/+=')"
Keep the output somewhere safe (1Password, Vaultwarden, etc.) — you'll paste it into Portainer next, and you'll need DIRECTUS_ADMIN_PASSWORD again to log in for the first time.
The compose defaults for these (
trm-pilot-change-me,REPLACE-ME-WITH-A-UUID,REPLACE-ME-WITH-A-LONG-RANDOM-STRING,CHANGE-ON-FIRST-LOGIN) are deliberately broken-looking. Anything still using them after deploy is a misconfiguration.
2. Set Portainer stack environment variables
Stack → Environment variables in Portainer's Add stack form. Required for first deploy:
| Variable | Value |
|---|---|
POSTGRES_PASSWORD |
from step 1 |
DIRECTUS_KEY |
from step 1 |
DIRECTUS_SECRET |
from step 1 |
DIRECTUS_ADMIN_EMAIL |
the email you'll log in with |
DIRECTUS_ADMIN_PASSWORD |
from step 1 |
DIRECTUS_PUBLIC_URL |
external URL of the Directus admin UI (e.g. https://directus.stage.example.com). Used in password-reset emails and OAuth redirects. |
Recommended for any non-throwaway environment:
| Variable | Value |
|---|---|
TCP_INGESTION_TAG |
a specific commit SHA (not main) for reproducibility |
PROCESSOR_TAG |
same |
DIRECTUS_TAG |
same |
LOG_LEVEL |
info or warn for prod; debug only for active troubleshooting |
LOG_STYLE |
json for log aggregators; default is already json |
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. |
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
8055inside thetrm_defaultCompose 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 tohttp://directus:8055. The proxy handles TLS, optional WAF / rate-limit, and any auth-header rewriting. SetDIRECTUS_PUBLIC_URLto the proxy-served URL (e.g.https://directus.stage.example.com) so password-reset emails and OAuth redirects work. The dev compose intrm/directusdoes host-publish8055for local iteration; this stage/prod stack deliberately does not.
3. Authenticate the host to the Gitea registry
First deploy only (Portainer's Registries UI is preferred over manual login):
docker login git.dev.microservices.al
4. Deploy the stack
Stack → Add stack → Repository → fill in repo URL, branch, compose path → Deploy the stack.
5. Watch the first boot
Directus's first boot runs ~30–45 s of internal migrations on top of the project's own boot pipeline. Total time-to-healthy on a fresh DB is ~60–90 s. Tail the logs in Portainer → Stack → directus → Logs:
Expected progression:
[entrypoint] step 1/5: db-init (pre-schema)
[db-init] db-init complete: 3 applied, 0 skipped
[entrypoint] step 2/5: directus bootstrap
INFO: Initializing bootstrap...
INFO: Installing Directus system tables...
INFO: Running migrations...
... ~80 internal migrations ...
INFO: Setting up first admin role...
INFO: Adding first admin user...
INFO: Done
[entrypoint] step 3/5: directus schema apply
INFO: Snapshot applied successfully
[entrypoint] step 4/5: db-init (post-schema)
[db-init] db-init complete: 2 applied, 0 skipped
[entrypoint] step 5/5: directus start (pm2-runtime)
PM2 log: App [directus:0] online
INFO: Server started at http://0.0.0.0:8055
The healthcheck flips to healthy once the server is serving (~5–10 s after the "Server started" log line).
6. First login
Browse to ${DIRECTUS_PUBLIC_URL} (or http://<host>:8055 if you didn't put it behind a proxy). Log in with DIRECTUS_ADMIN_EMAIL + DIRECTUS_ADMIN_PASSWORD.
Change the admin password immediately via the user's own profile in the admin UI. The DIRECTUS_ADMIN_PASSWORD env var is only the first-boot seed — changing it post-deploy has no effect on the running user. Same goes for POSTGRES_PASSWORD: it's baked into the persistent volume on first boot and must be rotated via ALTER USER inside psql, not by changing the env var.
7. Verify the schema landed
Admin UI → Settings → Data Model. You should see 12 user collections: organizations, users (built-in directus_users with custom fields), organization_users, vehicles, organization_vehicles, devices, organization_devices, events, classes, entries, entry_crew, entry_devices. If a collection is missing, the schema-apply step failed and the boot logs will say so.
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. Seedocs/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)
- Stack → Add stack → Repository in Portainer.
- Repository URL:
https://git.dev.microservices.al/trm/deploy - Branch:
main - Compose path:
compose.yaml - Environment variables: leave empty for defaults, or set per
.env.example. - (Optional) Enable Automatic updates with a webhook. Portainer will poll or accept a webhook to redeploy when this repo's
mainchanges.
Before the first deploy, the Portainer host must be authenticated to the Gitea registry:
docker login git.dev.microservices.al
(Or configure registry credentials in Portainer's Registries UI — preferred.)
Deploy without Portainer (manual)
git clone https://git.dev.microservices.al/trm/deploy
cd deploy
cp .env.example .env
# edit .env if you want to override defaults
docker compose pull
docker compose up -d
Updating
When a service publishes a new image (Gitea workflow on push to main):
docker compose pull
docker compose up -d
Portainer with automatic updates does this automatically.
To pin a specific build for production, set the relevant *_TAG variable in .env (or in Portainer's stack environment) to a commit SHA — e.g. TCP_INGESTION_TAG=af06973.
Network model
-
One internal Compose network (
trm_default). -
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 (
5027by default) is bound to the host so GPS devices can reach it. This is the only host-published port in the stack. -
HTTP/WS access goes through a reverse proxy (Traefik / Caddy / nginx) on the host or attached to the
trm_defaultnetwork. 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:/api/*→http://directus:8055/.../ws-business→ws://directus:8055/websocket/ws-live→ws://processor:<live-ws-port>(Phase 1.5; default8081)- everything else →
http://spa:80(the SPA's nginx falls through toindex.htmlfor 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
5432is 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.
- Copy the example file:
cp spa-config.example.json spa-config.json - Edit
spa-config.json:- Set
envto"stage"or"prod". - Add
googleMapsKeyif 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).
- Set
- Keep
spa-config.jsonoutside source control. The repo's.gitignorealready excludes it. - In Portainer, ensure
SPA_CONFIG_FILEpoints 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.
Why a separate repo
Compose covers multiple services; placing it inside any one service repo creates ownership ambiguity (which service "owns" the Postgres definition? the Redis volume?). Keeping deploy config in its own repo means:
- Compose changes are versioned independently of any service's code.
- Portainer's Repository stack tracks one source of truth.
- Per-environment overrides (e.g.
compose.stage.yaml,compose.prod.yaml) can be added cleanly later. - Adding a new service is a one-file change here, not a coordinated edit across repos.