Files
rootnode-academy/infra/docker/platform/docker-compose.production.yml
T
ced4568 eae8e48d43 feat(routing): Lab Host Router closes the Gateway-to-Lab cross-VM gap
Adds platform/lab-router/ — a narrow service that runs on the Lab Host,
authenticates the Gateway via mTLS, and resolves an opaque routing token to
a local container purely from Docker label state (no Postgres access, no
callback to the Provisioner). Replaces the Gateway's env.internal_ip
assumption (only valid when Gateway and Lab share a Docker daemon) with a
data-driven direct/router decision per lab_host
(lab_hosts.routing_mode/router_endpoint, migration 004), resolved in one
place (platform/gateway/src/lab-endpoint.js's labTarget()) that every
proxy/fetch call site in server.js now goes through.

Extends the existing Docker TLS CA (infra/scripts/docker-tls/generate-docker-tls.sh)
with a second mTLS leaf pair (router-server/gateway-client) for this new
data-plane connection, deployed by the docker-tls Ansible role into a
separate directory from the control-plane pair. Firewall
(rn-lab-host-01.rules) replaces the old speculative wide port-range
allowance with one narrow rule: platform_ip -> the Router's port only.

dev-local stays routing_mode: direct — zero behavior change for local dev,
confirmed by the full existing regression suite passing unmodified.
2026-08-24 20:14:39 -05:00

222 lines
9.4 KiB
YAML

# Production compose for rn-platform-01. Deployed by infra/ansible/roles/platform/ to
# /opt/rootnode/platform/docker-compose.yml, run as a systemd-managed `docker compose` unit
# (infra/ansible/roles/platform/templates/rootnode-platform.service.j2).
#
# Differs from platform/docker-compose.dev.yml in the ways that actually matter for
# production, and nothing else:
# - images are pulled from a registry by DIGEST-PINNED TAG (never :latest, never built
# in-place) — see infra/docker/IMAGE_STRATEGY.md
# - Postgres data and backups live on a bind-mounted ZFS dataset, not an anonymous volume
# - a reverse proxy (Caddy) terminates TLS and is the only container with a port published to
# the internet; a few other services publish loopback-only or single-source-IP-restricted
# ports for specific operational needs (Postgres for the host's own backup cron, Loki for
# rn-lab-host-01's log shipper) — see each service's own comment
# - no `network_mode: host` on gateway/provisioner — production uses a named bridge network,
# since rn-platform-01 no longer needs to reach ephemeral container IPs directly (those live
# on rn-lab-host-01, reached over the network, not shared Docker networking)
# - the api/gateway/provisioner containers still don't reach the internet except through
# what's explicitly allowed by the host firewall (infra/firewall/), not Docker's own
# default bridge NAT behavior
#
# All image tags, ports, and paths below are Ansible Jinja2 variables — this file is a
# template rendered by the `platform` role, not run directly. See
# infra/ansible/group_vars/production.yml for the values.
networks:
rn-platform-internal:
driver: bridge
internal: false # postgres/api/gateway/provisioner need it false to reach rn-lab-host-01
# over the network; the HOST firewall (not Docker) is what actually
# restricts what that traffic can reach — see docs/PRODUCTION_NETWORK_SECURITY.md
volumes:
postgres-data:
driver: local
driver_opts:
type: none
o: bind
device: "{{ rootnode_data_root }}/postgres"
caddy-data:
caddy-config:
prometheus-data:
alertmanager-data:
grafana-data:
loki-data:
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
env_file: /etc/rootnode/postgres.env
volumes:
- postgres-data:/var/lib/postgresql/data
- /opt/rootnode/platform/postgresql.conf.d:/etc/postgresql/conf.d:ro
# include_dir appends our tuning files to the image's own auto-generated postgresql.conf —
# no need to replace or template the base config file at all.
command: ["postgres", "-c", "include_dir=/etc/postgresql/conf.d"]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-rootnode_platform}"]
interval: 10s
timeout: 5s
retries: 10
networks: [rn-platform-internal]
ports:
- "127.0.0.1:5432:5432" # loopback-only — reachable from other containers on
# rn-platform-internal AND from the host's own backup cron
# (infra/backup/), never from another host on the network, let
# alone the internet. This is NOT the same as the "Postgres
# never exposed on this VM's external interface" rule in
# rn-platform-01.rules — that rule (and the host firewall in
# general) still applies to this port; 127.0.0.1 binding is a
# second, independent reason nothing off-host can ever reach it.
provisioner:
image: "{{ registry }}/rootnode/provisioner:{{ image_tag }}"
restart: unless-stopped
env_file: /etc/rootnode/provisioner.env
volumes:
- /etc/rootnode/provisioner-config:/app/config:ro
- /etc/rootnode/docker-tls:/app/docker-tls:ro
depends_on:
postgres:
condition: service_healthy
networks: [rn-platform-internal]
cap_add: [NET_ADMIN, NET_RAW] # only needed while a lab_host row is still a local Unix
# socket (dev); harmless no-op against a remote TLS endpoint
api:
image: "{{ registry }}/rootnode/platform-api:{{ image_tag }}"
restart: unless-stopped
env_file: /etc/rootnode/platform.env
environment:
# Phase 4.1 §25 — not a secret, just the deployed tag; set directly here rather than in
# the env template since it's the same value already used for the image tag above, not a
# separately-managed setting.
GIT_SHA: "{{ image_tag }}"
depends_on:
postgres:
condition: service_healthy
provisioner:
condition: service_started
networks: [rn-platform-internal]
gateway:
image: "{{ registry }}/rootnode/gateway:{{ image_tag }}"
restart: unless-stopped
env_file: /etc/rootnode/gateway.env
volumes:
# mTLS client identity for the Lab Host Router (data plane) — see gateway.env.j2's
# LAB_ROUTER_TLS_* vars. Read-only; the private key never leaves this one bind mount.
- /etc/rootnode/lab-router-tls:/tls:ro
depends_on: [provisioner, api]
networks: [rn-platform-internal]
reverse-proxy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "443:443"
- "80:80" # ACME HTTP-01 challenge / plain-HTTP-to-HTTPS redirect only
volumes:
- /opt/rootnode/platform/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
depends_on: [gateway]
networks: [rn-platform-internal]
# ── Observability agents (infra/monitoring/) ────────────────────────────────────────────
node-exporter:
image: prom/node-exporter:latest
restart: unless-stopped
network_mode: host # needs host-level metrics; this is metrics-only, not app traffic
pid: host
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/rootfs:ro
- /var/lib/node-exporter/textfile:/textfile:ro # backup-postgres.sh writes its metric here
command:
- "--path.procfs=/host/proc"
- "--path.sysfs=/host/sys"
- "--path.rootfs=/rootfs"
- "--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)"
- "--collector.textfile.directory=/textfile"
postgres-exporter:
image: prometheuscommunity/postgres-exporter:latest
restart: unless-stopped
environment:
DATA_SOURCE_NAME: "postgresql://{{ postgres_exporter_user }}:{{ vault_postgres_exporter_password }}@postgres:5432/rootnode_platform?sslmode=disable"
depends_on: [postgres]
networks: [rn-platform-internal]
blackbox-exporter:
image: prom/blackbox-exporter:latest
restart: unless-stopped
volumes:
- /opt/rootnode/monitoring/blackbox.yml:/etc/blackbox_exporter/config.yml:ro
networks: [rn-platform-internal]
prometheus:
image: prom/prometheus:latest
restart: unless-stopped
volumes:
- /opt/rootnode/monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- /opt/rootnode/monitoring/rules:/etc/prometheus/rules:ro
- prometheus-data:/prometheus
command:
- "--config.file=/etc/prometheus/prometheus.yml"
- "--storage.tsdb.retention.time={{ prometheus_retention_days }}d"
networks: [rn-platform-internal]
ports:
- "127.0.0.1:9090:9090" # bound to loopback only — access via SSH tunnel or Grafana, never public
alertmanager:
image: prom/alertmanager:latest
restart: unless-stopped
volumes:
- /opt/rootnode/monitoring/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
- alertmanager-data:/alertmanager
networks: [rn-platform-internal]
grafana:
image: grafana/grafana-oss:latest
restart: unless-stopped
environment:
GF_SECURITY_ADMIN_PASSWORD: "{{ vault_grafana_admin_password }}"
GF_SERVER_ROOT_URL: "https://{{ academy_domain }}/grafana/"
GF_SERVER_SERVE_FROM_SUB_PATH: "true"
GF_USERS_ALLOW_SIGN_UP: "false"
volumes:
- grafana-data:/var/lib/grafana
- /opt/rootnode/monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
- /opt/rootnode/monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
networks: [rn-platform-internal]
# No published port — reached only via the reverse proxy's /grafana/ path (Caddyfile),
# never a direct public listener. See docs/PRODUCTION_NETWORK_SECURITY.md §8.
loki:
image: grafana/loki:latest
restart: unless-stopped
volumes:
- /opt/rootnode/monitoring/loki-config.yml:/etc/loki/local-config.yaml:ro
- loki-data:/loki
command: ["-config.file=/etc/loki/local-config.yaml"]
networks: [rn-platform-internal]
ports:
- "3100:3100" # reachable from rn-lab-host-01's promtail over the network — restricted
# to that one source IP by the host firewall (rn-platform-01.rules), same
# pattern as Docker TLS's 2376. Not published to the internet.
promtail:
image: grafana/promtail:latest
restart: unless-stopped
volumes:
- /opt/rootnode/monitoring/promtail-config.yml:/etc/promtail/config.yml:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
- /var/log/journal:/var/log/journal:ro
- /etc/machine-id:/etc/machine-id:ro
command: ["-config.file=/etc/promtail/config.yml"]
networks: [rn-platform-internal]
depends_on: [loki]