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 |
|---|---|
|
Must be |
|
Set to |
|
Port to listen on (default: |
|
How long to wait for responses (default: 60 s) |
|
HTTP cache headers for API responses |
|
Restrict CORS origins in production |
Security¶
Use TLS in production — configure
EDRI_SSL_KEYandEDRI_SSL_CERTIFICATE, or terminate TLS at a reverse proxy.Set
EDRI_CORS_ORIGINSto your actual domain(s).Set
EDRI_CORS_CREDENTIALSonly if your application needs cookies/auth headers from browsers.Consider
EDRI_MAX_BODY_SIZEandEDRI_MAX_FILE_SIZElimits to prevent large payload attacks.Everything under
EDRI_ASSETS_PATHis 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_FILEbacked 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
ClusterIPin Kubernetes (notLoadBalancerorNodePort).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_PATHto 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
EDRIinstance 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=Truecross 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 (2026.10.03):
FROM python:3.13-alpine
ARG EDRI_VERSION=2026.10.03
WORKDIR /app
RUN pip install "edri[uvicorn]==${EDRI_VERSION}"
COPY . .
CMD ["python", "main.py"]
docker build --build-arg EDRI_VERSION=2026.10.03 -t my-app .
Pass 2026.10.03 as the build argument above; the 2026.10.03 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: 2026.10.03
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 2026.10.03 as theEDRI_VERSIONbuild 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
WorkerProcesschildren, 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 setreplicas > 1without 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-secretSecret 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 |
|
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 |
|
Returns HTTP 200 when all components report ready. Returns HTTP 503 if any component reports |
Startup |
|
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 |
|---|---|
|
SharedMemoryPipe slot size (default: 256 KiB) |
|
Set to your maximum expected payload size |
|
Lower for fast APIs, raise for slow workers |
|
Increase if Switch outages are common |
|
Adjust based on expected Manager latency |
|
Enable CPython's experimental JIT; helps only CPU-bound handler code — measure first |