Skip to content

Security Hardening Guide

Overview

This guide hardens a Cloud-Native DCS deployment for production use in pharmaceutical and regulated manufacturing environments.

Target security level: IEC 62443 SL 2-3 (protection against intentional violation using simple to sophisticated means).

Prerequisites: cert-manager, an OIDC provider (Keycloak, Azure AD, Okta), and an ingress controller (nginx or Traefik). Familiarity with the Security Operations guide is assumed.

BPCS-layer protection — not a safety-rated system

Cloud-Native DCS is a Basic Process Control System. It is not a Safety Instrumented System under IEC 61508 / IEC 61511 and is not rated for SIL 1, SIL 2, SIL 3, or SIL 4 functions. Security hardening protects the process-control plane against cyber threats. It does not turn it into a safety-rated shutdown system. Its interlocks and alarms are BPCS-layer protection functions. Whether a given hazard (emergency shutdown, overpressure trip, burner management, LEL/gas trip) requires an independent SIS is a process-hazard-analysis (PHA/LOPA) decision, and any function assigned to an SIS lives on independent, separately certified safety-PLC or hardwired-trip hardware that this software does not control.

Hardening Checklist

The chart is secure by default: most controls below ship enabled. For those rows, hardening means verifying the default is still in effect and filling in deployment-specific configuration (OIDC issuer, site namespaces, field bus CIDRs). There is no switch to flip.

Feature Helm Value Default Production
Gateway mode gateway.mode production production
Authentication gateway.auth.mode oidc oidc (configure issuer)
Mutual TLS mtls.enabled true true
MQTT security mqtt.securityProfile production production
Network policies networkPolicies.enabled true true (add site namespaces + field bus CIDRs)
Audit webhook webhook.enabled true true
Audit archival historian.audit.archival.enabled false true
Unit runtime auth unitRuntime.auth.enabled true true
Rate limiting gateway.rateLimit.enabled true true
Stream conn. ceiling gateway.streamLimits.maxConnectionsPerIP 10 size to the consumers behind each address
Ingress TLS gateway.ingress.enabled false true (with TLS)

Commands in this guide assume the default install from Deploy Your Own (helm install dcs … --namespace dcs-system), which names chart-managed resources with the dcs-cloud-native-dcs prefix. Substitute your own release namespace and fullname prefix if they differ.

Personas, Surfaces, and Privilege Tiers

kubectl grants broad, unaudited control and assumes Kubernetes fluency. It is not the interface the plant runs on. The governed tier is the gateway. The web UI, the dcs CLI, and the gateway terminal all authenticate through OIDC, resolve to a gateway RBAC persona, are scoped to a single Site, and write every state-changing action to the audit trail.

Only one persona needs a kubeconfig at all.

Persona Surface Authorization Kubernetes credentials Can write
Operator Gateway UI / HMI dcs-operator, dcs-lead-operator none ISA-88 state commands (audited)
Supervisor Gateway UI dcs-supervisor none Recipe approval / release, batch-record finalization (e-signature)
Process engineer dcs CLI, gateway terminal dcs-engineer none Recipe authoring, control logic via ChangeRequest under gitopsEnforcement
Platform engineer kubectl dcs:k8s:viewer or dcs:k8s:platform read-only nothing; direct writes are rejected
Cluster admin kubectl your IdP + dcs:admin:break-glass full break-glass only, audited at elevated severity

Operator and supervisor never leave the gateway. Their privilege is entirely a function of the groups claim in their OIDC token. See § 2. OIDC Authentication for the group table.

Process engineer works from the dcs CLI, either against the gateway API or through the in-browser terminal at /terminal. That page requires dcs-engineer or above, is scoped to the user's assigned Site, and runs bash --restricted with PATH limited to the dcs binary. The user cannot change PATH, invoke absolute paths, or redirect output.

The terminal's shell restriction is defence in depth, not a boundary

bash --restricted hardens a shell that is already reachable only by an authenticated dcs-engineer. It is not a sandbox. For a deployment that must contain a hostile engineer, disable /terminal at the ingress and give each user a shell pod with its own ServiceAccount instead.

Platform engineer is the only human persona holding Kubernetes credentials, and they are read-only (§ 7. RBAC). Under gitopsEnforcement.enabled, ValidatingAdmissionPolicies reject CREATE / UPDATE / DELETE on engineering-class CRDs from every identity except the Flux ServiceAccount and break-glass holders, so an over-broad kubeconfig handed out by mistake still cannot mutate plant configuration. See GitOps Enforcement.

Cluster admin is outside our control surface. Cluster-admin is granted by your IdP and Kubernetes RBAC. What the chart does own is the dcs:admin:break-glass ClusterRole: bind it to the on-call admin only during incident response, because every write it permits is recorded as an elevated-severity AuditRecord.

1. Gateway Production Mode

Production mode (the default) prevents deletion of batch records, procedural instances, and audit records (21 CFR Part 11). DELETE requests return 403 Forbidden. Confirm no environment override has switched the gateway back to development:

gateway:
  mode: "production"

Verification: curl -s -o /dev/null -w "%{http_code}" -X DELETE https://dcs.example.com/api/v1/audit/records/test -- expect 403.

2. OIDC Authentication

Configure the gateway to authenticate all API requests via OIDC. See Security Operations for the full authentication flow and exempt routes.

gateway:
  auth:
    mode: oidc
    oidc:
      issuerURL: "https://keycloak.example.com/realms/dcs"
      clientID: "cloud-native-dcs"
      audience: "cloud-native-dcs"
    allowedOrigins:
      - "https://dcs.newark-plant.example.com"

Group mapping -- configure these groups in your OIDC provider's groups claim:

Group Purpose
dcs-admin Administrative operations, setup, config; can perform any operation
dcs-supervisor Recipe approval / release / activation, batch-record finalization
dcs-engineer Recipe authoring, control logic configuration
dcs-lead-operator Everything an operator can do, plus launching equipment-oriented ad-hoc Operations (CIP, sampling, calibration)
dcs-operator Batch execution, ISA-88 state commands
dcs-viewer Reads only. Every GET the roles above can make and no state change of any kind, which is the grant a SIEM collector or a reporting job runs as

See Security Operations § Authorization (RBAC) for the full per-role permission breakdown, and Identity Providers for how a specific provider is configured to emit those names in its groups claim. The gateway compares each claim value against the role names for exact equality and discards what does not match, so a user whose provider emits group object IDs or a Keycloak full group path authenticates successfully and then holds no permissions at all.

CORS: Never use ["*"] in production -- list specific origins only.

Verification:

curl -H "Authorization: Bearer <token>" \
  https://dcs.newark-plant.example.com/api/v1/sites   # expect 200
curl https://dcs.newark-plant.example.com/api/v1/sites # expect 401

Session Lifecycle (IEC 62443 SR 2.5 / 2.6 / 3.8)

The gateway terminates sessions server-side before token expiry (#971): on logout (POST /api/v1/auth/logout, which the UI Logout and Lock buttons and the CLI call), after a configurable inactivity window, and on demand by an administrator (DELETE /api/v1/auth/sessions/{id}, listed via GET /api/v1/auth/sessions). Session IDs are unique per session (UUID jti) and invalid once revoked. The browser UI additionally locks after a configurable idle period and requires re-authentication to resume.

Termination reaches the connections a session is already holding, and it reaches them immediately (#1445). Revoking a session closes its live WebSocket, SSE and terminal streams. Each stream also re-checks the identity behind it every 50 seconds, and that recheck is what ends one whose token has expired or whose session crossed the inactivity window. Clients are told why (WebSocket close code 4001, a session-ended event on SSE). An integration therefore stops reconnecting and never loops into 401. See SCADA integration for the client contract. An open stream is not activity: it never keeps an idle session alive.

A session terminated out from under a working page is announced on that page's next request (#1546). The browser UI sends the operator to the login page carrying the path they were on and the reason the gateway gave, which distinguishes an inactivity termination and an administrative revocation from a token that simply ran out. Signing back in returns them to the view they were standing on, the same resume the manual lock performs.

gateway:
  auth:
    sessionInactivityTimeout: "30m"  # server-side; "0" disables
    uiIdleLockSeconds: 900           # browser idle lock; 0 disables
The session lifecycle on camera: the manual lock demanding re-authentication and resuming at the saved view, the idle window locking the session with no input, and logout leaving a session the browser's Back press cannot resurrect. The administrator-termination path (DELETE /api/v1/auth/sessions/{id}) is not in this take.

Essential-function constraint (62443-3-3 Clause 4.2): access control must never prevent the operation of essential functions. Control execution never depends on a browser session (the edge keeps running the batch regardless of UI lock state), but a dedicated operator station whose screens must stay visible and immediately actionable should set uiIdleLockSeconds: 0 (and, if warranted, a longer sessionInactivityTimeout) and rely on physical access control instead. This is the sanctioned configuration. It is not a workaround.

HA note: the session registry is per-replica (like the anti-replay nonce store): with multiple gateway replicas, a revocation takes effect on the replica that processed it, and a restart clears revocations (token expiry is the backstop). A shared backing store is tracked for multi-replica deployments.

3. Mutual TLS (mTLS)

Encrypts and authenticates all inter-component HTTP (IEC 62443-3-3 SR 3.1 / SR 4.1). Requires cert-manager.

mtls:
  enabled: true
  certManager:
    enabled: true
    clusterIssuer: true         # use ClusterIssuer so per-site Certificates work
    perSiteRuntimeCert: true    # Site reconciler creates a Certificate per site-* namespace
    issuerRef:
      name: ""                   # empty = auto-create self-signed CA
      kind: ClusterIssuer        # set to "Issuer" if clusterIssuer is false
    duration: 24h
    renewBefore: 8h

Cert distribution model. Gateway, operators, historian, and io-probe receive Certificates issued directly into dcs-system from this chart. Per-site unit-runtime pods run in site-* namespaces (one per tenant) and each site namespace gets its own Certificate created by the physical-operator's Site reconciler when the Site CR is reconciled. cert-manager owns rotation for every site: no cross-namespace secret copying, no manual kubectl apply, no drift when the CA rotates.

cert-manager prerequisite. With clusterIssuer: true, the auto-created dcs-cloud-native-dcs-mtls-ca Issuer is cluster-scoped and its CA Secret lives in the release namespace (typically dcs-system). The cert-manager controller must be installed with --cluster-resource-namespace=dcs-system so it can find the CA Secret when signing per-site leaf certs. If your cert-manager install uses the default cert-manager namespace for cluster resources, either move the chart's CA Secret there, or set clusterIssuer: false (in which case per-site Certificates are disabled and you must manage runtime cert distribution yourself).

To sign with an organizational PKI in place of the auto-created CA, set issuerRef.name to your ClusterIssuer (e.g., org-ca-issuer) and kind: ClusterIssuer.

Verification:

# dcs-system certs (gateway, operators, historian, io-probe)
kubectl get certificates -n dcs-system -l app.kubernetes.io/part-of=cloud-native-dcs

# Per-site runtime certs
kubectl get certificates -A -l app.kubernetes.io/component=unit-runtime
kubectl get certificate -n dcs-system dcs-cloud-native-dcs-gateway-mtls \
  -o jsonpath='{.status.notAfter}'

Migration from pre-#213 installs. Existing clusters with manually-copied dcs-cloud-native-dcs-runtime-mtls Secrets in site namespaces should migrate with:

# 1. Upgrade the chart with clusterIssuer: true, perSiteRuntimeCert: true.
# 2. For each site namespace, delete the manual Secret so the new Certificate
#    can own it:
for NS in $(kubectl get ns -l dcs.io/site -o name); do
  kubectl delete secret -n "${NS#namespace/}" dcs-cloud-native-dcs-runtime-mtls --ignore-not-found
done
# 3. Trigger a Site reconcile (e.g., annotate each Site CR with dcs.io/reconcile)
#    so the Site reconciler creates Certificates. cert-manager then issues
#    fresh Secrets in each namespace.
# 4. Bounce the runtime pods once so they mount the fresh Secret content.

4. MQTT Broker Security

The production profile is already the default -- TLS on port 8883, per-role authentication, and topic ACLs are active. Plaintext port 1883 is disabled. See Security Operations for ACL role details.

mqtt:
  securityProfile: production
  auth:
    users:
      gateway:  { password: "" }   # auto-generated (recommended)
      operator: { password: "" }
      runtime:  { password: "" }
      historian: { password: "" }

Auto-generated passwords are stored in the Secret dcs-cloud-native-dcs-mqtt-auth (one key per broker username: dcs-gateway, dcs-operator, dcs-runtime, dcs-historian). Use --set-string mqtt.auth.users.<role>.password=... only if you need explicit values.

Verification:

HIST_PASS=$(kubectl get secret -n dcs-system dcs-cloud-native-dcs-mqtt-auth \
  -o jsonpath='{.data.dcs-historian}' | base64 -d)
mosquitto_sub -h dcs-cloud-native-dcs-mqtt.dcs-system.svc -p 8883 \
  --cafile ca.crt -u dcs-historian -P "$HIST_PASS" -t "dcs/#" -C 1

5. Network Policies

Enforces IEC 62443 zone/conduit segmentation with default-deny ingress.

networkPolicies:
  enabled: true
  siteNamespaces:
    - site-newark-plant
  fieldBusCIDRs:
    - 10.0.100.0/24
  gateway:
    ingressNamespace: ingress-nginx
  monitoring:
    namespace: monitoring

Zone model:

flowchart TD
    subgraph Ext["External Zone"]
        Ing[Ingress Controller]
    end
    subgraph Ctrl["Control Zone"]
        GW[Gateway]
        Doc[Docs Site]
        Op[Operators]
        MQ[MQTT Broker]
    end
    subgraph Field["Field Zone"]
        RT[Unit Runtimes<br/>site-newark-plant]
    end
    subgraph Bus["Field Bus Zone"]
        IO[I/O Modules<br/>10.0.100.0/24]
    end
    Ing -->|HTTPS| GW
    Ing -->|HTTPS| Doc
    Ctrl -->|mTLS| RT
    RT -->|Modbus / EthernetIP / OPC UA| IO

Every site namespace must appear in siteNamespaces for runtime policies to be installed. Field bus CIDRs restrict runtime egress to known device networks.

Verification:

kubectl get networkpolicies -A -l app.kubernetes.io/part-of=cloud-native-dcs

6. Pod Security

The Helm chart enforces hardened security contexts on the control-plane workloads it deploys (gateway, operators, historian, MQTT, audit components): runAsNonRoot, readOnlyRootFilesystem, no privilege escalation, all capabilities dropped, seccompProfile: RuntimeDefault. The non-root UID itself (65532) comes from the distroless base image's USER directive. The chart pins no runAsUser.

Exception: the unit-runtime pod. The per-unit runtime pod that the physical operator creates on device nodes does not fit this profile, by design:

  • hostNetwork: true — deterministic host ports for the runtime HTTP API and health endpoints, and direct field-network access (see the Port Allocation Policy in Architecture).
  • A hostPath volume for local FB-state persistence, so the unit keeps executing through control-plane outages (local autonomy).

Those two deviations are the whole exception. Everything else is enforced. The runtime container carries the same securityContext controls as the chart-deployed profile: runAsNonRoot, allowPrivilegeEscalation: false, all capabilities dropped, seccompProfile: RuntimeDefault, and readOnlyRootFilesystem: true (all runtime state is written under the hostPath data mount, and none touches the root filesystem). See #722.

One supporting detail: the kubelet creates the hostPath data dir root-owned, so the pod runs a short-lived init-data-perms init container as root to chown it to the runtime UID before the app container starts. It is never privileged and is bounded to the CHOWN/DAC_OVERRIDE/FOWNER capabilities with everything else dropped (#731).

Compensating controls for the runtime pod: mTLS plus fail-closed bearer token auth on its HTTP API, NetworkPolicies restricting ingress and limiting egress to the field-bus CIDRs (§5), cosign-signed distroless images, and physical/host security of the dedicated device nodes. See the unit-runtime section of the Threat Model.

Pod Security Admission guidance therefore differs by namespace. restricted is safe for the control-plane namespace:

kubectl label namespace dcs-system \
  pod-security.kubernetes.io/enforce=restricted \
  pod-security.kubernetes.io/warn=restricted \
  pod-security.kubernetes.io/audit=restricted

Do not apply enforce=baseline or enforce=restricted to site namespaces that host unit runtimes: hostNetwork and hostPath are forbidden at both levels, so enforcement blocks every runtime pod in the site (taking down all I/O) the next time one is created or rescheduled. Keep enforcement at privileged on runtime-hosting site namespaces and use warn/audit at baseline for visibility into everything else scheduled there:

kubectl label namespace site-newark-plant \
  pod-security.kubernetes.io/enforce=privileged \
  pod-security.kubernetes.io/warn=baseline \
  pod-security.kubernetes.io/audit=baseline

Verification:

kubectl get pods -n dcs-system -o json | \
  jq '.items[0].spec.containers[0].securityContext'

7. RBAC

The generated manager-role ClusterRole uses least-privilege verbs. AuditRecords are restricted to create only -- no update or delete. By default each operator gets its own ServiceAccount, named dcs-cloud-native-dcs-<component> (override per component with <component>Operator.serviceAccount.name).

Verification:

kubectl auth can-i --list \
  --as=system:serviceaccount:dcs-system:dcs-cloud-native-dcs-physical-operator
kubectl auth can-i delete auditrecords.audit.dcs.io \
  --as=system:serviceaccount:dcs-system:dcs-cloud-native-dcs-batch-operator  # expect: no

Human read roles

For the platform-engineer persona the chart ships two read-only ClusterRoles. Both are disabled by default and neither carries a write verb. Enabling one renders the ClusterRole but binds nothing until you supply subjects.

Value ClusterRole Grants
humanRoles.viewer.enabled dcs:k8s:viewer get/list/watch on all eight dcs.io API groups
humanRoles.platform.enabled dcs:k8s:platform the above, plus pods, pod logs, events, workloads, services, nodes, and CRD schemas

Neither role can read Secrets or ConfigMaps -- those hold MQTT credentials, the e-signature signing key, and MES API keys.

humanRoles:
  platform:
    enabled: true
    subjects:
      - kind: Group
        name: platform-engineers
        apiGroup: rbac.authorization.k8s.io
    # Cluster-wide when empty. Listing namespaces binds per-namespace instead,
    # which cannot grant read on the cluster-scoped Enterprise and Site.
    namespaces: []

Leave subjects empty to render the ClusterRole and bind it yourself from IdP-managed group bindings. Do not extend either role with write verbs: the write path is Flux plus change control, and a human-bound write role reintroduces exactly the out-of-band mutation GitOps enforcement exists to prevent.

Verification:

kubectl auth can-i list units.physical.dcs.io --as=alice   # expect: yes
kubectl auth can-i delete units.physical.dcs.io --as=alice # expect: no
kubectl auth can-i get secrets --as=alice                  # expect: no

8. Secrets Management

Enable etcd encryption at rest for Kubernetes Secrets via EncryptionConfiguration on control plane nodes (aescbc or aesgcm provider).

Secret Contents Rotation
mTLS certificates TLS cert/key + CA cert-manager (automatic)
MQTT passwords Per-role credentials helm upgrade
Runtime auth tokens Bearer token per runtime helm upgrade
E-signature keys HMAC-SHA256 signing key Manual rotation

For vault integration, use the External Secrets Operator (ESO) with ExternalSecret resources referencing your vault paths.

Best practices: Never log secrets. Mount them as read-only volumes, and keep them out of environment variables. Enable Kubernetes audit logging for secret access.

9. Audit Trail and Retention

Enable the AuditRecord webhook for immutability enforcement (21 CFR Part 11) and archival for long-term retention:

webhook:
  enabled: true
  certManager:
    enabled: true

historian:
  enabled: true
  audit:
    archival:
      enabled: true
      schedule: "0 2 * * *"         # daily at 2 AM UTC
      activeRetentionDays: 90       # kept in etcd
      archiveRetentionDays: 1095    # 3 years in PostgreSQL
      batchSize: 500

Back up the PostgreSQL archive with pg_dump and etcd with snapshots.

Verification:

dcs --site newark-plant audit trace batch-20260324-001
# Confirm webhook rejects mutations:
kubectl patch auditrecord test-record -n site-newark-plant \
  --type merge -p '{"spec":{"action":"tampered"}}' 2>&1  # expect: denied
The verification step performed for real: an AuditRecord's spec patch and its deletion both denied by the webhook with the Part 11 clause in the message, a hand-forged archival attestation denied on its recomputed HMAC, and dcs audit verify --archived re-proving the signed archive.

10. Supply Chain Verification

Release images are signed with cosign (Sigstore keyless) and include SPDX SBOMs.

cosign verify ghcr.io/cloud-native-dcs/dcs-gateway:v0.1.0 \
  --certificate-identity-regexp="github.com/your-org/Cloud-Native-DCS" \
  --certificate-oidc-issuer="https://token.actions.githubusercontent.com"

cosign download sbom ghcr.io/cloud-native-dcs/dcs-gateway:v0.1.0

Enforce signed images in the cluster with a Kyverno policy:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: verify-dcs-images
spec:
  validationFailureAction: Enforce
  rules:
    - name: verify-cosign-signature
      match:
        any:
          - resources:
              kinds: ["Pod"]
      verifyImages:
        - imageReferences: ["ghcr.io/cloud-native-dcs/*"]
          attestors:
            - entries:
                - keyless:
                    subject: "https://github.com/your-org/*"
                    issuer: "https://token.actions.githubusercontent.com"

Pin images by digest

Tags are mutable: whoever controls the registry (or a compromised CI token) can re-point a tag at a different image. Every DCS component accepts an optional image.digest, which is preferred over any tag and renders the immutable repository@sha256:... form:

gateway:
  image:
    digest: "sha256:4f5cdc8a6f7e..."   # from `crane digest` or the release notes

Resolve the digest of a release tag with crane digest ghcr.io/cloud-native-dcs/dcs-gateway:v0.1.24 (or docker buildx imagetools inspect). Digests are per-image, so there is no global.image.digest. Pin each component individually (setting the global key fails the render). GitOps consumers can keep digests current automatically with Flux image automation's :digest setter.

11. Monitoring Security Events

Enable ServiceMonitors and Grafana dashboards:

monitoring:
  enabled: true
  serviceMonitor: { enabled: true, interval: 30s }
grafana:
  dashboards: { enabled: true }

Key metrics: dcs_audit_records_created_total, controller-runtime metrics. The MQTT broker logs connection and auth events in the production profile -- forward to your SIEM. Example PrometheusRule alerts:

groups:
  - name: dcs-security
    rules:
      - alert: HighAuditRecordCreationRate
        expr: rate(dcs_audit_records_created_total[5m]) > 10
        for: 5m
        annotations: { summary: "Unusual audit record creation rate" }
      - alert: GatewayRateLimitExceeded
        expr: rate(dcs_gateway_rate_limited_total[5m]) > 5
        for: 2m
        annotations: { summary: "Sustained rate limit rejections" }

Verification: kubectl get servicemonitors -n dcs-system

12. Rate Limiting

Per-IP rate limiting is enabled by default (IEC 62443 SR 7.1).

gateway:
  rateLimit:
    enabled: true
    read:   { requestsPerSecond: 100, burst: 200 }
    write:  { requestsPerSecond: 20,  burst: 40 }
    static: { requestsPerSecond: 500, burst: 1000 }

Requests fall into three tiers, each with its own per-IP bucket:

Tier Matches
read GET/HEAD against the API
write POST/PUT/DELETE (any path)
static GET/HEAD for the UI bundle — /css/, /js/, /vendor/, /favicon.svg, and the app shells (/login, /system, /data, /hmi, /terminal)

Size the read and write tiers in requests. Size the static tier in page loads. A cold-cache shell load fetches roughly 100 files from the embedded bundle, so the default burst of 1000 is about ten cold page views. Before the static tier existed, those files were charged to the read bucket and a single page view spent half of it. Two quick navigations from one IP (or several operator stations behind one NAT) 429'd the shell's own <script> tags, and the page half-booted with only a browser MIME-type error to go on.

Environment Read (RPS/burst) Write (RPS/burst) Static (RPS/burst)
Single operator, manual use 50 / 100 10 / 20 500 / 1000
Multiple operators + HMI clients 100 / 200 20 / 40 500 / 1000
SCADA integration 200 / 400 50 / 100 500 / 1000

The static tier does not scale with SCADA client count, because SCADA integrations call the API and never load the UI. It scales with the number of browsers behind a shared source address. Raise it if many operator stations sit behind one NAT.

Live-stream connection ceiling

Rate limiting bounds requests. What bounds the live stream is a ceiling on concurrent connections per source address, counting WebSocket and SSE together (#1447):

gateway:
  streamLimits:
    maxConnectionsPerIP: 10

Size it in consumers behind one address. A client count is the wrong unit. Ten fits browsers, which is what the default was chosen for. Every case that outgrows it shares one shape: several consumers arriving from one address.

Deployment Ceiling
A handful of operator browsers, distinct IPs 10
Control room behind one NAT 2–3 × the number of stations
Redundant SCADA pair, one egress address 4 × the connections one half opens
Gateway behind a proxy that governs connections itself high enough to defer to it

Count generously. A SCADA that opens one connection per area or per screen spends its allowance faster than its client count suggests, a browser holds a connection until the tab closes, and both halves of a redundant pair subscribe independently.

There is no setting that removes the ceiling. A value below 1 is read as the default of 10, and no value means "unbounded". An unbounded per-address fan-out is the denial-of-service surface SR 7.1 asks the gateway to bound, and a typo in a values file must not be able to take a control out silently. Defer to a front door that governs connections itself by raising the number high enough to stay out of its way.

A client over the ceiling is refused with 429 Too Many Requests and Retry-After: 5 on both transports. The WebSocket handshake is refused outright, with no upgrade followed by a close. A library therefore cannot mistake a quota for a dropped connection. Refusals increment dcs_gateway_stream_connections_refused_total{transport=…}. Alert on it, because the symptom on the plant side is a redundant SCADA half that silently never connected.

13. Ingress TLS Termination

gateway:
  ingress:
    enabled: true
    className: nginx
    hostname: dcs.newark-plant.example.com
    annotations:
      cert-manager.io/cluster-issuer: letsencrypt-prod
    tls:
      - secretName: dcs-gateway-tls
        hosts:
          - dcs.newark-plant.example.com

For air-gapped environments, substitute letsencrypt-prod with an internal CA issuer.

The in-cluster documentation site takes its own hostname and certificate through docs.ingress, which mirrors gateway.ingress field-for-field.

docs:
  ingress:
    enabled: true
    className: nginx
    hostname: docs.newark-plant.example.com
    annotations:
      cert-manager.io/cluster-issuer: letsencrypt-prod
    tls:
      - secretName: dcs-docs-tls
        hosts:
          - docs.newark-plant.example.com

Leaving docs.ingress disabled keeps the documentation reachable under the gateway's /docs path. Either route reaches the docs pod through the -docs NetworkPolicy, which admits the ingress controller named in networkPolicies.gateway.ingressNamespace (§ 5).

Verification:

kubectl get certificate -n dcs-system dcs-gateway-tls
curl -v https://dcs.newark-plant.example.com/healthz 2>&1 | \
  grep "SSL certificate verify ok"

Search-engine indexing

Any gateway reachable from the public internet is also reachable by a crawler. The gateway serves /robots.txt on every host that fronts it, and that file disallows crawling of the entire host. There is no Helm value for this and nothing to turn on.

The blanket rule matters because the ingress can mount more than the gateway on one hostname. With docs.enabled, the in-cluster documentation container answers /docs/ on the same host, serving a full copy of the published documentation site. An indexable gateway host would therefore put a duplicate of every documentation page into the index under a second hostname, splitting the ranking between the two. The documentation image carries a second layer of its own: every response leaves it with X-Robots-Tag: noindex, nofollow.

Verification:

curl -s https://dcs.newark-plant.example.com/robots.txt
curl -sI https://dcs.newark-plant.example.com/docs/ | grep -i x-robots-tag