# 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