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) <noreply@anthropic.com>
6.3 KiB
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
appcannot reach influxdb ondataunless an explicit attachment is declared. Each service's reachability is auditable fromnetworks:alone. - Defense in depth: only nginx-proxy and wireguard-server bind host ports. No accidental
0.0.0.0exposures. - 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, optionalconfig/. - 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
sqlstack). - Backup strategy for
data(influxdb, sql) andmgmt(gitea, jenkins workspaces). - First site: which plant gets
sites/<plant>/scaffolded first?