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.
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
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."
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, 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 (
5027by 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_defaultnetwork. The proxy handles TLS, public-DNS routing, and any WAF / rate-limit / auth-header policy. Wire your proxy to forward your domain tohttp://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
5432is 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.