Deployment

This guide covers running EDRI in production environments.


Production checklist

Before deploying to production, review these items:

Environment variables

Set ENVIRONMENT=production:

export ENVIRONMENT=production

This affects:

Behavior

Development

Production

API response timing

Included in responses

Stripped from responses

Watcher debounce delay

1 second

10 seconds

Error detail in responses

Full traceback

Minimal (configure per app)

Required configuration

Variable

Why it matters

ENVIRONMENT

Must be production for production mode

EDRI_HOST

Set to 0.0.0.0 to bind on all interfaces

EDRI_PORT

Port to listen on (default: 8080)

EDRI_API_RESPONSE_TIMEOUT

How long to wait for responses (default: 60 s)

EDRI_API_CACHE_CONTROL

HTTP cache headers for API responses

EDRI_CORS_ORIGINS

Restrict CORS origins in production

Security

  • Use TLS in production — configure EDRI_SSL_KEY and EDRI_SSL_CERTIFICATE, or terminate TLS at a reverse proxy.

  • Set EDRI_CORS_ORIGINS to your actual domain(s).

  • Set EDRI_CORS_CREDENTIALS only if your application needs cookies/auth headers from browsers.

  • Consider EDRI_MAX_BODY_SIZE and EDRI_MAX_FILE_SIZE limits to prevent large payload attacks.

  • Everything under EDRI_ASSETS_PATH is served publicly without middleware; keep private files out of it and serve static assets from a reverse proxy — see Static files.

  • Review error responses — in production, you may want to wrap validation errors rather than exposing raw exception chains.

Switch and Connector security

The Switch and Connector authenticate with a pre-shared key but do not encrypt traffic, and both sides exchange Python pickle. Add these to your deployment checklist:

  • Pre-shared key: Set EDRI_SWITCH_SECRET (or, preferably, EDRI_SWITCH_SECRET_FILE backed by a mounted Docker/Kubernetes secret) on the Switch and every Connector to the same value, at least 32 bytes. A Switch bound to any non-loopback address refuses to start without one, and so does a Connector dialing a non-loopback Switch. The key is cluster-wide — it authenticates against outsiders, not between members — so control who holds it.

  • Network isolation: The Switch port (default 8899) must not be exposed to the public internet. Set the Switch Service to ClusterIP in Kubernetes (not LoadBalancer or NodePort).

  • Firewall / NetworkPolicy: Restrict access to the Switch port to only the application pods that run a Connector. See Security for a Kubernetes NetworkPolicy example.

  • Transport encryption: The per-frame MAC authenticates but does not encrypt. Use a VPN, WireGuard tunnel, mTLS proxy, or service mesh (e.g. Istio) to encrypt Switch traffic across data centres.

  • Version compatibility: All connected instances must run the exact same EDRI CalVer version (see Version compatibility).

  • Event schema coordination: Event class definitions must match across all connected instances, at the same import path. Adding, removing, or renaming fields on a shareable event will cause deserialisation errors on peers running different code. Separate deployments should share them from one versioned package both sides depend on — see Event.

  • Logging: Error-level logging may include serialised event contents. Configure log levels appropriately in production and avoid logging sensitive payloads.

  • Monitoring: The Switch is single-instance and in-memory and preserves nothing across a reconnect. Monitor its process health and restart it promptly on failure. The Router cache (EDRI_CACHE_TIMEOUT) is the only safety net for short outages — the Connector replays missed events from it on reconnect.

Upload storage

  • Set EDRI_UPLOAD_FILES_PATH to a dedicated directory.

  • Do not use a shared system-wide temp directory for untrusted production uploads.

  • Put filesystem quotas or container volume limits on the upload directory.

  • EDRI does not automatically delete uploaded files; application code is responsible for cleanup. See Uploads.

  • If workers process uploaded files, ensure the upload directory is visible to worker processes.


Deployment architectures

Single node (simplest)

        flowchart TD
    subgraph Machine["Single machine"]
        App["EDRI App<br/>(Router + Managers + API)"]
    end
    
  • One EDRI instance with all components.

  • Everything runs locally — no Switch needed.

  • Suitable for low-to-medium traffic, single-instance deployments.

  • Internally, EDRI uses multiple processes (main process + Manager child processes + API listener + Broker).

Multi-server with Switch

        flowchart TD
    subgraph M1["Machine 1 — EDRI App"]
        direction TB
        R1[Router A]
        R1 --- Mg1[Manager 1] & Mg2[Manager 2] & API1[API]
        C1[Connector]
    end
    subgraph M2["Machine 2 — EDRI App"]
        direction TB
        R2[Router B]
        R2 --- Mg3[Manager 3] & Mg4[Manager 4] & API2[API]
        C2[Connector]
    end
    SW["Switch (Machine 3)<br/>receives router events<br/>forwards by UUID"]
    C1 -->|TCP/IP| SW
    C2 -->|TCP/IP| SW
    
  • Each machine runs an EDRI instance with a Connector.

  • The Switch runs on a dedicated machine (or container).

  • Events marked as shareable=True cross the network.

  • Use this for horizontal scaling or geographic distribution.

With load balancer

        flowchart TD
    LB["Load Balancer (HAProxy / Nginx)"]
    LB --> E1["EDRI #1<br/>API only"]
    LB --> E2["EDRI #2<br/>API only"]
    LB --> E3["EDRI #3<br/>API only"]
    E1 --> SW[Switch]
    E2 --> SW
    E3 --> SW
    SW --> BE["EDRI Backend<br/>(Managers + Store)"]
    
  • Stateless API nodes behind a load balancer.

    • API nodes only contain the API layer and optionally a Broker.

    • No persistent state — they can be scaled horizontally.

  • A single (or primary) backend EDRI runs Managers that own state.

    • The Key-Value Store runs on the backend node.

    • Workers execute on the backend node.

  • The Switch ties everything together.


Docker

Building your application image

Build with the documented version (development):

FROM python:3.13-alpine

ARG EDRI_VERSION=<VERSION>

WORKDIR /app

RUN pip install "edri[uvicorn]==${EDRI_VERSION}"

COPY . .

CMD ["python", "main.py"]
docker build --build-arg EDRI_VERSION=<VERSION> -t my-app .

Pass development as the build argument above; the <VERSION> placeholder in the Dockerfile is replaced with the actual version at documentation build time.

Using Docker Compose

# docker-compose.yml
services:
  switch:
    image: blastycz/edri:switch@sha256:<digest>
    expose:
      - "8899"
    environment:
      EDRI_SWITCH_HOST: 0.0.0.0          # reachable from the app container
      EDRI_SWITCH_SECRET_FILE: /run/secrets/edri_switch_secret
    secrets:
      - edri_switch_secret
    restart: unless-stopped

  app:
    build:
      context: .
      args:
        EDRI_VERSION: <VERSION>
    depends_on:
      - switch
    environment:
      ENVIRONMENT: production
      EDRI_SWITCH_HOST: switch
      EDRI_SWITCH_PORT: 8899
      EDRI_SWITCH_SECRET_FILE: /run/secrets/edri_switch_secret
      EDRI_HOST: 0.0.0.0
      EDRI_PORT: 8080
    secrets:
      - edri_switch_secret
    ports:
      - "8080:8080"
    restart: unless-stopped

secrets:
  edri_switch_secret:
    file: ./edri_switch_secret.txt   # a single line, at least 32 bytes

Replace <digest> with the SHA256 digest of the published Switch image. Build the application image with development as the EDRI_VERSION build argument (see Building your application image above).


Reverse proxy (Nginx)

server {
    listen 443 ssl;
    server_name api.example.com;

    ssl_certificate /etc/ssl/certs/example.crt;
    ssl_certificate_key /etc/ssl/private/example.key;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # Increase body size for file uploads
    client_max_body_size 100M;
}

The proxy_set_header Upgrade and Connection lines are required for WebSocket support.


Process management

Systemd service

[Unit]
Description=EDRI Application
After=network.target

[Service]
Type=simple
User=edri
WorkingDirectory=/opt/myapp
Environment=ENVIRONMENT=production
Environment=EDRI_HOST=0.0.0.0
ExecStart=/opt/myapp/.venv/bin/python main.py
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

Supervisor

[program:edri]
command=/opt/myapp/.venv/bin/python main.py
directory=/opt/myapp
environment=ENVIRONMENT="production",EDRI_HOST="0.0.0.0"
user=edri
autostart=true
autorestart=true
stderr_logfile=/var/log/edri.err.log
stdout_logfile=/var/log/edri.out.log

Graceful shutdown

EDRI.run() shuts down cleanly on both SIGINT (Ctrl-C) and SIGTERM — the signal process managers send by default. On either signal it stops the Router, then terminates and joins every child process it started (Managers, Store, Scheduler, Broker, Listener, GRPCListener, Connector) and stops the Watcher.

No special configuration is needed: systemctl stop, docker stop, and Kubernetes pod termination all work out of the box. Because the main process explicitly tears down its children, shutdown is clean even when only the main process receives the signal (Docker's PID-1 model), not just when the whole process group does (systemd's default KillMode=control-group).

Child processes deliberately are not daemons — a Manager may spawn WorkerProcess children, and Python forbids daemonic processes from having children. The main process therefore reaps them itself during shutdown.


Kubernetes

Single-node app (no Switch)

# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: edri-app
spec:
  replicas: 1          # EDRI uses multiple child processes — do NOT scale beyond 1 without Switch
  selector:
    matchLabels:
      app: edri-app
  template:
    metadata:
      labels:
        app: edri-app
    spec:
      containers:
        - name: app
          # Use a specific release tag (not latest) for reproducible deployments
          image: your-registry/edri-app:<version>
          ports:
            - containerPort: 8080
          env:
            - name: ENVIRONMENT
              value: production
            - name: EDRI_HOST
              value: "0.0.0.0"
            - name: EDRI_PORT
              value: "8080"
          resources:
            requests:
              cpu: "250m"
              memory: "256Mi"
            limits:
              cpu: "1000m"
              memory: "512Mi"
---
apiVersion: v1
kind: Service
metadata:
  name: edri-app
spec:
  selector:
    app: edri-app
  ports:
    - port: 80
      targetPort: 8080

Important: EDRI runs multiple Python processes internally via multiprocessing. Do not set replicas > 1 without a Switch, or each replica will be a fully independent instance with no shared state or event routing between them.

Multi-node with Switch

# switch-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: edri-switch
spec:
  replicas: 1   # Switch is single-instance; in-memory only
  selector:
    matchLabels:
      app: edri-switch
  template:
    metadata:
      labels:
        app: edri-switch
    spec:
      containers:
        - name: switch
          image: blastycz/edri:switch@sha256:<digest>
          ports:
            - containerPort: 8899
          env:
            - name: EDRI_SWITCH_HOST
              value: "0.0.0.0"
            - name: EDRI_SWITCH_PORT
              value: "8899"
            - name: EDRI_SWITCH_SECRET        # required for a non-loopback bind
              valueFrom:
                secretKeyRef:
                  name: edri-switch-secret
                  key: secret
---
apiVersion: v1
kind: Secret
metadata:
  name: edri-switch-secret
type: Opaque
stringData:
  # openssl rand -base64 32
  secret: "<32+ byte shared key>"
---
apiVersion: v1
kind: Service
metadata:
  name: edri-switch
spec:
  type: ClusterIP
  selector:
    app: edri-switch
  ports:
    - port: 8899
      targetPort: 8899
# app-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: edri-app
spec:
  replicas: 2   # Safe for stateless Managers with idempotent handlers only
  selector:
    matchLabels:
      app: edri-app
  template:
    metadata:
      labels:
        app: edri-app
    spec:
      containers:
        - name: app
          image: your-registry/edri-app:<version>
          env:
            - name: ENVIRONMENT
              value: production
            - name: EDRI_HOST
              value: "0.0.0.0"
            - name: EDRI_PORT
              value: "8080"
            - name: EDRI_SWITCH_HOST
              value: edri-switch
            - name: EDRI_SWITCH_PORT
              value: "8899"
            - name: EDRI_SWITCH_SECRET        # must match the Switch
              valueFrom:
                secretKeyRef:
                  name: edri-switch-secret
                  key: secret
          ports:
            - containerPort: 8080

The app pods need the same edri-switch-secret Secret shown above (create it in each namespace that runs a Connector).

Fan-out warning: The Switch distributes matching events to every subscribed Manager. Two replicas that contain the same Manager will both receive and handle every matching event independently — this is fan-out, not load balancing. Side-effect handlers (payments, email, external API calls) will execute twice unless they are idempotent. For competing-consumer semantics, use Workers inside a single Manager rather than multiple replicas. See Singleton operations for coordination patterns.

WebSocket and sticky sessions

If you use WebSocket connections behind a load balancer, each WS session must stay on the same replica for its lifetime. Configure session affinity (sticky sessions):

# ingress.yaml — nginx-ingress example
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: edri-ingress
  annotations:
    nginx.ingress.kubernetes.io/affinity: "cookie"
    nginx.ingress.kubernetes.io/session-cookie-name: "EDRI_ROUTE"
    nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
    nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
spec:
  rules:
    - host: api.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: edri-app
                port:
                  number: 80

The long proxy-read-timeout and proxy-send-timeout are required to keep WS connections alive.

Health probes

EDRI provides three built-in endpoints for health monitoring — separate concerns from alive to ready to diagnostic:

Probe

Endpoint

Behaviour

Liveness

/edri/live

Returns HTTP 200 as long as the Router event loop is running. Does not depend on component readiness — a transient dependency failure will not restart the pod.

Readiness

/edri/ready

Returns HTTP 200 when all components report ready. Returns HTTP 503 if any component reports ready=false (e.g. warming up, draining, or a dependency is unreachable).

Startup

/edri/live

Same as liveness — the process is alive and accepting connections.

No application code is needed for any of these endpoints. For component-level health details (per-Manager status, vitals), see the Health checking page. To override the built-in health probe, see Custom health endpoint below.

Define Kubernetes probes:

livenessProbe:
  httpGet:
    path: /edri/live
    port: 8080
  initialDelaySeconds: 10
  periodSeconds: 15
  timeoutSeconds: 5
  failureThreshold: 3
readinessProbe:
  httpGet:
    path: /edri/ready
    port: 8080
  initialDelaySeconds: 5
  periodSeconds: 10
  timeoutSeconds: 5
  failureThreshold: 1
startupProbe:
  httpGet:
    path: /edri/live
    port: 8080
  initialDelaySeconds: 3
  periodSeconds: 5
  failureThreshold: 30
  • Liveness and startup probe /edri/live — Router event loop is running, independent of component readiness.

  • Readiness probe /edri/ready — blocks traffic until all components are ready.

  • Startup probe gives the application up to 150 seconds (30 × 5) to initialise before liveness kicks in.


Monitoring

Health status endpoint

EDRI's Router includes a built-in HealthChecker that exposes an aggregated health view at:

  • GET /edri/health-check-status (built-in, no code required).

The response is JSON (or HTML via content negotiation) with per-component records. See Health checking for the full schema.

Custom health endpoint (optional)

If you need a custom health endpoint for load balancer probes that should return simple OK/not-OK without the full component detail:

@api(
    url="/health",
    method=HTTPMethod.GET,
    exclude={ApiType.HTML, ApiType.WS},
)
class AppHealthCheck(Event):
    response: EmptyResponse

This requires a Manager with a matching @request handler to set the response status. For most cases the built-in /edri/health-check-status is sufficient.

Logging

EDRI uses Python's standard logging module. Configure logging at application startup:

import logging
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)

Performance tuning

Variable

Recommendation

EDRI_CHUNK_SIZE

SharedMemoryPipe slot size (default: 256 KiB)

EDRI_MAX_BODY_SIZE

Set to your maximum expected payload size

EDRI_API_RESPONSE_TIMEOUT

Lower for fast APIs, raise for slow workers

EDRI_CACHE_TIMEOUT

Increase if Switch outages are common

EDRI_HEALTH_CHECK_TIMEOUT

Adjust based on expected Manager latency

PYTHON_JIT=1

Enable CPython's experimental JIT; helps only CPU-bound handler code — measure first