Skip to content
Pyrula
Pre-release: the packages are not on PyPI yet, so pip install does not resolve. Build from source to try it today.

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.

You serveCommandPackage
Plain @workflow functionspyrula worker --workflow app:process_orderpyrula-workflows
@agent functionspyrula worker --agent app:support --llm-ref app:llmpyrula-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.

Both Dockerfiles are multi-stage, run as uid 10001 and read connection settings from the environment.

examples/deploy/workflow-worker/Dockerfile
# 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_LINKS
ARG PIP_TRUSTED_HOST
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 PIP_NO_CACHE_DIR=1
RUN python -m venv /opt/venv
COPY requirements.txt /tmp/requirements.txt
RUN /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 pyrula
COPY --from=build /opt/venv /opt/venv
WORKDIR /app
COPY 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 10001
EXPOSE 8081
HEALTHCHECK --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 SIGTERM
ENTRYPOINT ["pyrula", "worker"]
CMD ["--workflow", "app:process_order"]

The agent worker differs in its entry point and its model key:

examples/deploy/agent-worker/Dockerfile
# 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_LINKS
ARG PIP_TRUSTED_HOST
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 PIP_NO_CACHE_DIR=1
RUN python -m venv /opt/venv
COPY requirements.txt /tmp/requirements.txt
RUN /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 pyrula
COPY --from=build /opt/venv /opt/venv
WORKDIR /app
COPY 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 10001
EXPOSE 8081
HEALTHCHECK --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 SIGTERM
ENTRYPOINT ["pyrula", "worker"]
CMD ["--agent", "app:support", "--llm-ref", "app:llm"]
Terminal window
cd examples/deploy/workflow-worker
docker 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).

VariableMeaning
PYRULA_STORE_URLvalkey://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_NAMESPACEKey namespace, so environments can share one instance.
PYRULA_POSTGRES_DSNOptional archive of finished runs; workflow entry point only. Run pyrula db migrate first.
PYRULA_WORKER_HEALTH_PORT, PYRULA_WORKER_HEALTH_HOSTProbe listener. The images set 8081 and 0.0.0.0; unset, probes are off.
PYRULA_WORKER_IDStable worker id (workflow entry point), e.g. the pod name.
PYRULA_CLOUD_API_KEYOptional. Reports runs to Pyrula Cloud.
XDG_STATE_HOMECloud connector spool location. The images use /var/lib/pyrula.
ANTHROPIC_API_KEYThe agent example’s model key.

Tuning flags (pyrula worker --help) mirror WorkerOptions.

examples/deploy/docker-compose.yml
# 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 --build
name: 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:

On one machine the store can be a SQLite file on a named volume, with the same image:

examples/deploy/docker-compose.single-host.yml
# 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=3
name: 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.

examples/deploy/kubernetes/worker.yaml
# 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/0
apiVersion: apps/v1
kind: Deployment
metadata:
name: workflow-worker
spec:
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/v1
kind: PodDisruptionBudget
metadata:
name: workflow-worker
spec:
maxUnavailable: 1
selector:
matchLabels: { app: workflow-worker }
  • /livez answers while the event loop turns, so a Valkey outage makes pods not ready instead of restarting them. It returns 503 loop_failed once a background loop dies; a workflow worker then exits non-zero and an agent worker waits for the probe to restart it. /readyz also covers the store.
  • terminationGracePeriodSeconds must exceed the drain (Shutdown).
  • Memory scales with --max-concurrent-runs and 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.
examples/deploy/render/render.yaml
# 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.

On SIGTERM or SIGINT the worker:

  1. turns /readyz to 503 (status: draining) and keeps answering /livez;
  2. stops claiming runs;
  3. 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-timeout twice 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;
  4. releases its shard leases so other workers take them immediately;
  5. 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.

  • Replicas are independent: start more with the same image, URL and namespace.
  • Each workflow’s queue is split into --shard-count shards (64). Workers lease shards and rebalance every --shard-lease-interval (10 s).
  • Raise --max-concurrent-runs before 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.
  • 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).

View this page as Markdown