# 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 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 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]