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:
znetsixe
2026-05-21 12:37:59 +02:00
commit 8ab9061983
46 changed files with 823 additions and 0 deletions

23
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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.

View 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
View 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
View 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:

View File

@@ -0,0 +1,3 @@
GRAFANA_ADMIN_USER=admin
GRAFANA_ADMIN_PASSWORD=
GRAFANA_ROOT_URL=

8
stacks/grafana/README.md Normal file
View 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

View 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:

View File

@@ -0,0 +1,5 @@
INFLUX_ADMIN_USER=admin
INFLUX_ADMIN_PASSWORD=
INFLUX_ADMIN_TOKEN=
INFLUX_ORG=wbd
INFLUX_BUCKET=telemetry

View 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

View 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:

View File

@@ -0,0 +1,2 @@
JENKINS_ADMIN_USER=admin
JENKINS_ADMIN_PASSWORD=

7
stacks/jenkins/README.md Normal file
View 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

View 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:

View File

@@ -0,0 +1,3 @@
KEYCLOAK_ADMIN=admin
KEYCLOAK_ADMIN_PASSWORD=
KEYCLOAK_HOSTNAME=

View 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

View 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
View 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
View 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
View 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:

View 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

View 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

View 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:

View File

@@ -0,0 +1,2 @@
# node-red — no special env beyond TZ for the stub
TZ=Europe/Amsterdam

View 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

View 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:

View File

@@ -0,0 +1 @@
# portainer needs no env beyond defaults

View 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

View 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:

View File

@@ -0,0 +1,2 @@
POSTFIX_RELAYHOST=
POSTFIX_FROM_DOMAIN=

8
stacks/postfix/README.md Normal file
View 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

View 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
View File

@@ -0,0 +1,3 @@
SQL_DB=config
SQL_USER=config
SQL_PASSWORD=

11
stacks/sql/README.md Normal file
View 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
View 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:

View File

@@ -0,0 +1,2 @@
TZ=Europe/Amsterdam
# Per-site WireGuard private key + cloud peer config live in config/wg0.conf

View 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

View 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:

View File

@@ -0,0 +1,3 @@
WG_SERVER_PORT=51820
WG_SERVER_PUBLIC_HOST=
TZ=Europe/Amsterdam

View 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

View 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: