Files
deploy/compose.dev.yaml
T

333 lines
15 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 36 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 ~6090 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 (~3045 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