scaffold: hub-and-spoke layout, 4-network topology, 13 stack stubs
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>
This commit is contained in:
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
@@ -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
|
||||
59
README.md
Normal file
59
README.md
Normal file
@@ -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/<plant>
|
||||
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
|
||||
43
cloud/.env.example
Normal file
43
cloud/.env.example
Normal file
@@ -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=
|
||||
34
cloud/README.md
Normal file
34
cloud/README.md
Normal file
@@ -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/<name>/` 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`.
|
||||
35
cloud/compose.yml
Normal file
35
cloud/compose.yml
Normal file
@@ -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
|
||||
135
docs/architecture.md
Normal file
135
docs/architecture.md
Normal file
@@ -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/<plant>/` scaffolded first?
|
||||
44
sites/README.md
Normal file
44
sites/README.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# sites
|
||||
|
||||
Per-plant edge deployments. One folder per physical site.
|
||||
|
||||
## Convention
|
||||
|
||||
```
|
||||
sites/<plant>/
|
||||
├── 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 <existing-site> sites/<new-plant>` (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/<new-plant> && docker compose up -d`.
|
||||
|
||||
See [`../docs/architecture.md`](../docs/architecture.md) for the full design rationale.
|
||||
6
stacks/gitea/.env.example
Normal file
6
stacks/gitea/.env.example
Normal file
@@ -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=
|
||||
7
stacks/gitea/README.md
Normal file
7
stacks/gitea/README.md
Normal file
@@ -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
|
||||
28
stacks/gitea/compose.yml
Normal file
28
stacks/gitea/compose.yml
Normal file
@@ -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:
|
||||
3
stacks/grafana/.env.example
Normal file
3
stacks/grafana/.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
GRAFANA_ADMIN_USER=admin
|
||||
GRAFANA_ADMIN_PASSWORD=
|
||||
GRAFANA_ROOT_URL=
|
||||
8
stacks/grafana/README.md
Normal file
8
stacks/grafana/README.md
Normal file
@@ -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
|
||||
23
stacks/grafana/compose.yml
Normal file
23
stacks/grafana/compose.yml
Normal file
@@ -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:
|
||||
5
stacks/influxdb/.env.example
Normal file
5
stacks/influxdb/.env.example
Normal file
@@ -0,0 +1,5 @@
|
||||
INFLUX_ADMIN_USER=admin
|
||||
INFLUX_ADMIN_PASSWORD=
|
||||
INFLUX_ADMIN_TOKEN=
|
||||
INFLUX_ORG=wbd
|
||||
INFLUX_BUCKET=telemetry
|
||||
8
stacks/influxdb/README.md
Normal file
8
stacks/influxdb/README.md
Normal file
@@ -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
|
||||
25
stacks/influxdb/compose.yml
Normal file
25
stacks/influxdb/compose.yml
Normal file
@@ -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:
|
||||
2
stacks/jenkins/.env.example
Normal file
2
stacks/jenkins/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
JENKINS_ADMIN_USER=admin
|
||||
JENKINS_ADMIN_PASSWORD=
|
||||
7
stacks/jenkins/README.md
Normal file
7
stacks/jenkins/README.md
Normal file
@@ -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
|
||||
20
stacks/jenkins/compose.yml
Normal file
20
stacks/jenkins/compose.yml
Normal file
@@ -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:
|
||||
3
stacks/keycloak/.env.example
Normal file
3
stacks/keycloak/.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
KEYCLOAK_ADMIN=admin
|
||||
KEYCLOAK_ADMIN_PASSWORD=
|
||||
KEYCLOAK_HOSTNAME=
|
||||
7
stacks/keycloak/README.md
Normal file
7
stacks/keycloak/README.md
Normal file
@@ -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
|
||||
25
stacks/keycloak/compose.yml
Normal file
25
stacks/keycloak/compose.yml
Normal file
@@ -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:
|
||||
2
stacks/mqtt/.env.example
Normal file
2
stacks/mqtt/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
# mqtt — broker uses config file, not env
|
||||
# GUI vars land here once a GUI image is chosen
|
||||
8
stacks/mqtt/README.md
Normal file
8
stacks/mqtt/README.md
Normal file
@@ -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)
|
||||
24
stacks/mqtt/compose.yml
Normal file
24
stacks/mqtt/compose.yml
Normal file
@@ -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:
|
||||
2
stacks/nginx-proxy/.env.example
Normal file
2
stacks/nginx-proxy/.env.example
Normal file
@@ -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
|
||||
12
stacks/nginx-proxy/README.md
Normal file
12
stacks/nginx-proxy/README.md
Normal file
@@ -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
|
||||
26
stacks/nginx-proxy/compose.yml
Normal file
26
stacks/nginx-proxy/compose.yml
Normal file
@@ -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:
|
||||
2
stacks/node-red/.env.example
Normal file
2
stacks/node-red/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
# node-red — no special env beyond TZ for the stub
|
||||
TZ=Europe/Amsterdam
|
||||
8
stacks/node-red/README.md
Normal file
8
stacks/node-red/README.md
Normal file
@@ -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
|
||||
20
stacks/node-red/compose.yml
Normal file
20
stacks/node-red/compose.yml
Normal file
@@ -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:
|
||||
1
stacks/portainer/.env.example
Normal file
1
stacks/portainer/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
# portainer needs no env beyond defaults
|
||||
8
stacks/portainer/README.md
Normal file
8
stacks/portainer/README.md
Normal file
@@ -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
|
||||
18
stacks/portainer/compose.yml
Normal file
18
stacks/portainer/compose.yml
Normal file
@@ -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:
|
||||
2
stacks/postfix/.env.example
Normal file
2
stacks/postfix/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
POSTFIX_RELAYHOST=
|
||||
POSTFIX_FROM_DOMAIN=
|
||||
8
stacks/postfix/README.md
Normal file
8
stacks/postfix/README.md
Normal file
@@ -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
|
||||
16
stacks/postfix/compose.yml
Normal file
16
stacks/postfix/compose.yml
Normal file
@@ -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:
|
||||
3
stacks/sql/.env.example
Normal file
3
stacks/sql/.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
SQL_DB=config
|
||||
SQL_USER=config
|
||||
SQL_PASSWORD=
|
||||
11
stacks/sql/README.md
Normal file
11
stacks/sql/README.md
Normal file
@@ -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)
|
||||
22
stacks/sql/compose.yml
Normal file
22
stacks/sql/compose.yml
Normal file
@@ -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:
|
||||
2
stacks/wireguard-client/.env.example
Normal file
2
stacks/wireguard-client/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
TZ=Europe/Amsterdam
|
||||
# Per-site WireGuard private key + cloud peer config live in config/wg0.conf
|
||||
9
stacks/wireguard-client/README.md
Normal file
9
stacks/wireguard-client/README.md
Normal file
@@ -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
|
||||
23
stacks/wireguard-client/compose.yml
Normal file
23
stacks/wireguard-client/compose.yml
Normal file
@@ -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:
|
||||
3
stacks/wireguard-server/.env.example
Normal file
3
stacks/wireguard-server/.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
WG_SERVER_PORT=51820
|
||||
WG_SERVER_PUBLIC_HOST=
|
||||
TZ=Europe/Amsterdam
|
||||
9
stacks/wireguard-server/README.md
Normal file
9
stacks/wireguard-server/README.md
Normal file
@@ -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
|
||||
34
stacks/wireguard-server/compose.yml
Normal file
34
stacks/wireguard-server/compose.yml
Normal file
@@ -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:
|
||||
Reference in New Issue
Block a user