Named-bridge-network production Compose (not host networking, unlike dev) for rn-platform-01; egress-proxy + agents for rn-lab-host-01. Caddy reverse proxy, single public origin, Grafana restricted to management_cidr. iptables-restore firewall rulesets for all three hosts (Proxmox host, rn-platform-01, rn-lab-host-01) codifying the Platform/Lab-Host boundary: no student-to-student traffic, no direct Proxmox exposure, no direct Postgres/Provisioner exposure from the Lab Host. Mutual TLS for the Provisioner<->Lab Host Docker connection: certificate generation/verification scripts, dockerd TLS config, firewall-restricted port 2376. Application support already existed since Phase 1; this is purely the operational half. Validated: docker compose config, caddy validate, firewall structural lint, and a real openssl s_server/s_client mTLS handshake proving the cert chain and mutual-auth rejection (legitimate client verified, rogue client correctly rejected with verify code 19).
213 lines
8.9 KiB
YAML
213 lines
8.9 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
|
|
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]
|