# Deployment
This guide covers running EDRI in production environments.
---
## Production checklist
Before deploying to production, review these items:
### Environment variables
Set `ENVIRONMENT=production`:
```bash
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 {doc}`/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 {ref}`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 {ref}`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 {doc}`/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 {doc}`/uploads`.
* If workers process uploaded files, ensure the upload directory is visible to worker processes.
---
## Deployment architectures
### Single node (simplest)
```mermaid
flowchart TD
subgraph Machine["Single machine"]
App["EDRI App
(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
```mermaid
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)
receives router events
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
```mermaid
flowchart TD
LB["Load Balancer (HAProxy / Nginx)"]
LB --> E1["EDRI #1
API only"]
LB --> E2["EDRI #2
API only"]
LB --> E3["EDRI #3
API only"]
E1 --> SW[Switch]
E2 --> SW
E3 --> SW
SW --> BE["EDRI Backend
(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 (**{{ VERSION }}**):
```dockerfile
FROM python:3.13-alpine
ARG EDRI_VERSION=
WORKDIR /app
RUN pip install "edri[uvicorn]==${EDRI_VERSION}"
COPY . .
CMD ["python", "main.py"]
```
```bash
docker build --build-arg EDRI_VERSION= -t my-app .
```
Pass **{{ VERSION }}** as the build argument above; the ```` placeholder in the Dockerfile is replaced with the actual version at documentation build time.
### Using Docker Compose
```yaml
# docker-compose.yml
services:
switch:
image: blastycz/edri:switch@sha256:
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:
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 `` with the SHA256 digest of the published Switch image. Build the application image with **{{ VERSION }}** as the ``EDRI_VERSION`` build argument (see [Building your application image](#building-your-application-image) above).
---
## Reverse proxy (Nginx)
```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
```ini
[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
```ini
[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)
```yaml
# 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:
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
```yaml
# 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:
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
```
```yaml
# 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:
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 {ref}`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):
```yaml
# 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 {doc}`/health` page. To override the built-in health probe, see [Custom health endpoint](#custom-health-endpoint-optional) below.
Define Kubernetes probes:
```yaml
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 {doc}`/health` 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:
```python
@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:
```python
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 |
---
## Related
* {doc}`/env` — full environment variable reference
* {doc}`/switch` — running the Switch in production
* {doc}`/connector` — Connector configuration
* {doc}`/grpc` — the optional gRPC front end (`start_grpc()`), a single process like the ASGI Listener with no built-in multi-worker scaling