deployment.md / CLAUDE.md / env_vars.md: the per-app LDAP (scadabridge-ldap container, OtOpcUa DevStubMode, per-box C:\publish\glauth) is replaced by one shared zb-shared-glauth on 10.100.0.35:3893 (dc=zb,dc=local); source of truth infra/glauth/. Fixed stale baseDNs (dc=lmxopcua/dc=otopcua -> dc=zb).
21 KiB
Deployment & Environments — SCADA/OT family
How the sister projects are deployed: environments, hosts, SSH access, Docker/Traefik topology, databases, and the full service/port map. Compiled 2026-06-03 by reading the actual compose/Traefik/SSH files (not docs alone). For the per-service environment variables see the companion
env_vars.md.Source confidence: container/port/Traefik/DB facts below are read straight from the compose +
traefik/*.ymlfiles. SSH facts are from~/.ssh/config+~/.ssh/known_hosts. Where a fact is referenced in repo docs but not pinned in a config/script on this machine, it's marked (referenced, not scripted in-repo) — don't treat those as automated.
1. Environment inventory
| Environment | Where it runs | What it is | Entry point |
|---|---|---|---|
ScadaBridge docker |
This Mac (Docker Desktop/OrbStack) | Full hub-and-spoke: 2 Central + 3 sites ×2 nodes | http://localhost:9000 |
ScadaBridge docker-env2 |
This Mac | Second isolated cluster: 2 Central + 1 site ×2 nodes | http://localhost:9100 |
ScadaBridge infra |
This Mac | Shared backing services (MSSQL, OPC-UA sims, SMTP, REST, Playwright) — not LDAP (see shared GLAuth below) | n/a (deps) |
OtOpcUa otopcua-dev |
This Mac | 3 independent Akka clusters (MAIN + SITE-A + SITE-B) sharing one ConfigDb | http://localhost:9200 |
| MxAccessGateway | windev (10.100.0.48), Windows |
Windows-native gRPC gateway + per-session x86 worker (no Docker) | http://10.100.0.48:5120 (gRPC) |
| Production (VD03) | wonder-app-vd03.zmr.zimmer.com |
Single-node ScadaBridge + MxGateway prod host | see docs/operations/ runbooks |
The three ScadaBridge stacks share one external Docker network scadabridge-net; the
OtOpcUa otopcua-dev stack runs on its own default network (otopcua-dev_default) and is
network-isolated from ScadaBridge. All local stacks can run simultaneously — host ports do not
collide (see §7).
On this Apple-Silicon Mac, MSSQL runs under amd64 emulation (slow first-ready; the "platform does not match" warning is expected/benign). See scadabridge-local-deploy-gotchas.
2. Hosts & SSH connectivity
2.1 Host inventory
| Host | Address | OS | Role | SSH port |
|---|---|---|---|---|
| This Mac | local | macOS (darwin) | Dev workstation — runs all local Docker stacks | n/a |
| windev | 10.100.0.48 |
Windows | OtOpcUa Windows-service host + MxAccessGateway (gRPC 5120 / dashboard 5130) |
22 |
| fixture host | 10.100.0.35 |
Debian/Linux + Docker | OtOpcUa driver integration-test fixtures + a test SQL Server | 22 |
| VD03 (prod) | wonder-app-vd03.zmr.zimmer.com |
Windows | Production single-node ScadaBridge + MxGateway | 2222 |
| gitea | gitea.dohertylan.com (10.100.0.228) |
Linux | Git remotes + NuGet feed (/api/packages/dohertj2/nuget) |
22 |
All are on the private 10.x lab network — a LAN/VPN connection is required.
2.2 How to connect (passwordless SSH)
Auth is key-based (passwordless) with ~/.ssh/id_ed25519 (a legacy ~/.ssh/id_rsa exists
as fallback). Only one host alias is defined in ~/.ssh/config:
# ~/.ssh/config (verified)
Include ~/.orbstack/ssh/config # OrbStack local Linux VMs — use `ssh orb` / `orb` CLI
Host windev
HostName 10.100.0.48
User dohertj2
IdentityFile ~/.ssh/id_ed25519
# Port 22 (default)
| Target | Command | Notes |
|---|---|---|
| windev (Win host) | ssh windev |
Configured alias; user dohertj2, key id_ed25519, port 22 |
| fixture host | ssh dohertj2@10.100.0.35 |
In known_hosts; no config alias — pass user explicitly; port 22, key-based |
| VD03 (prod) | ssh dohertj2@wonder-app-vd03.zmr.zimmer.com -p 2222 |
In known_hosts on port 2222 (the only non-standard SSH port); user/key not pinned in config — confirm before use |
| local Linux VMs | ssh orb / orb |
OrbStack-managed |
⚠️
~/binis empty on this Mac. OtOpcUa'sCLAUDE.mdmentions anlmxopcua-fixhelper "in~/bin" for controlling the10.100.0.35fixture containers — it is not present here (it's a Windows-side helper). On this machine, drive the fixture host with direct SSH, e.g.ssh dohertj2@10.100.0.35 'docker compose -f /opt/otopcua-<driver>/docker-compose.yml up -d'. Treat the exact remote paths/commands as (referenced, not scripted in-repo) — verify on the host.
3. ScadaBridge deployment
.NET 10 + Akka.NET. One image scadabridge:latest (built by docker/build.sh) backs every node;
role is chosen by SCADABRIDGE_CONFIG (Central|Site) → appsettings.{role}.json. Central is a
2-node Akka cluster (split-brain resolver = keep-oldest); each Site is its own 2-node Akka
cluster reached from Central via ClusterClient.
3.1 docker/ — primary 3-site cluster (network scadabridge-net)
| Service | Container | Host→container ports | Role | Volumes |
|---|---|---|---|---|
| central-a | scadabridge-central-a |
9001:5000 (UI+Inbound API), 9011:8081 (Akka) |
Central | central-node-a/appsettings.Central.json (ro), …/logs |
| central-b | scadabridge-central-b |
9002:5000, 9012:8081 |
Central | central-node-b/… |
| site-a-a | scadabridge-site-a-a |
9021:8082 (Akka), 9023:8083 (gRPC) |
Site | site-a-node-a/{appsettings.Site.json,data,logs} |
| site-a-b | scadabridge-site-a-b |
9022:8082, 9024:8083 |
Site | site-a-node-b/… |
| site-b-a | scadabridge-site-b-a |
9031:8082, 9033:8083 |
Site | site-b-node-a/… |
| site-b-b | scadabridge-site-b-b |
9032:8082, 9034:8083 |
Site | site-b-node-b/… |
| site-c-a | scadabridge-site-c-a |
9041:8082, 9043:8083 |
Site | site-c-node-a/… |
| site-c-b | scadabridge-site-c-b |
9042:8082, 9044:8083 |
Site | site-c-node-b/… |
| traefik | scadabridge-traefik |
9000:80 (Central LB), 8180:8080 (dashboard) |
LB | traefik/{traefik,dynamic}.yml (ro) |
All restart: unless-stopped; image scadabridge:latest (traefik traefik:v3.4).
Access: Central UI/API via LB http://localhost:9000; direct nodes :9001/:9002; Traefik
dashboard http://localhost:8180; Management API http://localhost:9000/management; health
…/health/ready + …/health/active.
3.2 docker-env2/ — secondary 1-site cluster (same scadabridge-net)
| Service | Container | Host→container ports | Role |
|---|---|---|---|
| central-a | scadabridge-env2-central-a |
9101:5000, 9111:8081 |
Central |
| central-b | scadabridge-env2-central-b |
9102:5000, 9112:8081 |
Central |
| site-x-a | scadabridge-env2-site-x-a |
9121:8082, 9123:8083 |
Site |
| site-x-b | scadabridge-env2-site-x-b |
9122:8082, 9124:8083 |
Site |
| traefik | scadabridge-env2-traefik |
9100:80 (LB), 8181:8080 (dashboard) |
LB |
Access: LB http://localhost:9100; direct :9101/:9102; dashboard http://localhost:8181.
This cluster's DBs and auth cookie name are distinct from docker/ so the two can run on
localhost at once — cookie ZB.MOM.WW.ScadaBridge.Auth.env2 vs the default; see
scadabridge-local-deploy-gotchas.
3.3 infra/ — shared backing services (network scadabridge-net)
| Service | Container | Image | Host ports | Purpose |
|---|---|---|---|---|
| mssql | scadabridge-mssql |
mcr.microsoft.com/mssql/server:2022-latest |
1433:1433 |
SQL Server — Central DBs for both clusters; named vol scadabridge-mssql-data; init via /docker-entrypoint-initdb.d/{setup,machinedata_seed,setup-env2}.sql |
| opcua | scadabridge-opcua |
mcr.microsoft.com/iotedge/opc-plc:latest |
50000:50000, 8080:8080 |
OPC-UA simulator 1 (--unsecuretransport --autoaccept) |
| opcua2 | scadabridge-opcua2 |
…/opc-plc:latest |
50010:50010, 8081:8080 |
OPC-UA simulator 2 |
| smtp | scadabridge-smtp |
axllent/mailpit:latest |
1025:1025, 8025:8025 |
SMTP sink + web UI (http://localhost:8025) |
| restapi | scadabridge-restapi |
local build ./restapi |
5200:5200 |
Test REST endpoint |
| playwright | scadabridge-playwright |
mcr.microsoft.com/playwright:v1.58.2-noble |
3000:3000 |
Browser-automation server |
LDAP is NOT started by
infra/. The per-appscadabridge-ldapcontainer has been retired (commented out ininfra/docker-compose.yml). All three apps (ScadaBridge, OtOpcUa, MxAccessGateway) now share a singlezb-shared-glauthcontainer on the Linux fixture host10.100.0.35:3893(baseDN dc=zb,dc=local, Transport=None). Source of truth and deploy/verify runbook:scadaproj/infra/glauth/(config.toml+docker-compose.yml+README.md); deploy by scp-ing those two files to10.100.0.35and runningdocker compose up -d.
3.4 Traefik (ScadaBridge)
Both clusters use a file provider + insecure API dashboard. traefik.yml: entrypoint web:80,
api.dashboard: true / insecure: true, file provider dynamic.yml. dynamic.yml router
central (PathPrefix(/) → service central) load-balances the two Central containers with an
active health check on /health/active (interval 5s, timeout 3s) — so traffic only routes to
the active leader (standby returns 503 and is dropped from rotation):
# docker/traefik/dynamic.yml (env2 points at scadabridge-env2-central-a/-b)
http:
routers: { central: { rule: "PathPrefix(`/`)", service: central, entryPoints: [web] } }
services:
central:
loadBalancer:
healthCheck: { path: /health/active, interval: 5s, timeout: 3s }
servers: [ {url: "http://scadabridge-central-a:5000"}, {url: "http://scadabridge-central-b:5000"} ]
3.5 Databases (ScadaBridge)
- Central → MSSQL (
scadabridge-mssql:1433), app loginscadabridge_app/ScadaBridge_Dev1#🔒(dev-only):docker/:ScadaBridgeConfig+ScadaBridgeMachineDatadocker-env2/:ScadaBridgeConfig2+ScadaBridgeMachineData2- Created by
infra/mssql/setup.sql+setup-env2.sqlat MSSQL init; EF Core migrations run on Central startup;docker-env2/init-db.shensures the env2 DBs before deploy;seed-sites.shseeds Site rows post-deploy.
- Site → SQLite, per node under the mounted
…/datavolume (SiteDbPath, plus a store-and-forward DB). Not networked, not replicated across hosts.
3.6 Deploy commands (ScadaBridge)
cd ~/Desktop/ScadaBridge
cd infra && docker compose up -d # 1) backing services (MSSQL, OPC-UA, SMTP, REST) — LDAP is shared glauth on 10.100.0.35 (scadaproj/infra/glauth/)
bash docker/build.sh # 2) create scadabridge-net (if missing) + build scadabridge:latest
bash docker/deploy.sh # 3) up -d --force-recreate; prints access points (9000/9001/9002/8180)
bash docker/seed-sites.sh # 4) seed sites + data-connections (optional)
# env2 cluster:
bash docker-env2/deploy.sh # reuses the image; runs init-db.sh; ports 9100/9101/9102/8181
Caveat:
deploy.shdoesup -d --force-recreate, starting both Central nodes at once — they can split-brain on a simultaneous start. Start Central sequenced (central-a → wait/health/active200 → central-b). Central also requiresScadaBridge__InboundApi__ApiKeyPepper(dev value is inline in both composes). Full detail: scadabridge-local-deploy-gotchas.
4. OtOpcUa deployment (otopcua-dev)
.NET 10 OPC-UA server. Three independent Akka clusters share the single OtOpcUa ConfigDb
(multi-tenancy via the ServerCluster table); Akka isolation is by disjoint seed lists (same
system name otopcua, internal remoting port 4053). Built locally from docker-dev/Dockerfile
→ image otopcua-host:dev. No per-app LDAP container — docker-dev is un-stubbed
(Authentication__Ldap__DevStubMode removed) and binds the shared GLAuth at
10.100.0.35:3893 (baseDN dc=zb,dc=local, Transport=None). Start the shared glauth first via
scadaproj/infra/glauth/ if it is not already running.
| Service | Container | Host→container ports | Cluster / role |
|---|---|---|---|
| sql | (otopcua-dev-sql-1) |
14330:1433 |
SQL Server 2022 — the shared OtOpcUa ConfigDb |
| cluster-seed | one-shot | — | mssql-tools running /seed/entrypoint.sh (idempotent ServerCluster/ClusterNode seed) |
| admin-a | host | (none — internal :9000 UI behind Traefik) |
MAIN, role admin (seed) |
| admin-b | host | (none) | MAIN, role admin (joins admin-a) |
| driver-a | host | 4840:4840 (OPC UA) |
MAIN, role driver |
| driver-b | host | 4841:4840 |
MAIN, role driver |
| site-a-1 | host | 4842:4840 |
SITE-A, admin,driver (seed) |
| site-a-2 | host | 4843:4840 |
SITE-A, admin,driver |
| site-b-1 | host | 4844:4840 |
SITE-B, admin,driver (seed) |
| site-b-2 | host | 4845:4840 |
SITE-B, admin,driver |
| traefik | host | 9200:80 (Admin UI LB), 8089:8080 (dashboard) |
traefik:v3.1 |
- OPC UA endpoints:
opc.tcp://localhost:4840(driver-a) …:4845(site-b-2). Admin nodes serve no OPC UA. - Admin UI (Traefik, sticky cookie
otopcua_lb, health-checked on/health/active):- MAIN cluster:
http://localhost:9200 - SITE-A:
http://site-a.localhost:9200· SITE-B:http://site-b.localhost:9200(Host-header routing; macOS auto-resolves*.localhost) - Traefik dashboard:
http://localhost:8089
- MAIN cluster:
- DB:
sqlservice,14330:1433, SAOtOpcUa!Dev123🔒(dev-only), databaseOtOpcUa; EF auto-migrates on host start, thencluster-seedinserts the 3 ServerCluster + 6 ClusterNode rows. - Deploy:
docker compose -f docker-dev/docker-compose.yml up -d --build; tear down with… down -v. - Galaxy link: driver nodes resolve
GALAXY_MXGW_API_KEYand connect out to MxAccessGateway (see §5).
Integration-test fixtures (separate from this stack) run on the Linux fixture host
10.100.0.35(Modbus:5020, Allen-Bradley:44818, S7:102, OPC-UA:50000, SQL:14330). Those are test endpoints, not the deployed app; per-fixture env defaults are inenv_vars.md§1.3.
5. MxAccessGateway deployment (Windows-native, no Docker)
Two processes: an x64 .NET 10 Server (ASP.NET Core gRPC + Blazor dashboard) and a per-session
x86 .NET 4.8 Worker that owns the 32-bit AVEVA MXAccess COM/STA. Windows-only. Deployed on
windev (10.100.0.48) and VD03, run as a Windows Service via NSSM (config delivered as
Kestrel__Endpoints__… environment variables, not appsettings.json).
5.1 Endpoint/port map
| Endpoint | Default URL | Protocol | Config key | Purpose |
|---|---|---|---|---|
| Http (gRPC) | http://0.0.0.0:5120 (h2c) |
HTTP/2 cleartext | Kestrel__Endpoints__Http__Url / __Protocols=Http2 |
Public gRPC: sessions, MxCommand/MxEvent, Galaxy browse |
| Dashboard | http://0.0.0.0:5130 |
HTTP/1.1 | Kestrel__Endpoints__Dashboard__Url |
Blazor dashboard + SignalR hubs + /login |
Local dev (launchSettings.json): gRPC http://localhost:5120 (https dev profile adds 7121).
TLS optional — set …Http__Url=https://…; the gateway auto-generates a self-signed cert if none is
supplied (docs/GatewayConfiguration.md). Dashboard cookie name is now configurable
(MxGateway:Dashboard:CookieName).
5.2 Run / host
# local dev
dotnet run --project src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj
# the x86 worker must be published first; path = MxGateway:Worker:ExecutablePath
dotnet build src/MxGateway.Worker/MxGateway.Worker.csproj -p:Platform=x86
- Worker model: the Server spawns one
ZB.MOM.WW.MxGateway.Worker.exe(x86) per gRPC session; IPC over a named pipe (\\.\pipe\mxgateway-<session>+ a per-sessionMXGATEWAY_WORKER_NONCE); heartbeat 5s / grace 15s; max 64 concurrent sessions. The worker exits when the session closes. - Production hosts: both
10.100.0.48andwonder-app-vd03serve gRPC on:5120(perdocs/GatewayConfiguration.md).
5.3 Who connects to it
| Client | Connects to | Auth |
|---|---|---|
OtOpcUa GalaxyDriver |
http://10.100.0.48:5120 (gRPC) |
API key via GALAXY_MXGW_API_KEY (mxgw_… bearer) 🔒 |
| ScadaBridge MxGateway adapter | same gRPC endpoint :5120 |
API key |
6. Cross-project runtime data flow (deployed)
AVEVA Galaxy (Wonderware) ──MXAccess COM (32-bit)──► MxAccessGateway (windev:5120 gRPC / :5130 dashboard)
▲ ▲
OtOpcUa GalaxyDriver ───gRPC────┘ │ gRPC
(otopcua-dev: opc.tcp :4840–4845) │
│ OPC UA │
▼ │
ScadaBridge DCL ◄──OPC UA──┐ ┌──MxGateway adapter──┘
(docker :9000 / env2 :9100) └───┘
ScadaBridge reaches Wonderware data two ways: (1) OPC UA → OtOpcUa → gateway, or (2) its
MxGateway adapter → gateway directly. The break surface is the wire contracts (the gateway .protos
and OtOpcUa's OPC-UA address space), not compile references.
7. Consolidated host port map
Every published host port across the local stacks (no collisions — all can run at once):
| Port | → Container:port | Service | Stack |
|---|---|---|---|
| 1025 | scadabridge-smtp:1025 |
SMTP submission | infra |
| 1433 | scadabridge-mssql:1433 |
SQL Server (ScadaBridge Central DBs) | infra |
| 3000 | scadabridge-playwright:3000 |
Playwright server | infra |
| 3893 | zb-shared-glauth:3893 on 10.100.0.35 |
LDAP (shared GLAuth — remote fixture host, not a local container) | scadaproj/infra/glauth/ |
| 5200 | scadabridge-restapi:5200 |
Test REST API | infra |
| 8025 | scadabridge-smtp:8025 |
Mailpit web UI | infra |
| 8080 | scadabridge-opcua:8080 |
OPC-UA sim 1 web UI | infra |
| 8081 | scadabridge-opcua2:8080 |
OPC-UA sim 2 web UI | infra |
| 50000 | scadabridge-opcua:50000 |
OPC-UA sim 1 endpoint | infra |
| 50010 | scadabridge-opcua2:50010 |
OPC-UA sim 2 endpoint | infra |
| 9000 | scadabridge-traefik:80 |
Central UI/API (LB) | docker |
| 8180 | scadabridge-traefik:8080 |
Traefik dashboard | docker |
| 9001 / 9002 | central-a / central-b :5000 | Central UI+Inbound API (direct) | docker |
| 9011 / 9012 | central-a / central-b :8081 | Akka remoting | docker |
| 9021–9024 | site-a-a/b :8082 / :8083 | Site A Akka / gRPC | docker |
| 9031–9034 | site-b-a/b :8082 / :8083 | Site B Akka / gRPC | docker |
| 9041–9044 | site-c-a/b :8082 / :8083 | Site C Akka / gRPC | docker |
| 9100 | scadabridge-env2-traefik:80 |
Central UI/API (LB) | docker-env2 |
| 8181 | scadabridge-env2-traefik:8080 |
Traefik dashboard | docker-env2 |
| 9101 / 9102 | env2 central-a / central-b :5000 | Central (direct) | docker-env2 |
| 9111 / 9112 | env2 central-a / central-b :8081 | Akka remoting | docker-env2 |
| 9121–9124 | env2 site-x-a/b :8082 / :8083 | Site X Akka / gRPC | docker-env2 |
| 14330 | otopcua-dev sql :1433 |
SQL Server (OtOpcUa DB) |
otopcua-dev |
| 4840 / 4841 | driver-a / driver-b :4840 | OPC UA (MAIN) | otopcua-dev |
| 4842 / 4843 | site-a-1 / site-a-2 :4840 | OPC UA (SITE-A) | otopcua-dev |
| 4844 / 4845 | site-b-1 / site-b-2 :4840 | OPC UA (SITE-B) | otopcua-dev |
| 9200 | otopcua-dev traefik :80 |
Admin UI (LB) | otopcua-dev |
| 8089 | otopcua-dev traefik :8080 |
Traefik dashboard | otopcua-dev |
Remote (non-local) endpoints: MxAccessGateway gRPC 10.100.0.48:5120 (h2c) / dashboard :5130;
production gRPC on wonder-app-vd03:5120. SSH: windev/fixture/gitea on 22, VD03 on 2222.
8. Secrets & dev-only values
Every credential shown above (OtOpcUa!Dev123, ScadaBridge_Dev1#, the inline API-key peppers,
the docker-dev JWT signing key, the mxgw_… API key) is a dev-only placeholder for the local
stacks — never reuse as a real secret. Production injects real secrets out-of-band (NSSM env / secret
store), per ScadaBridge docs/operations/inbound-api-key-reissue.md (the VD03 runbook). The full
🔒 secret inventory and the __-env-var override forms are in env_vars.md §5.
9. Production (VD03) — pointer
wonder-app-vd03.zmr.zimmer.com (SSH :2222) runs the production single-node ScadaBridge and the
MxGateway (gRPC :5120). The production install is not a scripted in-repo flow here — the
operational procedures live in ScadaBridge docs/operations/ (failover-procedures.md,
maintenance-procedures.md, inbound-api-key-reissue.md, troubleshooting-guide.md). Treat any
prod service/port specifics not in those runbooks as unverified.