From 8ab906198351994fda15a14d7de004eb3363b90f Mon Sep 17 00:00:00 2001 From: znetsixe Date: Thu, 21 May 2026 12:37:59 +0200 Subject: [PATCH] scaffold: hub-and-spoke layout, 4-network topology, 13 stack stubs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Initial structure for R&D infrastructure: - stacks/ — 13 reusable, runnable stack stubs (kebab-case) cloud-and-edge: node-red, influxdb, grafana, keycloak, portainer, nginx-proxy, mqtt, postfix cloud-only: wireguard-server, gitea, jenkins, sql (postgres stub) edge-only: wireguard-client - cloud/ — single central hub composition with 4 networks (edge, app, data internal, mgmt) and include: stubs - sites/ — per-plant edge folders (template README only for now) - docs/architecture.md — hub-and-spoke + ingress + segmentation rationale Network model: only nginx-proxy (80/443/8883) and wireguard-server (51820/udp) publish ports on the cloud host. Edge nginx publishes 80/443 on plant-LAN interface only. MQTT cloud-side via nginx stream proxy; MQTT edge-side internal-only; Postfix outbound-only. OT layer (OPCUA, PLCs) is out of scope for this repo. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 23 +++++ README.md | 59 ++++++++++++ cloud/.env.example | 43 +++++++++ cloud/README.md | 34 +++++++ cloud/compose.yml | 35 +++++++ docs/architecture.md | 135 +++++++++++++++++++++++++++ sites/README.md | 44 +++++++++ stacks/gitea/.env.example | 6 ++ stacks/gitea/README.md | 7 ++ stacks/gitea/compose.yml | 28 ++++++ stacks/grafana/.env.example | 3 + stacks/grafana/README.md | 8 ++ stacks/grafana/compose.yml | 23 +++++ stacks/influxdb/.env.example | 5 + stacks/influxdb/README.md | 8 ++ stacks/influxdb/compose.yml | 25 +++++ stacks/jenkins/.env.example | 2 + stacks/jenkins/README.md | 7 ++ stacks/jenkins/compose.yml | 20 ++++ stacks/keycloak/.env.example | 3 + stacks/keycloak/README.md | 7 ++ stacks/keycloak/compose.yml | 25 +++++ stacks/mqtt/.env.example | 2 + stacks/mqtt/README.md | 8 ++ stacks/mqtt/compose.yml | 24 +++++ stacks/nginx-proxy/.env.example | 2 + stacks/nginx-proxy/README.md | 12 +++ stacks/nginx-proxy/compose.yml | 26 ++++++ stacks/node-red/.env.example | 2 + stacks/node-red/README.md | 8 ++ stacks/node-red/compose.yml | 20 ++++ stacks/portainer/.env.example | 1 + stacks/portainer/README.md | 8 ++ stacks/portainer/compose.yml | 18 ++++ stacks/postfix/.env.example | 2 + stacks/postfix/README.md | 8 ++ stacks/postfix/compose.yml | 16 ++++ stacks/sql/.env.example | 3 + stacks/sql/README.md | 11 +++ stacks/sql/compose.yml | 22 +++++ stacks/wireguard-client/.env.example | 2 + stacks/wireguard-client/README.md | 9 ++ stacks/wireguard-client/compose.yml | 23 +++++ stacks/wireguard-server/.env.example | 3 + stacks/wireguard-server/README.md | 9 ++ stacks/wireguard-server/compose.yml | 34 +++++++ 46 files changed, 823 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 cloud/.env.example create mode 100644 cloud/README.md create mode 100644 cloud/compose.yml create mode 100644 docs/architecture.md create mode 100644 sites/README.md create mode 100644 stacks/gitea/.env.example create mode 100644 stacks/gitea/README.md create mode 100644 stacks/gitea/compose.yml create mode 100644 stacks/grafana/.env.example create mode 100644 stacks/grafana/README.md create mode 100644 stacks/grafana/compose.yml create mode 100644 stacks/influxdb/.env.example create mode 100644 stacks/influxdb/README.md create mode 100644 stacks/influxdb/compose.yml create mode 100644 stacks/jenkins/.env.example create mode 100644 stacks/jenkins/README.md create mode 100644 stacks/jenkins/compose.yml create mode 100644 stacks/keycloak/.env.example create mode 100644 stacks/keycloak/README.md create mode 100644 stacks/keycloak/compose.yml create mode 100644 stacks/mqtt/.env.example create mode 100644 stacks/mqtt/README.md create mode 100644 stacks/mqtt/compose.yml create mode 100644 stacks/nginx-proxy/.env.example create mode 100644 stacks/nginx-proxy/README.md create mode 100644 stacks/nginx-proxy/compose.yml create mode 100644 stacks/node-red/.env.example create mode 100644 stacks/node-red/README.md create mode 100644 stacks/node-red/compose.yml create mode 100644 stacks/portainer/.env.example create mode 100644 stacks/portainer/README.md create mode 100644 stacks/portainer/compose.yml create mode 100644 stacks/postfix/.env.example create mode 100644 stacks/postfix/README.md create mode 100644 stacks/postfix/compose.yml create mode 100644 stacks/sql/.env.example create mode 100644 stacks/sql/README.md create mode 100644 stacks/sql/compose.yml create mode 100644 stacks/wireguard-client/.env.example create mode 100644 stacks/wireguard-client/README.md create mode 100644 stacks/wireguard-client/compose.yml create mode 100644 stacks/wireguard-server/.env.example create mode 100644 stacks/wireguard-server/README.md create mode 100644 stacks/wireguard-server/compose.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..83580fb --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Secrets and per-deploy env +.env +.env.local +*.env.local + +# Local volume mounts / runtime data +data/ +volumes/ + +# Editor + OS noise +.DS_Store +Thumbs.db +*.swp +*~ +.vscode/ +.idea/ + +# Logs +*.log + +# Compose runtime overrides (developer-local) +compose.override.yml +docker-compose.override.yml diff --git a/README.md b/README.md new file mode 100644 index 0000000..9daedde --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# infra + +R&D infrastructure stacks for Waterschap Brabantse Delta. Hub-and-spoke deployment: one **cloud** central hub + per-plant **edge** sites. + +## Layout + +``` +infra/ +├── stacks/ # reusable, runnable stack defs (kebab-case) +├── cloud/ # the single central hub +├── sites/ # per-plant edge deployments +└── docs/ # architecture + conventions +``` + +Stacks are pulled into the cloud and site composes via the Compose Spec `include:` directive. Each stack is also runnable standalone for testing. + +## Quick start + +```bash +# Cloud hub (run on the central server) +cd cloud +cp .env.example .env # fill in real secrets +docker compose up -d + +# A plant edge (run on the edge gateway at the plant) +cd sites/ +cp .env.example .env +docker compose up -d +``` + +## Stacks + +| Stack | Purpose | Cloud | Edge | +|---|---|:---:|:---:| +| node-red | Flow-based automation | ✓ | ✓ | +| influxdb | Time-series database | ✓ | ✓ | +| grafana | Dashboards / SCADA | ✓ | ✓ | +| keycloak | Identity / SSO | ✓ | ✓ | +| portainer | Container management UI | ✓ | ✓ | +| nginx-proxy | HTTPS + MQTT-TLS reverse proxy | ✓ | ✓ | +| mqtt | MQTT broker + GUI | ✓ | ✓ | +| postfix | Outbound mail relay | ✓ | ✓ | +| wireguard-server | VPN server | ✓ | — | +| wireguard-client | VPN client | — | ✓ | +| gitea | Git server | ✓ | — | +| jenkins | CI/CD | ✓ | — | +| sql | Config DB (single point of config) | ✓ | — | + +## Design + +See [`docs/architecture.md`](docs/architecture.md) for the hub-and-spoke topology, 4-network model, ingress table, and the reasoning behind each choice. + +## Conventions + +- kebab-case folder names +- `compose.yml` (Compose Spec), not `docker-compose.yml` +- Stack composes are pulled into cloud/site via `include:` +- Secrets in `.env` files (gitignored); `.env.example` committed with placeholders +- OT layer (OPCUA, PLCs) is **out of scope** for this repo diff --git a/cloud/.env.example b/cloud/.env.example new file mode 100644 index 0000000..c34a044 --- /dev/null +++ b/cloud/.env.example @@ -0,0 +1,43 @@ +# Aggregated env for the cloud composition. +# Copy to .env and fill in real values. Never commit .env. + +TZ=Europe/Amsterdam + +# Domain / TLS +PRIMARY_DOMAIN= +LETSENCRYPT_EMAIL= + +# WireGuard server +WG_SERVER_PORT=51820 +WG_SERVER_PUBLIC_HOST= + +# Keycloak (admin bootstrap) +KEYCLOAK_ADMIN=admin +KEYCLOAK_ADMIN_PASSWORD= + +# InfluxDB +INFLUX_ADMIN_USER=admin +INFLUX_ADMIN_PASSWORD= +INFLUX_ADMIN_TOKEN= +INFLUX_ORG=wbd +INFLUX_BUCKET=telemetry + +# Grafana +GRAFANA_ADMIN_USER=admin +GRAFANA_ADMIN_PASSWORD= + +# SQL (single point of config) +SQL_DB=config +SQL_USER=config +SQL_PASSWORD= + +# Postfix +POSTFIX_RELAYHOST= +POSTFIX_FROM_DOMAIN= + +# Gitea +GITEA_ROOT_URL= + +# Jenkins +JENKINS_ADMIN_USER=admin +JENKINS_ADMIN_PASSWORD= diff --git a/cloud/README.md b/cloud/README.md new file mode 100644 index 0000000..9dc2a82 --- /dev/null +++ b/cloud/README.md @@ -0,0 +1,34 @@ +# cloud + +The single central hub. One deployment, internet-facing. + +## What runs here + +nginx-proxy, wireguard-server, keycloak, portainer, influxdb, grafana, node-red, mqtt, postfix, gitea, jenkins, sql. + +See [`../docs/architecture.md`](../docs/architecture.md) for the full network topology and ingress table. + +## Run + +```bash +cp .env.example .env # fill in real secrets first +docker compose up -d +docker compose ps +``` + +## Ingress (host port bindings) + +| Port | Container | +|---|---| +| tcp/80, 443 | nginx-proxy | +| tcp/8883 | nginx-proxy (MQTT-TLS via stream block) | +| udp/51820 | wireguard-server | + +Everything else stays on the internal `app` / `data` / `mgmt` networks. + +## Adding a new stack + +1. Create `stacks//` with `compose.yml`, `.env.example`, `README.md`. +2. Uncomment (or add) the `include:` entry in `compose.yml`. +3. Add the stack's env vars to `.env.example`. +4. `docker compose pull && docker compose up -d`. diff --git a/cloud/compose.yml b/cloud/compose.yml new file mode 100644 index 0000000..7fb1f8a --- /dev/null +++ b/cloud/compose.yml @@ -0,0 +1,35 @@ +# Cloud / Central layer composition. +# Includes all cloud-relevant stacks and defines the 4-network topology. +# Run: cp .env.example .env && docker compose up -d + +name: cloud + +# Uncomment includes as each stack is scaffolded with real services. +include: + # - ../stacks/nginx-proxy/compose.yml + # - ../stacks/wireguard-server/compose.yml + # - ../stacks/keycloak/compose.yml + # - ../stacks/portainer/compose.yml + # - ../stacks/influxdb/compose.yml + # - ../stacks/grafana/compose.yml + # - ../stacks/node-red/compose.yml + # - ../stacks/mqtt/compose.yml + # - ../stacks/postfix/compose.yml + # - ../stacks/gitea/compose.yml + # - ../stacks/jenkins/compose.yml + # - ../stacks/sql/compose.yml + +networks: + edge: + name: cloud-edge + driver: bridge + app: + name: cloud-app + driver: bridge + data: + name: cloud-data + driver: bridge + internal: true # databases — no internet egress + mgmt: + name: cloud-mgmt + driver: bridge diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..2e2654e --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,135 @@ +# Architecture + +R&D infrastructure for Waterschap Brabantse Delta. Hub-and-spoke topology: + +- **Cloud** layer — central services, one deployment, internet-facing. +- **Edge** layer — per-plant, plant-LAN-facing, tunneled to cloud via WireGuard. +- **OT** layer — per-plant, behind edge. Managed **outside** this repo. + +``` + Internet + │ + ┌───────────┴───────────┐ + │ tcp/80, 443, 8883 │ + │ udp/51820 │ + ▼ │ +┌───────────────────────────────────┐ +│ Cloud (central, one) │ +│ nginx-proxy ◀── 80/443/8883 │ +│ wireguard-server ◀── 51820/udp │ +│ gitea, jenkins, keycloak, ... │ +│ influxdb, grafana, node-red │ +│ mqtt, postfix, portainer │ +│ sql (single point of config) │ +└───────────────┬───────────────────┘ + │ WireGuard tunnels + ┌───────┼────────┬───────────┐ + ▼ ▼ ▼ ▼ + ┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐ + │ Edge: │ │ Edge: │ │ Edge: │ │ ... │ + │plant1 │ │plant2 │ │plant3 │ │ │ + └───┬───┘ └───────┘ └───────┘ └───────┘ + │ TLS + ▼ + ┌──────────┐ + │ OT │ ← out of scope of this repo + │ OPCUA │ + │ PLC │ + └──────────┘ +``` + +## Network topology (per layer) + +Each layer uses **four internal Docker networks**: + +| Network | Purpose | Notes | +|---|---|---| +| `edge` | Outermost. Cloud: internet-facing. Edge: plant-LAN-facing. | Only port-publishers join. | +| `app` | Application / automation tier (node-red, grafana, jenkins, gitea, …). | Default landing for app services. | +| `data` | Databases (influxdb, sql). | `internal: true` — no internet egress. | +| `mgmt` | Identity, control plane (portainer, keycloak admin, wireguard mgmt). | Restricted. | + +### Cloud attachments + +``` +edge : nginx-proxy, wireguard-server +app : nginx-proxy, mqtt, postfix, node-red, grafana, + jenkins, gitea, keycloak +data : influxdb, sql, grafana +mgmt : portainer, keycloak, wireguard-server +``` + +### Edge attachments + +``` +edge : nginx-proxy ← plant-LAN-facing +app : nginx-proxy, mqtt, postfix, node-red, + grafana, keycloak, wireguard-client +data : influxdb, grafana +mgmt : portainer, keycloak, wireguard-client +``` + +## Ingress (the only ports facing outside) + +### Cloud (the central host) + +| Port | Container | Notes | +|---|---|---| +| `tcp/80` | nginx-proxy | HTTP → 301 to 443 | +| `tcp/443` | nginx-proxy | All HTTPS UIs; TLS termination | +| `tcp/8883` | nginx-proxy | MQTT-TLS via `stream {}` block, SNI route to broker | +| `udp/51820` | wireguard-server | VPN tunnel ingress | + +Two containers publish a total of four ports. **Everything else is invisible** from outside the host. + +### Edge (per-plant gateway) + +| Port | Container | Bound to | +|---|---|---| +| `tcp/80` | nginx-proxy | Plant-LAN interface only | +| `tcp/443` | nginx-proxy | Plant-LAN interface only | + +The edge `wireguard-client` initiates outbound to the cloud — it publishes **no port**. On-site operators reach SCADA on the plant LAN; remote ops reach the same nginx via the WG tunnel. + +## Why segment + +- **Blast radius**: a compromised node-red on `app` cannot reach influxdb on `data` unless an explicit attachment is declared. Each service's reachability is auditable from `networks:` alone. +- **Defense in depth**: only nginx-proxy and wireguard-server bind host ports. No accidental `0.0.0.0` exposures. +- **NIS2 / utility audit**: WBD is in scope as water-sector. Compose networks are a cheap way to evidence segmentation at runtime and on paper. + +## Special cases + +### Postfix (cloud + edge) + +The diagram labels it "OUT ONLY". Postfix initiates outbound SMTP to internet MX servers but accepts **no inbound** mail. So Postfix has zero ingress, no published port, no listener facing the internet. It just needs egress (which every container has via host NAT). + +### MQTT (cloud) + +nginx-proxy `stream {}` block reverse-proxies `tcp/8883` to the internal broker via SNI. The broker has **no published port**. Cleanest "everything through nginx" model. + +### MQTT (edge) + +Broker is **fully internal** to the edge stack — no plant-LAN ingress. Node-RED on edge bridges OPCUA → broker, then broker → cloud broker over the WG tunnel. Field devices that need MQTT publish to the cloud broker via WG, not to the edge broker directly. + +### WireGuard server (cloud) + +WireGuard is connectionless UDP with crypto-routed packets. It cannot be sensibly reverse-proxied (NAT/MTU break, no security benefit). The server publishes `udp/51820` directly — the **only** non-nginx public ingress on cloud. + +## Conventions + +- **Folder names**: kebab-case (`node-red`, `nginx-proxy`, `wireguard-server`). +- **Compose filename**: `compose.yml` (official Compose Spec). +- **Composition**: cloud/site composes pull stacks via `include:`. Stacks remain runnable standalone for testing. +- **Secrets**: `.env` (gitignored) + `.env.example` (committed with placeholders). +- **Per-stack contents**: `compose.yml`, `.env.example`, `README.md`, optional `config/`. +- **OT layer**: out of scope; PLC + OPCUA managed in a separate process. + +## Open decisions + +These are deferred until we build the respective stack. Tracked here so we don't forget. + +- **SQL flavor**: postgres / mariadb / mysql? Leaning postgres for the "single point of config" use case. +- **SSL strategy**: certbot inside nginx-proxy, acme-companion sidecar, or step-ca-driven internal PKI? Probably acme-companion against Let's Encrypt for external endpoints + internal PKI for service-to-service. +- **Keycloak storage**: bundled H2 (dev only) vs external SQL backend (probably the same `sql` stack). +- **Backup strategy** for `data` (influxdb, sql) and `mgmt` (gitea, jenkins workspaces). +- **First site**: which plant gets `sites//` scaffolded first? diff --git a/sites/README.md b/sites/README.md new file mode 100644 index 0000000..5039692 --- /dev/null +++ b/sites/README.md @@ -0,0 +1,44 @@ +# sites + +Per-plant edge deployments. One folder per physical site. + +## Convention + +``` +sites// +├── compose.yml # include: ../../stacks/... +├── .env.example # committed; copy to .env locally +└── README.md # site-specific notes (IPs, LAN ranges, ops contact) +``` + +The folder name is **kebab-case** and matches the plant's short name (e.g. `nieuwveer`, `bath`, `waalwijk`). + +## What runs at an edge + +nginx-proxy, wireguard-client, keycloak, portainer, influxdb, grafana, node-red, mqtt, postfix. Cloud-only services (gitea, jenkins, sql, wireguard-server) are not deployed at edges. + +## Networks (mirrors cloud, plant-LAN-facing) + +| Network | Notes | +|---|---| +| `edge` | nginx-proxy, bound to the plant-LAN interface only | +| `app` | nginx-proxy, mqtt, postfix, node-red, grafana, keycloak, wireguard-client | +| `data` | influxdb, grafana (`internal: true`) | +| `mgmt` | portainer, keycloak, wireguard-client | + +## Ingress at edge + +| Port | Container | Bound to | +|---|---|---| +| tcp/80, 443 | nginx-proxy | plant-LAN interface only | + +The wireguard-client publishes nothing — it dials out to the cloud server. + +## Creating a new site + +1. `cp -r sites/` (or scaffold by hand). +2. Edit `compose.yml` for any site-specific overrides. +3. Edit `.env.example` and copy to `.env` with real values. +4. Deploy: `cd sites/ && docker compose up -d`. + +See [`../docs/architecture.md`](../docs/architecture.md) for the full design rationale. diff --git a/stacks/gitea/.env.example b/stacks/gitea/.env.example new file mode 100644 index 0000000..e32641c --- /dev/null +++ b/stacks/gitea/.env.example @@ -0,0 +1,6 @@ +GITEA_ROOT_URL= +GITEA_DB_TYPE=postgres +GITEA_DB_HOST=sql:5432 +GITEA_DB_NAME=gitea +GITEA_DB_USER=gitea +GITEA_DB_PASSWORD= diff --git a/stacks/gitea/README.md b/stacks/gitea/README.md new file mode 100644 index 0000000..2e73572 --- /dev/null +++ b/stacks/gitea/README.md @@ -0,0 +1,7 @@ +# gitea + +Self-hosted git server. **Cloud-only stack.** (External repos live at `gitea.wbd-rd.nl`; this is for R&D internal use.) + +- **Networks**: `app` (UI) + `data` (DB backend in `sql` stack) +- **Volume**: `gitea-data` (repos + LFS + actions runners) +- **TODO**: SSH access strategy (nginx stream proxy port 22, or skip and use HTTPS-only), Keycloak OIDC, runners for Gitea Actions diff --git a/stacks/gitea/compose.yml b/stacks/gitea/compose.yml new file mode 100644 index 0000000..7b72b65 --- /dev/null +++ b/stacks/gitea/compose.yml @@ -0,0 +1,28 @@ +# gitea — git server (cloud only) +# Networks: app + data (uses sql stack as DB backend) + +services: + gitea: + image: gitea/gitea:1.22 + restart: unless-stopped + networks: [app, data] + environment: + USER_UID: "1000" + USER_GID: "1000" + GITEA__server__ROOT_URL: ${GITEA_ROOT_URL} + GITEA__database__DB_TYPE: ${GITEA_DB_TYPE:-postgres} + GITEA__database__HOST: ${GITEA_DB_HOST:-sql:5432} + GITEA__database__NAME: ${GITEA_DB_NAME:-gitea} + GITEA__database__USER: ${GITEA_DB_USER} + GITEA__database__PASSWD: ${GITEA_DB_PASSWORD} + TZ: ${TZ:-Europe/Amsterdam} + volumes: + - gitea-data:/data + # SSH port: TODO — decide if Gitea SSH (22/2222) is exposed via nginx stream or skipped + +networks: + app: + data: + +volumes: + gitea-data: diff --git a/stacks/grafana/.env.example b/stacks/grafana/.env.example new file mode 100644 index 0000000..f49bb30 --- /dev/null +++ b/stacks/grafana/.env.example @@ -0,0 +1,3 @@ +GRAFANA_ADMIN_USER=admin +GRAFANA_ADMIN_PASSWORD= +GRAFANA_ROOT_URL= diff --git a/stacks/grafana/README.md b/stacks/grafana/README.md new file mode 100644 index 0000000..11b0b8b --- /dev/null +++ b/stacks/grafana/README.md @@ -0,0 +1,8 @@ +# grafana + +Dashboard UI. Cloud-side = central observability. Edge-side = plant-local SCADA. + +- **Networks**: `app` (reachable from nginx-proxy) + `data` (queries influxdb) +- **Volume**: `grafana-data` (sqlite, plugins, sessions) +- **Config**: `./config/provisioning` (datasources + dashboards as code) — add once SQL/Influx are wired +- **TODO**: Keycloak OIDC, datasource provisioning, dashboard-as-code baseline diff --git a/stacks/grafana/compose.yml b/stacks/grafana/compose.yml new file mode 100644 index 0000000..33d8824 --- /dev/null +++ b/stacks/grafana/compose.yml @@ -0,0 +1,23 @@ +# grafana — dashboards / SCADA UI +# Networks: app (for nginx reverse-proxy) + data (for influxdb queries) + +services: + grafana: + image: grafana/grafana-oss:11.3.0 + restart: unless-stopped + networks: [app, data] + volumes: + - grafana-data:/var/lib/grafana + - ./config/provisioning:/etc/grafana/provisioning:ro + environment: + GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER} + GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD} + GF_SERVER_ROOT_URL: ${GRAFANA_ROOT_URL:-} + # TODO: Keycloak OIDC auth, datasource provisioning, plugin pins + +networks: + app: + data: + +volumes: + grafana-data: diff --git a/stacks/influxdb/.env.example b/stacks/influxdb/.env.example new file mode 100644 index 0000000..075198b --- /dev/null +++ b/stacks/influxdb/.env.example @@ -0,0 +1,5 @@ +INFLUX_ADMIN_USER=admin +INFLUX_ADMIN_PASSWORD= +INFLUX_ADMIN_TOKEN= +INFLUX_ORG=wbd +INFLUX_BUCKET=telemetry diff --git a/stacks/influxdb/README.md b/stacks/influxdb/README.md new file mode 100644 index 0000000..386691d --- /dev/null +++ b/stacks/influxdb/README.md @@ -0,0 +1,8 @@ +# influxdb + +Time-series database for telemetry. Central at cloud, local at each edge. + +- **Network**: `data` only (no internet egress) +- **Reached by**: grafana, node-red (they bridge `app` + `data`) +- **Volumes**: `influxdb-data` (TSM files), `influxdb-config` (config + auth) +- **TODO**: retention/downsampling policy, backup strategy, scrape from cloud central Influx for plant-summary roll-ups diff --git a/stacks/influxdb/compose.yml b/stacks/influxdb/compose.yml new file mode 100644 index 0000000..ec16726 --- /dev/null +++ b/stacks/influxdb/compose.yml @@ -0,0 +1,25 @@ +# influxdb — time-series database +# Networks: data (no internet egress) + +services: + influxdb: + image: influxdb:2.7 + restart: unless-stopped + networks: [data] + volumes: + - influxdb-data:/var/lib/influxdb2 + - influxdb-config:/etc/influxdb2 + environment: + DOCKER_INFLUXDB_INIT_MODE: setup + DOCKER_INFLUXDB_INIT_USERNAME: ${INFLUX_ADMIN_USER} + DOCKER_INFLUXDB_INIT_PASSWORD: ${INFLUX_ADMIN_PASSWORD} + DOCKER_INFLUXDB_INIT_ORG: ${INFLUX_ORG} + DOCKER_INFLUXDB_INIT_BUCKET: ${INFLUX_BUCKET} + DOCKER_INFLUXDB_INIT_ADMIN_TOKEN: ${INFLUX_ADMIN_TOKEN} + +networks: + data: + +volumes: + influxdb-data: + influxdb-config: diff --git a/stacks/jenkins/.env.example b/stacks/jenkins/.env.example new file mode 100644 index 0000000..3bec905 --- /dev/null +++ b/stacks/jenkins/.env.example @@ -0,0 +1,2 @@ +JENKINS_ADMIN_USER=admin +JENKINS_ADMIN_PASSWORD= diff --git a/stacks/jenkins/README.md b/stacks/jenkins/README.md new file mode 100644 index 0000000..5b4e2b7 --- /dev/null +++ b/stacks/jenkins/README.md @@ -0,0 +1,7 @@ +# jenkins + +CI/CD for EVOLV + R&D pipelines. **Cloud-only stack.** + +- **Network**: `app` (UI proxied via nginx) +- **Volume**: `jenkins-home` (config + job state + plugins) +- **TODO**: configuration-as-code (jcasc), Keycloak OIDC, agent strategy (DinD vs SSH vs K8s), pipeline shared libraries diff --git a/stacks/jenkins/compose.yml b/stacks/jenkins/compose.yml new file mode 100644 index 0000000..fb8caa4 --- /dev/null +++ b/stacks/jenkins/compose.yml @@ -0,0 +1,20 @@ +# jenkins — CI/CD (cloud only) +# Networks: app + +services: + jenkins: + image: jenkins/jenkins:lts-jdk17 + restart: unless-stopped + networks: [app] + environment: + TZ: ${TZ:-Europe/Amsterdam} + JENKINS_OPTS: --httpPort=8080 + volumes: + - jenkins-home:/var/jenkins_home + # TODO: agent strategy (docker-in-docker vs ssh agents vs k8s agents) + +networks: + app: + +volumes: + jenkins-home: diff --git a/stacks/keycloak/.env.example b/stacks/keycloak/.env.example new file mode 100644 index 0000000..446636e --- /dev/null +++ b/stacks/keycloak/.env.example @@ -0,0 +1,3 @@ +KEYCLOAK_ADMIN=admin +KEYCLOAK_ADMIN_PASSWORD= +KEYCLOAK_HOSTNAME= diff --git a/stacks/keycloak/README.md b/stacks/keycloak/README.md new file mode 100644 index 0000000..dfda128 --- /dev/null +++ b/stacks/keycloak/README.md @@ -0,0 +1,7 @@ +# keycloak + +Identity provider for SSO across grafana, node-red, gitea, jenkins, portainer. + +- **Networks**: `app` (OIDC endpoints for relying apps) + `mgmt` (admin console) +- **Storage**: stub uses bundled file storage; move to `sql` stack before production +- **TODO**: realm + client provisioning (kc.sh or terraform-keycloak), session/token lifetimes, theme branding diff --git a/stacks/keycloak/compose.yml b/stacks/keycloak/compose.yml new file mode 100644 index 0000000..9b30e3c --- /dev/null +++ b/stacks/keycloak/compose.yml @@ -0,0 +1,25 @@ +# keycloak — identity / SSO +# Networks: app (apps reach the realm endpoints) + mgmt (admin console) + +services: + keycloak: + image: quay.io/keycloak/keycloak:26.0 + restart: unless-stopped + command: ["start", "--optimized"] + networks: [app, mgmt] + environment: + KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN} + KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD} + KC_HOSTNAME: ${KEYCLOAK_HOSTNAME:-} + KC_PROXY_HEADERS: xforwarded + KC_HTTP_ENABLED: "true" + # TODO: external DB (KC_DB=postgres) once sql stack lands + volumes: + - keycloak-data:/opt/keycloak/data + +networks: + app: + mgmt: + +volumes: + keycloak-data: diff --git a/stacks/mqtt/.env.example b/stacks/mqtt/.env.example new file mode 100644 index 0000000..6f40ef4 --- /dev/null +++ b/stacks/mqtt/.env.example @@ -0,0 +1,2 @@ +# mqtt — broker uses config file, not env +# GUI vars land here once a GUI image is chosen diff --git a/stacks/mqtt/README.md b/stacks/mqtt/README.md new file mode 100644 index 0000000..96ff820 --- /dev/null +++ b/stacks/mqtt/README.md @@ -0,0 +1,8 @@ +# mqtt + +Eclipse Mosquitto broker. Cloud-side accepts external connections via nginx stream proxy. Edge-side is fully internal. + +- **Network**: `app` (no published port — nginx-proxy fronts external traffic on cloud) +- **Edge note**: on edge stacks, broker stays purely internal; node-red bridges OPCUA → broker → cloud broker over WG +- **Config**: `config/mosquitto.conf` — listener config, ACLs, persistence +- **TODO**: ACL policy, bridge config to cloud broker, GUI choice (mqtt-explorer / hivemq web / custom) diff --git a/stacks/mqtt/compose.yml b/stacks/mqtt/compose.yml new file mode 100644 index 0000000..063e36f --- /dev/null +++ b/stacks/mqtt/compose.yml @@ -0,0 +1,24 @@ +# mqtt — MQTT broker (Eclipse Mosquitto) + optional GUI +# Networks: app (no port published — reached via nginx stream proxy on 8883) + +services: + mqtt-broker: + image: eclipse-mosquitto:2.0 + restart: unless-stopped + networks: [app] + volumes: + - ./config/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro + - mqtt-data:/mosquitto/data + - mqtt-log:/mosquitto/log + # No 'ports:' — nginx-proxy stream-proxies external 8883 to internal 1883/8883 + + # mqtt-gui: # TODO: choose a GUI image (mqtt-explorer? hivemq web client? custom) + # image: ... + # networks: [app] + +networks: + app: + +volumes: + mqtt-data: + mqtt-log: diff --git a/stacks/nginx-proxy/.env.example b/stacks/nginx-proxy/.env.example new file mode 100644 index 0000000..6ea62c6 --- /dev/null +++ b/stacks/nginx-proxy/.env.example @@ -0,0 +1,2 @@ +# nginx-proxy — config-file-driven, no env vars in stub +# Domain + cert settings will land here once SSL strategy is chosen diff --git a/stacks/nginx-proxy/README.md b/stacks/nginx-proxy/README.md new file mode 100644 index 0000000..2a00b15 --- /dev/null +++ b/stacks/nginx-proxy/README.md @@ -0,0 +1,12 @@ +# nginx-proxy + +The single web ingress. Reverse-proxies HTTPS UIs and stream-proxies MQTT-TLS. + +- **Networks**: `edge` (the only port-publisher) + `app` (talks to upstream services) +- **Host ports**: `tcp/80`, `tcp/443`, `tcp/8883` +- **Config**: + - `config/nginx.conf` — base + - `config/conf.d/*.conf` — HTTP vhosts (one per upstream UI) + - `config/stream.d/mqtt.conf` — MQTT-TLS stream block, SNI route to mqtt broker + - `config/certs/` — TLS certs (volume-mounted from cert manager) +- **TODO**: pick SSL strategy (acme-companion sidecar vs certbot vs internal PKI), write vhost templates per upstream diff --git a/stacks/nginx-proxy/compose.yml b/stacks/nginx-proxy/compose.yml new file mode 100644 index 0000000..671c29b --- /dev/null +++ b/stacks/nginx-proxy/compose.yml @@ -0,0 +1,26 @@ +# nginx-proxy — TLS reverse proxy (HTTPS + MQTT-TLS) +# Networks: edge (port publisher) + app (proxy targets) +# Publishes: 80, 443, 8883 on the host + +services: + nginx-proxy: + image: nginx:1.27-alpine + restart: unless-stopped + networks: [edge, app] + ports: + - "80:80" + - "443:443" + - "8883:8883" # MQTT-TLS via stream{} block + volumes: + - ./config/conf.d:/etc/nginx/conf.d:ro + - ./config/stream.d:/etc/nginx/stream.d:ro + - ./config/nginx.conf:/etc/nginx/nginx.conf:ro + - nginx-certs:/etc/nginx/certs:ro + # TODO: SSL strategy (acme-companion sidecar vs certbot vs internal PKI) + +networks: + edge: + app: + +volumes: + nginx-certs: diff --git a/stacks/node-red/.env.example b/stacks/node-red/.env.example new file mode 100644 index 0000000..3959bfd --- /dev/null +++ b/stacks/node-red/.env.example @@ -0,0 +1,2 @@ +# node-red — no special env beyond TZ for the stub +TZ=Europe/Amsterdam diff --git a/stacks/node-red/README.md b/stacks/node-red/README.md new file mode 100644 index 0000000..288fa7b --- /dev/null +++ b/stacks/node-red/README.md @@ -0,0 +1,8 @@ +# node-red + +Node-RED flow editor + runtime. Used at both cloud and edge. + +- **UI**: internal port 1880 → reverse-proxied at `/node-red` (or subdomain) +- **Networks**: `app` +- **Volumes**: `node-red-data` (flows + credentials) +- **TODO**: Keycloak OIDC integration, preinstalled module list, EVOLV nodes installation diff --git a/stacks/node-red/compose.yml b/stacks/node-red/compose.yml new file mode 100644 index 0000000..952e1cb --- /dev/null +++ b/stacks/node-red/compose.yml @@ -0,0 +1,20 @@ +# node-red — flow-based automation runtime + editor +# Networks: app +# UI: internal port 1880 → reverse-proxied through nginx-proxy + +services: + node-red: + image: nodered/node-red:4 + restart: unless-stopped + networks: [app] + volumes: + - node-red-data:/data + environment: + TZ: ${TZ:-Europe/Amsterdam} + # TODO: Keycloak OIDC adapter; preinstalled modules; CONTRIB allow-list + +networks: + app: + +volumes: + node-red-data: diff --git a/stacks/portainer/.env.example b/stacks/portainer/.env.example new file mode 100644 index 0000000..b9bb2ee --- /dev/null +++ b/stacks/portainer/.env.example @@ -0,0 +1 @@ +# portainer needs no env beyond defaults diff --git a/stacks/portainer/README.md b/stacks/portainer/README.md new file mode 100644 index 0000000..5a9bf63 --- /dev/null +++ b/stacks/portainer/README.md @@ -0,0 +1,8 @@ +# portainer + +Docker container management UI. Used at both cloud and edge. + +- **Network**: `mgmt` +- **Docker socket**: mounted read-only (`/var/run/docker.sock`) — effectively root-equivalent on the host. Restrict access via nginx-proxy + Keycloak. +- **Volume**: `portainer-data` +- **TODO**: edge-agent topology — each edge runs a portainer-agent that registers back to cloud-central portainer diff --git a/stacks/portainer/compose.yml b/stacks/portainer/compose.yml new file mode 100644 index 0000000..89917c8 --- /dev/null +++ b/stacks/portainer/compose.yml @@ -0,0 +1,18 @@ +# portainer — container management UI +# Networks: mgmt + +services: + portainer: + image: portainer/portainer-ce:2.21.4 + restart: unless-stopped + networks: [mgmt] + volumes: + - portainer-data:/data + - /var/run/docker.sock:/var/run/docker.sock:ro + # TODO: edge-agent on each site connected back to this central portainer + +networks: + mgmt: + +volumes: + portainer-data: diff --git a/stacks/postfix/.env.example b/stacks/postfix/.env.example new file mode 100644 index 0000000..8792ca6 --- /dev/null +++ b/stacks/postfix/.env.example @@ -0,0 +1,2 @@ +POSTFIX_RELAYHOST= +POSTFIX_FROM_DOMAIN= diff --git a/stacks/postfix/README.md b/stacks/postfix/README.md new file mode 100644 index 0000000..9d8544f --- /dev/null +++ b/stacks/postfix/README.md @@ -0,0 +1,8 @@ +# postfix + +**Outbound-only** SMTP relay. Used by grafana alerts, node-red flows, jenkins build notifications. + +- **Network**: `app` (internal services hit `smtp://postfix:25`) +- **Ingress**: none — no port published, no listener facing internet +- **Egress**: TCP/25 outbound to internet MX servers (or a smart-host relay) +- **TODO**: SPF/DKIM/DMARC for `${POSTFIX_FROM_DOMAIN}`, smart-host config if WBD provides a relay diff --git a/stacks/postfix/compose.yml b/stacks/postfix/compose.yml new file mode 100644 index 0000000..55608ef --- /dev/null +++ b/stacks/postfix/compose.yml @@ -0,0 +1,16 @@ +# postfix — outbound mail relay only (no inbound, no published port) +# Networks: app + +services: + postfix: + image: boky/postfix:4.2.0 + restart: unless-stopped + networks: [app] + environment: + RELAYHOST: ${POSTFIX_RELAYHOST} + ALLOWED_SENDER_DOMAINS: ${POSTFIX_FROM_DOMAIN} + TZ: ${TZ:-Europe/Amsterdam} + # No 'ports:' — outbound only. Internal services hit smtp://postfix:25 on the app network. + +networks: + app: diff --git a/stacks/sql/.env.example b/stacks/sql/.env.example new file mode 100644 index 0000000..8c31420 --- /dev/null +++ b/stacks/sql/.env.example @@ -0,0 +1,3 @@ +SQL_DB=config +SQL_USER=config +SQL_PASSWORD= diff --git a/stacks/sql/README.md b/stacks/sql/README.md new file mode 100644 index 0000000..3d23b6a --- /dev/null +++ b/stacks/sql/README.md @@ -0,0 +1,11 @@ +# sql + +Central configuration database — the "single point of config" backing Keycloak, Gitea, and other stacks that need a relational store. **Cloud-only stack.** + +- **Network**: `data` only (no internet egress) +- **Engine**: stub uses **postgres:16-alpine** pending decision (postgres vs mariadb vs mysql) +- **Volume**: `sql-data` +- **TODO**: + - confirm engine choice (likely postgres for keycloak + gitea compatibility) + - per-app database/role provisioning (init scripts in `config/init.d/`) + - backup strategy (pg_dump cron sidecar vs streaming replica) diff --git a/stacks/sql/compose.yml b/stacks/sql/compose.yml new file mode 100644 index 0000000..a1824f1 --- /dev/null +++ b/stacks/sql/compose.yml @@ -0,0 +1,22 @@ +# sql — single point of config DB (cloud only) +# Networks: data (no internet egress) +# TBD: postgres / mariadb / mysql — stub uses postgres pending decision + +services: + sql: + image: postgres:16-alpine + restart: unless-stopped + networks: [data] + environment: + POSTGRES_DB: ${SQL_DB} + POSTGRES_USER: ${SQL_USER} + POSTGRES_PASSWORD: ${SQL_PASSWORD} + TZ: ${TZ:-Europe/Amsterdam} + volumes: + - sql-data:/var/lib/postgresql/data + +networks: + data: + +volumes: + sql-data: diff --git a/stacks/wireguard-client/.env.example b/stacks/wireguard-client/.env.example new file mode 100644 index 0000000..85f66ac --- /dev/null +++ b/stacks/wireguard-client/.env.example @@ -0,0 +1,2 @@ +TZ=Europe/Amsterdam +# Per-site WireGuard private key + cloud peer config live in config/wg0.conf diff --git a/stacks/wireguard-client/README.md b/stacks/wireguard-client/README.md new file mode 100644 index 0000000..5ce878d --- /dev/null +++ b/stacks/wireguard-client/README.md @@ -0,0 +1,9 @@ +# wireguard-client + +VPN client running at each edge. **Edge-only stack.** + +- **Networks**: `app` + `mgmt` (so other edge containers can route through the tunnel) +- **No published port** — initiates outbound to the cloud `wireguard-server` on `udp/51820` +- **Config**: `config/wg0.conf` (per-site, contains the site's private key + cloud peer pubkey + AllowedIPs) +- **Routing**: edge containers reach cloud-side services by routing destined-for-cloud-subnet traffic via this client +- **TODO**: routing strategy (split-tunnel vs full), keepalive interval, MTU tuning per WAN type diff --git a/stacks/wireguard-client/compose.yml b/stacks/wireguard-client/compose.yml new file mode 100644 index 0000000..cc39f65 --- /dev/null +++ b/stacks/wireguard-client/compose.yml @@ -0,0 +1,23 @@ +# wireguard-client — VPN client (edge only) +# Networks: app + mgmt +# No published port — initiates outbound to cloud server + +services: + wireguard-client: + image: linuxserver/wireguard:latest + restart: unless-stopped + cap_add: [NET_ADMIN, SYS_MODULE] + sysctls: + - net.ipv4.conf.all.src_valid_mark=1 + networks: [app, mgmt] + environment: + PUID: "1000" + PGID: "1000" + TZ: ${TZ:-Europe/Amsterdam} + volumes: + - ./config/wg0.conf:/config/wg_confs/wg0.conf:ro + - /lib/modules:/lib/modules:ro + +networks: + app: + mgmt: diff --git a/stacks/wireguard-server/.env.example b/stacks/wireguard-server/.env.example new file mode 100644 index 0000000..0a75bb5 --- /dev/null +++ b/stacks/wireguard-server/.env.example @@ -0,0 +1,3 @@ +WG_SERVER_PORT=51820 +WG_SERVER_PUBLIC_HOST= +TZ=Europe/Amsterdam diff --git a/stacks/wireguard-server/README.md b/stacks/wireguard-server/README.md new file mode 100644 index 0000000..b1b354f --- /dev/null +++ b/stacks/wireguard-server/README.md @@ -0,0 +1,9 @@ +# wireguard-server + +VPN ingress for edges and remote ops. **Cloud-only stack.** + +- **Networks**: `edge` (the only non-nginx public ingress) + `mgmt` (admin access into tunnel) +- **Host port**: `udp/51820` +- **Why not behind nginx?** WireGuard is connectionless UDP with crypto-routed packets; proxying it through nginx-stream breaks NAT/MTU and adds no security benefit. It publishes its port directly. +- **Peers**: managed via `wg-server-config/peer_*` config files. Each edge gets one peer. +- **TODO**: peer onboarding workflow, AllowedIPs split-tunnel decisions per peer diff --git a/stacks/wireguard-server/compose.yml b/stacks/wireguard-server/compose.yml new file mode 100644 index 0000000..4495683 --- /dev/null +++ b/stacks/wireguard-server/compose.yml @@ -0,0 +1,34 @@ +# wireguard-server — VPN ingress (cloud only) +# Networks: edge (publishes UDP) + mgmt (admin reach into tunnel) +# Publishes: udp/51820 on the host (the only non-nginx public ingress) + +services: + wireguard-server: + image: linuxserver/wireguard:latest + restart: unless-stopped + cap_add: [NET_ADMIN, SYS_MODULE] + sysctls: + - net.ipv4.conf.all.src_valid_mark=1 + networks: [edge, mgmt] + ports: + - "${WG_SERVER_PORT:-51820}:51820/udp" + environment: + PUID: "1000" + PGID: "1000" + TZ: ${TZ:-Europe/Amsterdam} + SERVERURL: ${WG_SERVER_PUBLIC_HOST} + SERVERPORT: ${WG_SERVER_PORT:-51820} + PEERS: "0" # peers are managed manually as edges come online + PEERDNS: auto + INTERNAL_SUBNET: 10.13.13.0 + ALLOWEDIPS: 0.0.0.0/0 + volumes: + - wg-server-config:/config + - /lib/modules:/lib/modules:ro + +networks: + edge: + mgmt: + +volumes: + wg-server-config: