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: the positions hypertable is owned by 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'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 ~6090 s.

Planned (will be added as they land):

  • react-spa — front-end SPA (static bundle, served via reverse proxy).

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.
DIRECTUS_CORS_ENABLED / DIRECTUS_CORS_ORIGIN Set to true and the SPA's origin URL once the React SPA is deployed.

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.

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 stackRepository → fill in repo URL, branch, compose path → Deploy the stack.

5. Watch the first boot

Directus's first boot runs ~3045 s of internal migrations on top of the project's own boot pipeline. Total time-to-healthy on a fresh DB is ~6090 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 (~510 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. See docs/wiki/entities/directus.md "destructive-apply hazard."


Deploy via Portainer (Repository Stack)

  1. Stack → Add stack → Repository in Portainer.
  2. Repository URL: https://git.dev.microservices.al/trm/deploy
  3. Branch: main
  4. Compose path: compose.yaml
  5. Environment variables: leave empty for defaults, or set per .env.example.
  6. (Optional) Enable Automatic updates with a webhook. Portainer will poll or accept a webhook to redeploy when this repo's main changes.

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, 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).
  • 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.
  • 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.

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.
S
Description
No description provided
Readme 60 KiB