# 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`](env_vars.md).
>
> **Source confidence:** container/port/Traefik/DB facts below are read straight from the
> compose + `traefik/*.yml` files. 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](#7-consolidated-host-port-map)).

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

```sshconfig
# ~/.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 |

> ⚠️ `~/bin` is **empty** on this Mac. OtOpcUa's `CLAUDE.md` mentions an `lmxopcua-fix` helper "in
> `~/bin`" for controlling the `10.100.0.35` fixture 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-app `scadabridge-ldap` container has been retired
> (commented out in `infra/docker-compose.yml`). All three apps (ScadaBridge, OtOpcUa, MxAccessGateway)
> now share a single **`zb-shared-glauth`** container on the Linux fixture host **`10.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 to `10.100.0.35` and running `docker 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):

```yaml
# 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 login `scadabridge_app` / `ScadaBridge_Dev1#` 🔒(dev-only):
  - `docker/`: `ScadaBridgeConfig` + `ScadaBridgeMachineData`
  - `docker-env2/`: `ScadaBridgeConfig2` + `ScadaBridgeMachineData2`
  - Created by `infra/mssql/setup.sql` + `setup-env2.sql` at MSSQL init; EF Core migrations run on Central startup; `docker-env2/init-db.sh` ensures the env2 DBs before deploy; `seed-sites.sh` seeds Site rows post-deploy.
- **Site → SQLite**, per node under the mounted `…/data` volume (`SiteDbPath`, plus a store-and-forward DB). Not networked, not replicated across hosts.

### 3.6 Deploy commands (ScadaBridge)

```bash
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.sh` does `up -d --force-recreate`, starting both Central nodes at once — they
> can split-brain on a simultaneous start. Start Central **sequenced** (central-a → wait `/health/active`
> 200 → central-b). Central also requires `ScadaBridge__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`
- **DB:** `sql` service, `14330:1433`, SA `OtOpcUa!Dev123` 🔒(dev-only), database `OtOpcUa`; EF auto-migrates on host start, then `cluster-seed` inserts 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_KEY` and 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 in [`env_vars.md`](env_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

```powershell
# 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-session `MXGATEWAY_WORKER_NONCE`);
  heartbeat 5s / grace 15s; max 64 concurrent sessions. The worker exits when the session closes.
- **Production hosts:** both `10.100.0.48` and `wonder-app-vd03` serve gRPC on `:5120` (per
  `docs/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 `.proto`s
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`](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.
