Deploy a worker
A worker claims runs from the store and executes them. Sleeps, signal waits, children and
schedules need one (Production checklist). The files
below live in examples/deploy/
and use the /livez and /readyz health probes.
Entry point
Section titled “Entry point”| You serve | Command | Package |
|---|---|---|
Plain @workflow functions | pyrula worker --workflow app:process_order | pyrula-workflows |
@agent functions | pyrula worker --agent app:support --llm-ref app:llm | pyrula-agents |
One process serves workflows or agents, not both (CLI reference).
--workflow takes module:attr and repeats; a bare module name serves every workflow the
module registers, and mixing the two forms is refused.
Build the image
Section titled “Build the image”Both Dockerfiles are multi-stage, run as uid 10001 and read connection settings from the environment.
# syntax=docker/dockerfile:1# A production workflow worker. Build from this directory:# docker build -t my-workflow-worker .# Base image pinned by tag and digest; bump both together.ARG PYTHON_IMAGE=python:3.13-slim-bookworm@sha256:93498f36dc7e24ef233bfdf6cc17d4b8bd32d94ee737143e906460d5f5a914c5
FROM ${PYTHON_IMAGE} AS build# Only for installing before the first release: point pip at a directory of wheels# (--build-arg PIP_FIND_LINKS=...). Unset, pip uses the package index.ARG PIP_FIND_LINKSARG PIP_TRUSTED_HOSTENV PIP_DISABLE_PIP_VERSION_CHECK=1 PIP_NO_CACHE_DIR=1RUN python -m venv /opt/venvCOPY requirements.txt /tmp/requirements.txtRUN /opt/venv/bin/pip install -r /tmp/requirements.txt
FROM ${PYTHON_IMAGE}# Fixed uid so a Kubernetes runAsNonRoot check can verify it. The home directory is# the only writable path: it holds the Cloud connector's event spool.RUN useradd --system --uid 10001 --home-dir /var/lib/pyrula --create-home \ --shell /usr/sbin/nologin pyrulaCOPY --from=build /opt/venv /opt/venvWORKDIR /appCOPY app.py .ENV PATH=/opt/venv/bin:$PATH \ PYTHONUNBUFFERED=1 \ XDG_STATE_HOME=/var/lib/pyrula \ PYRULA_WORKER_HEALTH_PORT=8081 \ PYRULA_WORKER_HEALTH_HOST=0.0.0.0# Connection settings (PYRULA_STORE_URL, PYRULA_POSTGRES_DSN, PYRULA_CLOUD_API_KEY)# come from the environment at run time; nothing secret is baked into the image.USER 10001EXPOSE 8081HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --retries=3 \ CMD ["python", "-c", "import urllib.request as u; u.urlopen('http://127.0.0.1:8081/readyz', timeout=2)"]# SIGTERM starts the drain: /readyz turns 503, in-flight runs finish within# --shutdown-grace, then the process exits 0.STOPSIGNAL SIGTERMENTRYPOINT ["pyrula", "worker"]CMD ["--workflow", "app:process_order"]The agent worker differs in its entry point and its model key:
# syntax=docker/dockerfile:1# A production agent worker, served by `pyrula worker`. Build from this directory:# docker build -t my-agent-worker .# Base image pinned by tag and digest; bump both together.ARG PYTHON_IMAGE=python:3.13-slim-bookworm@sha256:93498f36dc7e24ef233bfdf6cc17d4b8bd32d94ee737143e906460d5f5a914c5
FROM ${PYTHON_IMAGE} AS build# Only for installing before the first release: point pip at a directory of wheels# (--build-arg PIP_FIND_LINKS=...). Unset, pip uses the package index.ARG PIP_FIND_LINKSARG PIP_TRUSTED_HOSTENV PIP_DISABLE_PIP_VERSION_CHECK=1 PIP_NO_CACHE_DIR=1RUN python -m venv /opt/venvCOPY requirements.txt /tmp/requirements.txtRUN /opt/venv/bin/pip install -r /tmp/requirements.txt
FROM ${PYTHON_IMAGE}# Fixed uid so a Kubernetes runAsNonRoot check can verify it. The home directory is# the only writable path: it holds the Cloud connector's event spool.RUN useradd --system --uid 10001 --home-dir /var/lib/pyrula --create-home \ --shell /usr/sbin/nologin pyrulaCOPY --from=build /opt/venv /opt/venvWORKDIR /appCOPY app.py .ENV PATH=/opt/venv/bin:$PATH \ PYTHONUNBUFFERED=1 \ XDG_STATE_HOME=/var/lib/pyrula \ PYRULA_WORKER_HEALTH_PORT=8081 \ PYRULA_WORKER_HEALTH_HOST=0.0.0.0# Connection settings (PYRULA_STORE_URL, ANTHROPIC_API_KEY, PYRULA_CLOUD_API_KEY) come# from the environment at run time; nothing secret is baked into the image.USER 10001EXPOSE 8081HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --retries=3 \ CMD ["python", "-c", "import urllib.request as u; u.urlopen('http://127.0.0.1:8081/readyz', timeout=2)"]# SIGTERM starts the drain: /readyz turns 503, in-flight turns finish within# --shutdown-grace, then the process exits 0.STOPSIGNAL SIGTERMENTRYPOINT ["pyrula", "worker"]CMD ["--agent", "app:support", "--llm-ref", "app:llm"]cd examples/deploy/workflow-workerdocker build -t my-workflow-worker .Before the first release is published, build the wheels yourself and pass
--build-arg PIP_FIND_LINKS=<url or path> (plus PIP_TRUSTED_HOST=<host> for plain HTTP).
Configuration
Section titled “Configuration”| Variable | Meaning |
|---|---|
PYRULA_STORE_URL | valkey://host:port/db, or sqlite:////path.db for workers on one host. Workers, clients and APIs in an environment share the URL and namespace. |
PYRULA_VALKEY_NAMESPACE | Key namespace, so environments can share one instance. |
PYRULA_POSTGRES_DSN | Optional archive of finished runs; workflow entry point only. Run pyrula db migrate first. |
PYRULA_WORKER_HEALTH_PORT, PYRULA_WORKER_HEALTH_HOST | Probe listener. The images set 8081 and 0.0.0.0; unset, probes are off. |
PYRULA_WORKER_ID | Stable worker id (workflow entry point), e.g. the pod name. |
PYRULA_CLOUD_API_KEY | Optional. Reports runs to Pyrula Cloud. |
XDG_STATE_HOME | Cloud connector spool location. The images use /var/lib/pyrula. |
ANTHROPIC_API_KEY | The agent example’s model key. |
Tuning flags (pyrula worker --help) mirror
WorkerOptions.
Docker Compose
Section titled “Docker Compose”# A workflow worker and Valkey on one host. From this directory:# docker compose up --build# The agent worker is behind a profile and needs a model key:# ANTHROPIC_API_KEY=... docker compose --profile agents up --buildname: pyrula-workers
services: valkey: image: valkey/valkey:9.1.3 # noeviction: an eviction policy deletes live run journals under memory pressure. # appendonly: without persistence a restart loses every in-flight run. command: ["valkey-server", "--appendonly", "yes", "--maxmemory-policy", "noeviction"] volumes: - valkey-data:/data healthcheck: test: ["CMD", "valkey-cli", "ping"] interval: 5s timeout: 3s retries: 10
workflow-worker: build: ./workflow-worker depends_on: valkey: condition: service_healthy environment: PYRULA_STORE_URL: valkey://valkey:6379/0 PYRULA_VALKEY_NAMESPACE: production # Optional: PYRULA_POSTGRES_DSN archives finished runs; run `pyrula db migrate` first. # Optional: PYRULA_CLOUD_API_KEY reports runs to Pyrula Cloud. PYRULA_CLOUD_API_KEY: ${PYRULA_CLOUD_API_KEY:-} volumes: - workflow-spool:/var/lib/pyrula # The drain takes up to shutdown_grace (30 s) + 3 x cancel_timeout (5 s) + the Cloud # connector's drain deadline (5 s), 50 s in all; the stop grace period outlasts it. stop_grace_period: 60s restart: unless-stopped
agent-worker: profiles: ["agents"] build: ./agent-worker depends_on: valkey: condition: service_healthy environment: PYRULA_STORE_URL: valkey://valkey:6379/0 PYRULA_VALKEY_NAMESPACE: production ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:?set ANTHROPIC_API_KEY to run the agent worker} PYRULA_CLOUD_API_KEY: ${PYRULA_CLOUD_API_KEY:-} volumes: - agent-spool:/var/lib/pyrula stop_grace_period: 60s restart: unless-stopped
volumes: valkey-data: workflow-spool: agent-spool:Single-host Compose
Section titled “Single-host Compose”On one machine the store can be a SQLite file on a named volume, with the same image:
# A workflow worker on one host with a SQLite store: no Valkey service. From this directory:# docker compose -f docker-compose.single-host.yml up --build# More worker containers on this host share the same file:# docker compose -f docker-compose.single-host.yml up --build --scale workflow-worker=3name: pyrula-single-host
services: workflow-worker: build: ./workflow-worker environment: # The file lives on the named volume, on this host's own disk. A bind mount from a # network share, or the same volume used from a second host, is refused at open. PYRULA_STORE_URL: sqlite:////var/lib/pyrula/store/pyrula.db # Optional: PYRULA_CLOUD_API_KEY reports runs to Pyrula Cloud. PYRULA_CLOUD_API_KEY: ${PYRULA_CLOUD_API_KEY:-} volumes: # The store and the Cloud connector's event spool, shared by every container here. - pyrula-data:/var/lib/pyrula # The drain takes up to shutdown_grace (30 s) + 3 x cancel_timeout (5 s) + the Cloud # connector's drain deadline (5 s), 50 s in all; the stop grace period outlasts it. stop_grace_period: 60s restart: unless-stopped
volumes: pyrula-data:--scale workflow-worker=3 runs three workers on the one file, sharing its single writer
(Choosing a store). Back up with
pyrula store backup from inside a container. Kubernetes and Render pods can land on different
nodes, and a second node opening the file is refused, so those recipes use Valkey.
Kubernetes
Section titled “Kubernetes”# A workflow worker Deployment, with the probes and drain time matched to the worker.# Swap the image and the container args for the agent worker (`pyrula worker --agent ...`).# The Secret holds PYRULA_STORE_URL and, optionally, PYRULA_CLOUD_API_KEY and# PYRULA_POSTGRES_DSN:# kubectl create secret generic pyrula-worker \# --from-literal=PYRULA_STORE_URL=valkey://valkey.default.svc:6379/0apiVersion: apps/v1kind: Deploymentmetadata: name: workflow-workerspec: replicas: 3 selector: matchLabels: { app: workflow-worker } template: metadata: labels: { app: workflow-worker } spec: # The worker drains for up to shutdown_grace (30 s) + 3 x cancel_timeout (5 s) + the # Cloud connector's drain deadline (5 s), 50 s in all; the pod gets longer before SIGKILL. terminationGracePeriodSeconds: 60 securityContext: runAsNonRoot: true runAsUser: 10001 containers: - name: worker image: registry.example.com/my-workflow-worker:1.0.0 args: ["--workflow", "app:process_order", "--shutdown-grace", "30"] ports: - { name: health, containerPort: 8081 } env: - { name: PYRULA_WORKER_HEALTH_PORT, value: "8081" } - { name: PYRULA_WORKER_HEALTH_HOST, value: "0.0.0.0" } - { name: PYRULA_VALKEY_NAMESPACE, value: production } # A stable id per pod, so logs and shard leases name the same worker across restarts. - name: PYRULA_WORKER_ID valueFrom: { fieldRef: { fieldPath: metadata.name } } envFrom: - secretRef: { name: pyrula-worker } # Liveness says the event loop turns and, on an agent worker, that no background # loop has died. A store outage must not restart pods, so it is readiness, not # liveness, that watches the store. livenessProbe: httpGet: { path: /livez, port: health } periodSeconds: 10 failureThreshold: 3 readinessProbe: httpGet: { path: /readyz, port: health } periodSeconds: 5 resources: requests: { cpu: 250m, memory: 256Mi } limits: { memory: 512Mi } securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true capabilities: { drop: ["ALL"] } volumeMounts: # The Cloud connector's event spool. emptyDir survives a container restart but not # a pod reschedule; use a PersistentVolumeClaim to keep unsent events across both. - { name: spool, mountPath: /var/lib/pyrula } volumes: - name: spool emptyDir: {}---# A voluntary disruption (node drain, upgrade) evicts a pod with SIGTERM and the drain above.# Evicting every pod at once would stop all claiming until replacements are ready.apiVersion: policy/v1kind: PodDisruptionBudgetmetadata: name: workflow-workerspec: maxUnavailable: 1 selector: matchLabels: { app: workflow-worker }/livezanswers while the event loop turns, so a Valkey outage makes pods not ready instead of restarting them. It returns 503loop_failedonce a background loop dies; a workflow worker then exits non-zero and an agent worker waits for the probe to restart it./readyzalso covers the store.terminationGracePeriodSecondsmust exceed the drain (Shutdown).- Memory scales with
--max-concurrent-runsand payload size; the resource numbers are a starting point. - A worker needs no Service. The probe is unauthenticated, so keep it off any externally reachable one.
Render
Section titled “Render”# Render Blueprint: a workflow worker and the Valkey it needs. Render's Key Value is# Valkey-compatible; `noeviction` keeps live run journals from being evicted.services: - type: worker name: workflow-worker runtime: docker dockerContext: ./workflow-worker dockerfilePath: ./workflow-worker/Dockerfile numInstances: 2 # Render sends SIGTERM and waits this long before SIGKILL; it must outlast the drain # (shutdown_grace 30 s + 3 x cancel_timeout 5 s + the Cloud connector's 5 s = 50 s). maxShutdownDelaySeconds: 60 envVars: - key: PYRULA_STORE_URL fromService: type: keyvalue name: pyrula-valkey property: connectionString - key: PYRULA_VALKEY_NAMESPACE value: production # Optional. Set in the dashboard; never commit a key. - key: PYRULA_CLOUD_API_KEY sync: false - key: PYRULA_RUNTIME_NAME value: render-workflow-worker
- type: keyvalue name: pyrula-valkey plan: starter maxmemoryPolicy: noeviction ipAllowList: []A Render background worker has no HTTP health-check path, so /readyz is not consulted. Render
restarts the instance when the process exits, which a workflow worker does when a loop dies.
Shutdown
Section titled “Shutdown”On SIGTERM or SIGINT the worker:
- turns
/readyzto 503 (status: draining) and keeps answering/livez; - stops claiming runs;
- waits up to
--shutdown-grace(30 s) for in-flight runs, then cancels the rest within one--cancel-timeout(5 s), leaving them for another worker to recover; it then waits up to--cancel-timeouttwice more for store calls, sync steps and sync tools still in a thread (after stopping its loops and at the end), abandoning a hung one and queueing its run for another worker; - releases its shard leases so other workers take them immediately;
- gives the Cloud connector
PYRULA_CLOUD_DRAIN_DEADLINE_S(5 s) to flush, then exits 0.
A second SIGTERM or SIGINT during the drain quits at once with 143 or 130; its runs recover
like a crash’s, once their locks lapse on lock_ttl.
The orchestrator’s kill timer must exceed the sum, 50 s with the defaults (the recipes use 60).
Raise it along with --shutdown-grace.
Scaling
Section titled “Scaling”- Replicas are independent: start more with the same image, URL and namespace.
- Each workflow’s queue is split into
--shard-countshards (64). Workers lease shards and rebalance every--shard-lease-interval(10 s). - Raise
--max-concurrent-runsbefore adding replicas when runs wait on I/O. Add replicas for CPU or to survive a node loss. - Drain a workflow before changing its
--shard-count(Scaling across shards). - After a crash, runs recover on the orphan scan (lock TTL plus up to
--orphan-scan-interval, 60 s) and shards after--shard-lease-ttl(30 s). A running step is run again, so side-effecting steps should be idempotent.
Limits
Section titled “Limits”- Valkey is required for workers on more than one host; SQLite serves one host
(Choosing a store), and Postgres only archives finished
runs. The server must be Valkey 7.2+ or Redis 6.0+, persistent, with
noeviction(checklist). - Without a persistent volume at
/var/lib/pyrula, unsent Cloud events are lost with the container (details).