Files
ScadaBridge/docker/docker-compose.yml
T

473 lines
28 KiB
YAML

# EVERY node needs a secret store path; only four take the hub anchor below. Keep this
# anchor separate from that one — folding it in is what crash-looped site-b and site-c on
# 2026-08-11: they merge no secrets anchor, fell back to the shipped default, and were
# rejected at boot.
#
# The value must be absolute AND outside the content root (/app) — Secrets 0.6.2 validates
# both at startup, because a store inside the deployment directory is destroyed by an
# ordinary upgrade (this wiped the MxGateway key store on 2026-08-09). /data is the node's
# own data volume (named volume since register row 79), the SAME volume as /app/data, so an
# existing store is found unmoved; only the path used to reach it changes. Do NOT point
# this back at /app/data — absolute is not sufficient, and the boot will fail.
#
# Do NOT delete this key expecting the library's per-user default to take over. Measured on
# this rig: with a Secrets section present but no SqlitePath, the effective value is the
# RELATIVE "secrets.db", which fails both rules. Setting it explicitly is the supported
# posture for a deployment in any case.
x-secrets-store-env: &secrets-store-env
Secrets__SqlitePath: "/data/scadabridge-secrets.db"
# ── Secrets:Store:Provider=SqlServer: central shares one secret store directly (Task 5) ──
#
# Task 11 retired secrets replication entirely — both the pull-only gRPC hub (central
# hosted, sites swept it) and the SQL-Server ReplicatingSecretStore mode that preceded it.
# Sites now receive secrets exclusively via Deploy Artifacts; there is no hub and nothing
# for a site to sweep. What remains, and is unrelated to replication, is Secrets:Store:
# Provider=SqlServer: it points BOTH central nodes at ONE shared SQL-Server database
# directly — no hub, no follower sweep, just two nodes reading and writing the same rows —
# so a clustered central pair can never serve sites a drifted secret set. central-a/b set
# it explicitly below; site nodes always ignore the key and keep their local SQLite store.
#
# ZbSecretsHub is a dedicated database on the rig's existing scadabridge-mssql container
# (default zbsecrets schema, created by the boot-time SqlServerSecretsStoreMigrator; the
# database itself + the scadabridge_app grant were provisioned once via sqlcmd — see
# docs/plans/2026-08-07-secrets-central-shared-store-live-gate.md). Same DEV-ONLY
# credentials as the ScadaBridge__Database__* strings below. Must be a LITERAL value,
# never a ${secret:} reference — the pre-host expander needs this string to reach the
# store that would resolve it (bootstrap circularity; registration rejects it). Site nodes
# must NEVER carry this key: sites talk to central, not to central's database.
x-secrets-central-store-env: &secrets-central-store-env
Secrets__Store__Provider: "SqlServer"
Secrets__SqlServer__ConnectionString: "Server=scadabridge-mssql,1433;Database=ZbSecretsHub;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true"
# ── Deploy-artifact secret KEKs ──────────────────────────────────────────────────────
#
# Every node that HANDLES a deployed secret — central (it encrypts what ships in the
# artifact) and site-a (it decrypts what it receives) — must resolve the SAME
# ZB_SECRETS_MASTER_KEY: only ciphertext ever crosses the wire, so a node with a different
# KEK fails closed on resolve with a kek_id mismatch that reads like corruption but is a
# deployment error. site-b/site-c deliberately carry a DIFFERENT dev KEK (below) so that
# mismatch is a real, provable negative on this rig rather than an assumption — see
# Task 13.
#
# ALL VALUES HERE ARE DEV-ONLY and committed under the same exception as the mesh PSKs
# and the ApiKeyPepper below: a local docker rig needs a working credential in source
# control to boot. Production supplies the KEK out of band (ZB_SECRETS_MASTER_KEY, never
# committed).
x-secrets-shared-kek-env: &secrets-shared-kek-env
# DEV-ONLY KEK — NOT a real key. Identical on central-a/b and site-a-a/b.
ZB_SECRETS_MASTER_KEY: "zZiBWuoaVMbJmGXToLk9Lakw0iJozXoL/7Gxac3GwJ4="
# A DIFFERENT DEV-ONLY KEK — NOT a real key — deliberately mismatched from the one above.
# site-b and site-c never receive a deployed secret from this rig's exercised paths today,
# but carrying a mismatched key here (rather than none) means a future deploy targeting
# them fails closed with a named kek_id mismatch instead of an untested code path.
x-secrets-mismatched-kek-env: &secrets-mismatched-kek-env
ZB_SECRETS_MASTER_KEY: "esKrmqV2RBmMq/+6T/5zK2BcalRy5gzDXhoG9Qydg6Q="
services:
central-a:
image: scadabridge:latest
# An init process (tini) as PID 1, so a crashed dotnet process actually dies.
# Without it dotnet IS PID 1, Linux ignores the SIGABRT the runtime's crash path
# raises against PID 1, and any unhandled boot exception left the container
# `running` with the main thread spinning at 100% CPU — restart policy never
# fired (ScadaBridge#34). Belt to Program.cs's UnhandledException handler, which
# covers managed exceptions but not FailFast/runtime-internal aborts.
init: true
# CoordinatedShutdown needs cluster-leave (15s budget) + cluster-exiting +
# actor-system-terminate + Serilog flush; the 10s SIGTERM default SIGKILLed
# mid-drain, turning every redeploy into the crash path (review 01 [Medium]).
stop_grace_period: 30s
container_name: scadabridge-central-a
environment:
# Central-only shared SQL secret store (Secrets:Store:Provider=SqlServer, Task 5) + the
# deploy-artifact secret KEK (anchors at the top of this file).
<<: [*secrets-store-env, *secrets-shared-kek-env, *secrets-central-store-env]
SCADABRIDGE_CONFIG: Central
ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: "http://+:5000"
# DEV-ONLY local-cluster value — NOT a real secret. The Auth/Config normalization
# (2026-06-03) made ScadaBridge:InboundApi:ApiKeyPepper a hard Central startup
# requirement (>=16 chars, per-environment). Real deployments inject a true secret
# out-of-band (env/secret store), never from source control — see
# docs/operations/inbound-api-key-reissue.md. Both Central nodes share one pepper.
ScadaBridge__InboundApi__ApiKeyPepper: "dev-only-insecure-pepper-docker-cluster-0001"
# Shared inbound API key store (design 2026-09-02, register row 108): both centrals read and
# write ONE zbauth schema in the configuration database, so a key minted on either node
# survives a central failover and a revoke is cluster-wide. The SqlServer connection string
# is deliberately NOT set: it defaults to ScadaBridge__Database__ConfigurationDb below.
# Each node's legacy per-node SQLite file is imported once at boot and renamed *.imported.
ScadaBridge__InboundApi__ApiKeyStore__Provider: "SqlServer"
# DEV-ONLY local-cluster secrets — NOT real secrets. Relocated out of the mounted
# appsettings.Central.json (G-4 T5 secrets cleanup) so that app-config file carries no
# plaintext credentials. Env overrides layer over JSON BEFORE the ${secret:} pre-host
# expander runs, so the dev cluster boots with no KEK/secret store. The SQL password is
# the same value already committed for the sibling scadabridge-mssql container
# (infra/docker-compose.yml MSSQL_SA_PASSWORD) — consolidation, not new exposure. Real
# deployments use ${secret:} tokens (src/.../appsettings.Central.json) + a seeded store.
ScadaBridge__Database__ConfigurationDb: "Server=scadabridge-mssql,1433;Database=ScadaBridgeConfig;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true"
ScadaBridge__Database__MachineDataDb: "Server=scadabridge-mssql,1433;Database=ScadaBridgeMachineData;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true"
ScadaBridge__Security__Ldap__ServiceAccountPassword: "serviceaccount123"
ScadaBridge__Security__JwtSigningKey: "scadabridge-dev-jwt-signing-key-must-be-at-least-32-characters-long"
# DEV-ONLY gRPC control-plane preshared keys, one per site — NOT real secrets.
# Central verifies/presents these; each site node carries the same value as
# ScadaBridge:Communication:GrpcPsk in its mounted appsettings.Site.json. Kept as
# env overrides (not in the mounted central appsettings) so that file stays free of
# plaintext credentials. Production instead seeds SB-GRPC-PSK-<siteId> into the
# secret store, which is also the only source that can serve a site added at runtime.
ScadaBridge__Communication__SitePsks__site-a: "dev-grpc-psk-docker-site-a"
ScadaBridge__Communication__SitePsks__site-b: "dev-grpc-psk-docker-site-b"
ScadaBridge__Communication__SitePsks__site-c: "dev-grpc-psk-docker-site-c"
ports:
- "9001:5000" # Web UI + Inbound API
- "9011:8081" # Akka remoting (host access for CLI/debugging)
- "9013:8083" # gRPC control plane (CentralControlService, T1A.2)
volumes:
- ./central-node-a/appsettings.Central.json:/app/appsettings.Central.json:ro
# Originally added for the gRPC secrets hub's local SQLite store; since the
# central store moved to the shared SQL-Server database (Secrets 0.5.0,
# scadaproj#4) the scadabridge-secrets.db here is a pre-0.5.0 residue, but the
# volume is still needed (inbound-api-keys.sqlite lives on it).
- central-node-a-data:/app/data
# Same named volume, mounted a second time OUTSIDE the content root (/app).
# Secrets 0.6.2 refuses a store path under the content root; this mount is
# not touched by an image rebuild, but the guard is lexical and cannot know that.
- central-node-a-data:/data
- ./central-node-a/logs:/app/logs
networks:
- scadabridge-net
restart: unless-stopped
central-b:
image: scadabridge:latest
init: true # PID-1 crash wedge, ScadaBridge#34 — see central-a
# CoordinatedShutdown needs cluster-leave (15s budget) + cluster-exiting +
# actor-system-terminate + Serilog flush; the 10s SIGTERM default SIGKILLed
# mid-drain, turning every redeploy into the crash path (review 01 [Medium]).
stop_grace_period: 30s
container_name: scadabridge-central-b
environment:
# Central-only shared SQL secret store (Secrets:Store:Provider=SqlServer, Task 5) + the
# deploy-artifact secret KEK (anchors at the top of this file).
<<: [*secrets-store-env, *secrets-shared-kek-env, *secrets-central-store-env]
SCADABRIDGE_CONFIG: Central
ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: "http://+:5000"
# DEV-ONLY local-cluster value — NOT a real secret. The Auth/Config normalization
# (2026-06-03) made ScadaBridge:InboundApi:ApiKeyPepper a hard Central startup
# requirement (>=16 chars, per-environment). Real deployments inject a true secret
# out-of-band (env/secret store), never from source control — see
# docs/operations/inbound-api-key-reissue.md. Both Central nodes share one pepper.
ScadaBridge__InboundApi__ApiKeyPepper: "dev-only-insecure-pepper-docker-cluster-0001"
# Shared inbound API key store (design 2026-09-02, register row 108): both centrals read and
# write ONE zbauth schema in the configuration database, so a key minted on either node
# survives a central failover and a revoke is cluster-wide. The SqlServer connection string
# is deliberately NOT set: it defaults to ScadaBridge__Database__ConfigurationDb below.
# Each node's legacy per-node SQLite file is imported once at boot and renamed *.imported.
ScadaBridge__InboundApi__ApiKeyStore__Provider: "SqlServer"
# DEV-ONLY local-cluster secrets — NOT real secrets. Relocated out of the mounted
# appsettings.Central.json (G-4 T5 secrets cleanup) so that app-config file carries no
# plaintext credentials. Env overrides layer over JSON BEFORE the ${secret:} pre-host
# expander runs, so the dev cluster boots with no KEK/secret store. The SQL password is
# the same value already committed for the sibling scadabridge-mssql container
# (infra/docker-compose.yml MSSQL_SA_PASSWORD) — consolidation, not new exposure. Real
# deployments use ${secret:} tokens (src/.../appsettings.Central.json) + a seeded store.
ScadaBridge__Database__ConfigurationDb: "Server=scadabridge-mssql,1433;Database=ScadaBridgeConfig;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true"
ScadaBridge__Database__MachineDataDb: "Server=scadabridge-mssql,1433;Database=ScadaBridgeMachineData;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true"
ScadaBridge__Security__Ldap__ServiceAccountPassword: "serviceaccount123"
ScadaBridge__Security__JwtSigningKey: "scadabridge-dev-jwt-signing-key-must-be-at-least-32-characters-long"
# DEV-ONLY gRPC control-plane preshared keys, one per site — NOT real secrets.
# Central verifies/presents these; each site node carries the same value as
# ScadaBridge:Communication:GrpcPsk in its mounted appsettings.Site.json. Kept as
# env overrides (not in the mounted central appsettings) so that file stays free of
# plaintext credentials. Production instead seeds SB-GRPC-PSK-<siteId> into the
# secret store, which is also the only source that can serve a site added at runtime.
ScadaBridge__Communication__SitePsks__site-a: "dev-grpc-psk-docker-site-a"
ScadaBridge__Communication__SitePsks__site-b: "dev-grpc-psk-docker-site-b"
ScadaBridge__Communication__SitePsks__site-c: "dev-grpc-psk-docker-site-c"
ports:
- "9002:5000" # Web UI + Inbound API
- "9012:8081" # Akka remoting
- "9014:8083" # gRPC control plane (CentralControlService, T1A.2)
volumes:
- ./central-node-b/appsettings.Central.json:/app/appsettings.Central.json:ro
# Originally added for the gRPC secrets hub's local SQLite store; since the
# central store moved to the shared SQL-Server database (Secrets 0.5.0,
# scadaproj#4) the scadabridge-secrets.db here is a pre-0.5.0 residue, but the
# volume is still needed (inbound-api-keys.sqlite lives on it).
- central-node-b-data:/app/data
# Same named volume, mounted a second time OUTSIDE the content root (/app).
# Secrets 0.6.2 refuses a store path under the content root; this mount is
# not touched by an image rebuild, but the guard is lexical and cannot know that.
- central-node-b-data:/data
- ./central-node-b/logs:/app/logs
networks:
- scadabridge-net
restart: unless-stopped
site-a-a:
image: scadabridge:latest
init: true # PID-1 crash wedge, ScadaBridge#34 — see central-a
# CoordinatedShutdown needs cluster-leave (15s budget) + cluster-exiting +
# actor-system-terminate + Serilog flush; the 10s SIGTERM default SIGKILLed
# mid-drain, turning every redeploy into the crash path (review 01 [Medium]).
stop_grace_period: 30s
container_name: scadabridge-site-a-a
environment:
# The shared deploy-artifact secret KEK (anchors at the top of this file) — this node
# must decrypt what central ships in a deploy artifact. site-b and site-c deliberately
# carry a DIFFERENT (mismatched) KEK instead, so that failure mode is provable on this
# rig rather than assumed (Task 13).
<<: [*secrets-store-env, *secrets-shared-kek-env]
SCADABRIDGE_CONFIG: Site
# Register row 63. The central containers have always set this; the site containers
# never did, so the Development-only DI scope validation (register row 50,
# ServiceProviderValidation.Configure — ValidateScopes/ValidateOnBuild) was dead on
# exactly the half of the topology that has six nodes. The site graph now validates
# clean, so turning it on here buys live coverage: a captive dependency or an
# unconstructible site-only descriptor fails the container at boot instead of
# surfacing as a mid-run resolve error. Dev/rig only — never set in production.
ASPNETCORE_ENVIRONMENT: Development
ports:
- "9021:8082" # Akka remoting (host access for debugging)
- "9023:8083" # gRPC streaming
volumes:
- ./site-a-node-a/appsettings.Site.json:/app/appsettings.Site.json:ro
- ./recipes:/app/recipes:ro # script file access (Files global) — sample Delmia recipes
- site-a-node-a-data:/app/data
# Same named volume, mounted a second time OUTSIDE the content root (/app).
# Secrets 0.6.2 refuses a store path under the content root; this mount is
# not touched by an image rebuild, but the guard is lexical and cannot know that.
- site-a-node-a-data:/data
- ./site-a-node-a/logs:/app/logs
networks:
- scadabridge-net
restart: unless-stopped
site-a-b:
image: scadabridge:latest
init: true # PID-1 crash wedge, ScadaBridge#34 — see central-a
# CoordinatedShutdown needs cluster-leave (15s budget) + cluster-exiting +
# actor-system-terminate + Serilog flush; the 10s SIGTERM default SIGKILLed
# mid-drain, turning every redeploy into the crash path (review 01 [Medium]).
stop_grace_period: 30s
container_name: scadabridge-site-a-b
environment:
# The shared deploy-artifact secret KEK (anchors at the top of this file) — this node
# must decrypt what central ships in a deploy artifact. site-b and site-c deliberately
# carry a DIFFERENT (mismatched) KEK instead, so that failure mode is provable on this
# rig rather than assumed (Task 13).
<<: [*secrets-store-env, *secrets-shared-kek-env]
SCADABRIDGE_CONFIG: Site
# Register row 63. The central containers have always set this; the site containers
# never did, so the Development-only DI scope validation (register row 50,
# ServiceProviderValidation.Configure — ValidateScopes/ValidateOnBuild) was dead on
# exactly the half of the topology that has six nodes. The site graph now validates
# clean, so turning it on here buys live coverage: a captive dependency or an
# unconstructible site-only descriptor fails the container at boot instead of
# surfacing as a mid-run resolve error. Dev/rig only — never set in production.
ASPNETCORE_ENVIRONMENT: Development
ports:
- "9022:8082" # Akka remoting
- "9024:8083" # gRPC streaming
volumes:
- ./site-a-node-b/appsettings.Site.json:/app/appsettings.Site.json:ro
- ./recipes:/app/recipes:ro # script file access (Files global) — sample Delmia recipes
- site-a-node-b-data:/app/data
# Same named volume, mounted a second time OUTSIDE the content root (/app).
# Secrets 0.6.2 refuses a store path under the content root; this mount is
# not touched by an image rebuild, but the guard is lexical and cannot know that.
- site-a-node-b-data:/data
- ./site-a-node-b/logs:/app/logs
networks:
- scadabridge-net
restart: unless-stopped
site-b-a:
image: scadabridge:latest
init: true # PID-1 crash wedge, ScadaBridge#34 — see central-a
# CoordinatedShutdown needs cluster-leave (15s budget) + cluster-exiting +
# actor-system-terminate + Serilog flush; the 10s SIGTERM default SIGKILLed
# mid-drain, turning every redeploy into the crash path (review 01 [Medium]).
stop_grace_period: 30s
container_name: scadabridge-site-b-a
environment:
# A DIFFERENT (mismatched) deploy-artifact secret KEK from central/site-a's — see the
# anchor comment at the top of this file (Task 13's mismatch negative).
<<: [*secrets-store-env, *secrets-mismatched-kek-env]
SCADABRIDGE_CONFIG: Site
# Register row 63. The central containers have always set this; the site containers
# never did, so the Development-only DI scope validation (register row 50,
# ServiceProviderValidation.Configure — ValidateScopes/ValidateOnBuild) was dead on
# exactly the half of the topology that has six nodes. The site graph now validates
# clean, so turning it on here buys live coverage: a captive dependency or an
# unconstructible site-only descriptor fails the container at boot instead of
# surfacing as a mid-run resolve error. Dev/rig only — never set in production.
ASPNETCORE_ENVIRONMENT: Development
ports:
- "9031:8082" # Akka remoting
- "9033:8083" # gRPC streaming
volumes:
- ./site-b-node-a/appsettings.Site.json:/app/appsettings.Site.json:ro
- site-b-node-a-data:/app/data
# Same named volume, mounted a second time OUTSIDE the content root (/app).
# Secrets 0.6.2 refuses a store path under the content root; this mount is
# not touched by an image rebuild, but the guard is lexical and cannot know that.
- site-b-node-a-data:/data
- ./site-b-node-a/logs:/app/logs
networks:
- scadabridge-net
restart: unless-stopped
site-b-b:
image: scadabridge:latest
init: true # PID-1 crash wedge, ScadaBridge#34 — see central-a
# CoordinatedShutdown needs cluster-leave (15s budget) + cluster-exiting +
# actor-system-terminate + Serilog flush; the 10s SIGTERM default SIGKILLed
# mid-drain, turning every redeploy into the crash path (review 01 [Medium]).
stop_grace_period: 30s
container_name: scadabridge-site-b-b
environment:
# A DIFFERENT (mismatched) deploy-artifact secret KEK from central/site-a's — see the
# anchor comment at the top of this file (Task 13's mismatch negative).
<<: [*secrets-store-env, *secrets-mismatched-kek-env]
SCADABRIDGE_CONFIG: Site
# Register row 63. The central containers have always set this; the site containers
# never did, so the Development-only DI scope validation (register row 50,
# ServiceProviderValidation.Configure — ValidateScopes/ValidateOnBuild) was dead on
# exactly the half of the topology that has six nodes. The site graph now validates
# clean, so turning it on here buys live coverage: a captive dependency or an
# unconstructible site-only descriptor fails the container at boot instead of
# surfacing as a mid-run resolve error. Dev/rig only — never set in production.
ASPNETCORE_ENVIRONMENT: Development
ports:
- "9032:8082" # Akka remoting
- "9034:8083" # gRPC streaming
volumes:
- ./site-b-node-b/appsettings.Site.json:/app/appsettings.Site.json:ro
- site-b-node-b-data:/app/data
# Same named volume, mounted a second time OUTSIDE the content root (/app).
# Secrets 0.6.2 refuses a store path under the content root; this mount is
# not touched by an image rebuild, but the guard is lexical and cannot know that.
- site-b-node-b-data:/data
- ./site-b-node-b/logs:/app/logs
networks:
- scadabridge-net
restart: unless-stopped
site-c-a:
image: scadabridge:latest
init: true # PID-1 crash wedge, ScadaBridge#34 — see central-a
# CoordinatedShutdown needs cluster-leave (15s budget) + cluster-exiting +
# actor-system-terminate + Serilog flush; the 10s SIGTERM default SIGKILLed
# mid-drain, turning every redeploy into the crash path (review 01 [Medium]).
stop_grace_period: 30s
container_name: scadabridge-site-c-a
environment:
# A DIFFERENT (mismatched) deploy-artifact secret KEK from central/site-a's — see the
# anchor comment at the top of this file (Task 13's mismatch negative).
<<: [*secrets-store-env, *secrets-mismatched-kek-env]
SCADABRIDGE_CONFIG: Site
# Register row 63. The central containers have always set this; the site containers
# never did, so the Development-only DI scope validation (register row 50,
# ServiceProviderValidation.Configure — ValidateScopes/ValidateOnBuild) was dead on
# exactly the half of the topology that has six nodes. The site graph now validates
# clean, so turning it on here buys live coverage: a captive dependency or an
# unconstructible site-only descriptor fails the container at boot instead of
# surfacing as a mid-run resolve error. Dev/rig only — never set in production.
ASPNETCORE_ENVIRONMENT: Development
ports:
- "9041:8082" # Akka remoting
- "9043:8083" # gRPC streaming
volumes:
- ./site-c-node-a/appsettings.Site.json:/app/appsettings.Site.json:ro
- site-c-node-a-data:/app/data
# Same named volume, mounted a second time OUTSIDE the content root (/app).
# Secrets 0.6.2 refuses a store path under the content root; this mount is
# not touched by an image rebuild, but the guard is lexical and cannot know that.
- site-c-node-a-data:/data
- ./site-c-node-a/logs:/app/logs
networks:
- scadabridge-net
restart: unless-stopped
site-c-b:
image: scadabridge:latest
init: true # PID-1 crash wedge, ScadaBridge#34 — see central-a
# CoordinatedShutdown needs cluster-leave (15s budget) + cluster-exiting +
# actor-system-terminate + Serilog flush; the 10s SIGTERM default SIGKILLed
# mid-drain, turning every redeploy into the crash path (review 01 [Medium]).
stop_grace_period: 30s
container_name: scadabridge-site-c-b
environment:
# A DIFFERENT (mismatched) deploy-artifact secret KEK from central/site-a's — see the
# anchor comment at the top of this file (Task 13's mismatch negative).
<<: [*secrets-store-env, *secrets-mismatched-kek-env]
SCADABRIDGE_CONFIG: Site
# Register row 63. The central containers have always set this; the site containers
# never did, so the Development-only DI scope validation (register row 50,
# ServiceProviderValidation.Configure — ValidateScopes/ValidateOnBuild) was dead on
# exactly the half of the topology that has six nodes. The site graph now validates
# clean, so turning it on here buys live coverage: a captive dependency or an
# unconstructible site-only descriptor fails the container at boot instead of
# surfacing as a mid-run resolve error. Dev/rig only — never set in production.
ASPNETCORE_ENVIRONMENT: Development
ports:
- "9042:8082" # Akka remoting
- "9044:8083" # gRPC streaming
volumes:
- ./site-c-node-b/appsettings.Site.json:/app/appsettings.Site.json:ro
- site-c-node-b-data:/app/data
# Same named volume, mounted a second time OUTSIDE the content root (/app).
# Secrets 0.6.2 refuses a store path under the content root; this mount is
# not touched by an image rebuild, but the guard is lexical and cannot know that.
- site-c-node-b-data:/data
- ./site-c-node-b/logs:/app/logs
networks:
- scadabridge-net
restart: unless-stopped
traefik:
image: traefik:v3.4
container_name: scadabridge-traefik
ports:
- "9000:80" # Central load-balanced entrypoint
- "8180:8080" # Traefik dashboard
volumes:
- ./traefik/traefik.yml:/etc/traefik/traefik.yml:ro
- ./traefik/dynamic.yml:/etc/traefik/dynamic.yml:ro
networks:
- scadabridge-net
restart: unless-stopped
networks:
scadabridge-net:
external: true
# Named volumes for every node's SQLite data dir (register row 79). On macOS/OrbStack a
# bind mount crosses virtiofs, which does not give SQLite true fsync durability — both
# site-a LocalDb files corrupted through that layer (row 69). Named volumes live on
# native ext4 inside the OrbStack VM, taking virtiofs out of the write path entirely.
# Host-side inspection still works read-only via ~/OrbStack/docker/volumes/<name>;
# `docker compose down` preserves them; remove with `docker volume rm <name>`.
# The appsettings/logs bind mounts stay: config is ro, and Serilog's append-only files
# have no fsync-durability requirement worth losing `tail -f` from the repo tree for.
volumes:
central-node-a-data:
name: scadabridge-central-node-a-data
central-node-b-data:
name: scadabridge-central-node-b-data
site-a-node-a-data:
name: scadabridge-site-a-node-a-data
site-a-node-b-data:
name: scadabridge-site-a-node-b-data
site-b-node-a-data:
name: scadabridge-site-b-node-a-data
site-b-node-b-data:
name: scadabridge-site-b-node-b-data
site-c-node-a-data:
name: scadabridge-site-c-node-a-data
site-c-node-b-data:
name: scadabridge-site-c-node-b-data