# TRM platform — deployment stack (dev environment) # # Deployed via Komodo Stack-from-Git: # Repository: git.dev.trmtracking.org/trm/deploy # Compose path: compose.dev.yaml # Branch: dev # # Images are built and pushed by each service's own Gitea Actions workflow # (see each repo's .gitea/workflows/build.yml). This file references the # resulting images by tag and runs them as a coordinated stack. # # Before first deploy on the host: `docker login git.dev.trmtracking.org` # (or configure registry credentials in Komodo's Image Registry Account). # # Environment variables are populated from Komodo's per-stack secrets # config (or a `.env` file alongside this compose for non-Komodo hosts). # Defaults are provided via `${VAR:-default}` so the stack starts with no # explicit configuration on a fresh deploy. # # Routing — fronted by Traefik on the host (single origin model required by # the SPA's session cookie + Processor WebSocket upgrade; see # react-spa.md and processor-ws-contract.md): # app.dev.trmtracking.org/ → spa (catch-all SPA bundle) # app.dev.trmtracking.org/api/* → directus (REST/GraphQL, /api stripped) # app.dev.trmtracking.org/ws-business → directus (Directus's WS, served at this path natively) # app.dev.trmtracking.org/ws-live → processor (live telemetry WS, /ws-live stripped) # api.dev.trmtracking.org/ → directus (admin UI on its own subdomain) name: trm services: # ------------------------------------------------------------------- # Redis — telemetry queue + (future) connection registry for Phase 2. # Internal-only; no host port mapping. # ------------------------------------------------------------------- redis: image: redis:7-alpine expose: - '6379' volumes: - redis-data:/data restart: unless-stopped healthcheck: test: ['CMD', 'redis-cli', 'ping'] interval: 10s timeout: 3s retries: 5 # ------------------------------------------------------------------- # tcp-ingestion — Teltonika telemetry TCP server. # Built by git.dev.trmtracking.org/trm/tcp-ingestion's Gitea workflow. # ------------------------------------------------------------------- tcp-ingestion: image: git.dev.trmtracking.org/trm/tcp-ingestion:${TCP_INGESTION_TAG:-dev} depends_on: redis: condition: service_healthy ports: # Devices connect to this port. Use `${HOST_BIND_IP:-0.0.0.0}:5027:5027` # if you want to restrict which host interface accepts connections. - '${TCP_INGESTION_PORT:-5027}:5027' environment: NODE_ENV: production INSTANCE_ID: ${TCP_INGESTION_INSTANCE_ID:-stage-1} REDIS_URL: redis://redis:6379 # Pinned explicitly here to keep tcp-ingestion and processor in sync; # neither service's compiled default is authoritative — the deploy # stack is the single source of truth for the stream name. REDIS_TELEMETRY_STREAM: ${REDIS_TELEMETRY_STREAM:-telemetry:teltonika} LOG_LEVEL: ${LOG_LEVEL:-info} restart: unless-stopped # ------------------------------------------------------------------- # postgres — PostgreSQL 16 with TimescaleDB + PostGIS extensions # (and others) bundled. Image: timescale/timescaledb-ha, `-all` suffix # = all extensions present and ready for `CREATE EXTENSION`. # # Schema is owned by Directus (when it lands); the `positions` # hypertable is owned by the processor's migration runner. # Internal-only; no host port mapping. # # The image tag should be reviewed every 3–6 months. Pick the latest # stable `pg16-ts*-all` build from Docker Hub: # https://hub.docker.com/r/timescale/timescaledb-ha/tags # Pin to a specific tag (not rolling `pg16`) so deploys are reproducible. # ------------------------------------------------------------------- postgres: image: timescale/timescaledb-ha:pg16.6-ts2.17.2-all expose: - '5432' volumes: - postgres-data:/home/postgres/pgdata/data environment: POSTGRES_USER: ${POSTGRES_USER:-trm} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-trm-pilot-change-me} POSTGRES_DB: ${POSTGRES_DB:-trm} restart: unless-stopped healthcheck: test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-trm} -d ${POSTGRES_DB:-trm}'] interval: 10s timeout: 3s retries: 5 # ------------------------------------------------------------------- # processor — consumes telemetry from Redis, writes to Postgres, and # (post Phase 1.5) broadcasts live positions over its own WebSocket. # Built by git.dev.trmtracking.org/trm/processor's Gitea workflow. # # Routed by Traefik at app.dev.trmtracking.org/ws-live (the host strips # the prefix; processor hosts its WS server at root). The route is # ready in advance of the live-broadcast feature landing. # ------------------------------------------------------------------- processor: image: git.dev.trmtracking.org/trm/processor:${PROCESSOR_TAG:-dev} depends_on: redis: condition: service_healthy postgres: condition: service_healthy expose: - '8081' environment: NODE_ENV: production INSTANCE_ID: ${PROCESSOR_INSTANCE_ID:-processor-1} REDIS_URL: redis://redis:6379 # Must match tcp-ingestion's REDIS_TELEMETRY_STREAM — both pinned # to the same shared variable so they cannot drift. REDIS_TELEMETRY_STREAM: ${REDIS_TELEMETRY_STREAM:-telemetry:teltonika} POSTGRES_URL: postgres://${POSTGRES_USER:-trm}:${POSTGRES_PASSWORD:-trm-pilot-change-me}@postgres:5432/${POSTGRES_DB:-trm} LIVE_WS_PORT: ${LIVE_WS_PORT:-8081} LOG_LEVEL: ${LOG_LEVEL:-info} networks: - default - proxy restart: unless-stopped labels: - "traefik.enable=true" - "traefik.docker.network=proxy" - "traefik.http.routers.trm-processor-live.rule=Host(`app.dev.trmtracking.org`) && PathPrefix(`/ws-live`)" - "traefik.http.routers.trm-processor-live.entrypoints=websecure" - "traefik.http.routers.trm-processor-live.tls.certresolver=le" - "traefik.http.routers.trm-processor-live.middlewares=trm-strip-ws-live" - "traefik.http.middlewares.trm-strip-ws-live.stripprefix.prefixes=/ws-live" - "traefik.http.services.trm-processor-live.loadbalancer.server.port=8081" # ------------------------------------------------------------------- # directus — business-plane API, admin UI, and schema authority. # Built by git.dev.trmtracking.org/trm/directus's Gitea workflow. # # Boot pipeline (5 steps; see trm/directus/entrypoint.sh): # 1. db-init pre-schema → positions hypertable + faulty column # 2. directus bootstrap → installs Directus system tables # 3. directus schema apply → applies snapshots/schema.yaml # 4. db-init post-schema → composite UNIQUE constraints # 5. pm2-runtime start → server up at :8055 # # First-boot on a fresh DB takes ~60–90 s (Directus runs its own # internal migrations during step 2). Subsequent boots are ~5 s as # all steps no-op against the warm DB. # # Schema-as-code: collections + fields + relations live in the image # (snapshots/schema.yaml + db-init/*.sql baked in at build time). # Schema changes flow through the trm/directus repo + its CI dry-run # gate, NOT through manual edits on this stage instance. Editing # collections via the admin UI here will be DROPPED on the next image # rebuild — schema-apply enforces the committed snapshot. See # docs/wiki/entities/directus.md "destructive-apply hazard" callout. # # Routing: two Traefik routers point at the same backend. # api.dev.trmtracking.org/* → admin UI + direct API access # app.dev.trmtracking.org/api/* → SPA's REST/GraphQL surface (prefix stripped) # app.dev.trmtracking.org/ws-business → SPA's business-plane WebSocket # The /ws-business path is served by Directus natively via # WEBSOCKETS_REST_PATH below; no Traefik rewrite is needed. # ------------------------------------------------------------------- directus: image: git.dev.trmtracking.org/trm/directus:${DIRECTUS_TAG:-dev} depends_on: postgres: condition: service_healthy expose: # Internal-only. The admin UI + API are reachable from other services # in the stack via service-name DNS (`http://directus:8055`). Traefik # on the host fronts both public hostnames and forwards to this port. # # Why not host-publish 8055 directly: the admin UI is a privileged # surface (full CRUD + permission policies + Flow execution). Direct # exposure leaks an attack surface and forces TLS into a service that # shouldn't care about it. tcp-ingestion is different (GPS devices # connect directly so it must publish to the host); Directus is HTTP # and belongs behind a proxy in any non-throwaway environment. - '8055' environment: # ----- Database connection ----- DB_CLIENT: pg DB_HOST: postgres DB_PORT: 5432 DB_DATABASE: ${POSTGRES_DB:-trm} DB_USER: ${POSTGRES_USER:-trm} DB_PASSWORD: ${POSTGRES_PASSWORD:-trm-pilot-change-me} # ----- Instance security — REQUIRED, must be unique per environment. # KEY: any UUID. SECRET: long random string, e.g. `openssl rand -hex 64`. # Two instances sharing the same KEY/SECRET produce colliding JWTs. # Defaults below are placeholders — REPLACE in Komodo's stack secrets. KEY: ${DIRECTUS_KEY:-REPLACE-ME-WITH-A-UUID} SECRET: ${DIRECTUS_SECRET:-REPLACE-ME-WITH-A-LONG-RANDOM-STRING} # ----- Admin bootstrap — only used on first init. # If directus_users is empty at first boot, an admin user is created # from these. Subsequent boots ignore them. Change the password via # the admin UI after first login. ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL:-admin@example.com} ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD:-CHANGE-ON-FIRST-LOGIN} # ----- Public-facing URL (used in emails, OAuth redirects, asset URLs). # Default = the admin canonical hostname so password-reset links land # in the admin UI, not the SPA. PUBLIC_URL: ${DIRECTUS_PUBLIC_URL:-https://api.dev.trmtracking.org} # ----- Logging ----- LOG_LEVEL: ${LOG_LEVEL:-info} LOG_STYLE: ${LOG_STYLE:-json} # ----- WebSockets — required for the live channel architecture # (Directus's WS subs cover business-plane events; processor's WS # carries the telemetry firehose). See live-channel-architecture # in the wiki. WEBSOCKETS_ENABLED: 'true' # Serve Directus's WS at the path the SPA expects under app.dev, # avoiding a Traefik rewrite (cookies + upgrade headers stay clean). WEBSOCKETS_REST_PATH: /ws-business # ----- Cache / CORS — defaults disabled; enable per environment. CACHE_ENABLED: ${DIRECTUS_CACHE_ENABLED:-false} CORS_ENABLED: ${DIRECTUS_CORS_ENABLED:-false} CORS_ORIGIN: ${DIRECTUS_CORS_ORIGIN:-false} volumes: # Persist admin-uploaded files across container restarts. # snapshots/ + db-init/ are baked into the image, NOT mounted — # that's the schema-as-code split. - directus-uploads:/directus/uploads networks: - default - proxy restart: unless-stopped labels: - "traefik.enable=true" - "traefik.docker.network=proxy" # Admin UI on its own subdomain (full surface, root path). - "traefik.http.routers.trm-directus-admin.rule=Host(`api.dev.trmtracking.org`)" - "traefik.http.routers.trm-directus-admin.entrypoints=websecure" - "traefik.http.routers.trm-directus-admin.tls.certresolver=le" - "traefik.http.routers.trm-directus-admin.service=trm-directus" # SPA-side REST/GraphQL surface — strip /api prefix. - "traefik.http.routers.trm-directus-api.rule=Host(`app.dev.trmtracking.org`) && PathPrefix(`/api`)" - "traefik.http.routers.trm-directus-api.entrypoints=websecure" - "traefik.http.routers.trm-directus-api.tls.certresolver=le" - "traefik.http.routers.trm-directus-api.service=trm-directus" - "traefik.http.routers.trm-directus-api.middlewares=trm-strip-api" - "traefik.http.middlewares.trm-strip-api.stripprefix.prefixes=/api" # SPA-side business-plane WebSocket — Directus serves at /ws-business # natively (WEBSOCKETS_REST_PATH above), so no rewrite middleware. - "traefik.http.routers.trm-directus-wsbiz.rule=Host(`app.dev.trmtracking.org`) && PathPrefix(`/ws-business`)" - "traefik.http.routers.trm-directus-wsbiz.entrypoints=websecure" - "traefik.http.routers.trm-directus-wsbiz.tls.certresolver=le" - "traefik.http.routers.trm-directus-wsbiz.service=trm-directus" # Single backend service shared by all three routers above. - "traefik.http.services.trm-directus.loadbalancer.server.port=8055" healthcheck: test: ['CMD-SHELL', 'wget -qO- http://localhost:8055/server/health || exit 1'] interval: 30s timeout: 10s # First boot includes Directus's internal migrations (~30–45 s on # fresh DB). 120 s gives margin; warm boots become healthy in ~10 s. start_period: 120s retries: 3 # ------------------------------------------------------------------- # spa — React + TypeScript single-page app for end-user operators. # Built by git.dev.trmtracking.org/trm/spa's Gitea workflow. # # The image is nginx serving a static bundle. The bundle's runtime # config (Directus URL, Processor WS URL, optional Google Maps key, # env label) is read at first paint from /config.json — overridable # via the volume mount below without rebuilding the image. # # Routed as the catch-all on app.dev.trmtracking.org. The /api, # /ws-business, and /ws-live PathPrefix routers above bind first; # everything else falls through to this router. # # Same-origin is non-negotiable: the SPA's auth cookie + the # processor's WebSocket upgrade both rely on browser-attached cookies # that only flow within one origin. # ------------------------------------------------------------------- spa: image: git.dev.trmtracking.org/trm/spa:${SPA_TAG:-dev} expose: - '80' volumes: # Override the baked-in dev config with the per-environment one. # Operators copy spa-config.example.json to spa-config.json (or # whatever ${SPA_CONFIG_FILE} points at) before first deploy and # edit the URLs / env label / optional Google Maps key. Read-only # mount — the container can't accidentally write to its own config. - ${SPA_CONFIG_FILE:-./spa-config.json}:/usr/share/nginx/html/config.json:ro networks: - proxy restart: unless-stopped labels: - "traefik.enable=true" - "traefik.docker.network=proxy" - "traefik.http.routers.trm-spa.rule=Host(`app.dev.trmtracking.org`)" - "traefik.http.routers.trm-spa.entrypoints=websecure" - "traefik.http.routers.trm-spa.tls.certresolver=le" - "traefik.http.services.trm-spa.loadbalancer.server.port=80" healthcheck: test: ['CMD-SHELL', 'wget -qO- http://localhost/ || exit 1'] interval: 30s timeout: 5s start_period: 5s retries: 3 volumes: redis-data: postgres-data: directus-uploads: networks: default: {} proxy: external: true