Deployment¶
wfm ships as one container image that runs in three modes, and one Helm chart that deploys
them the same way on k3s and on EKS. Only values change between clusters. Argo CD is the
expected way to apply it, but plain helm works too.
internet corporate network / VPN
│ │
public LB / Traefik / ALB internal LB / Traefik / ALB
(only /hooks, /scim) (UI, /api, /auth, SSE)
│ │
┌───────▼───────┐ ┌───────▼───────┐
│ wfm-hooks │ │ wfm-web │──► IdPs (OIDC), SCM APIs, S3
│ HPA, PDB │ │ HPA, PDB │
└───────┬───────┘ └───────┬───────┘
│ ┌────────────────────┐ │
└───────►│ PostgreSQL 17 │◄─────────────┘
│ CloudNativePG │◄──── KEDA (queue depth)
│ WAL → S3 │
└─────────▲──────────┘
│
┌───────┴───────┐
│ wfm-worker │──► SCM APIs, outbound webhooks, Google directory, S3
│ KEDA, PDB │
└───────────────┘
| Component | Command | Serves | Exposure |
|---|---|---|---|
| web | wfm serve --mode=web |
UI, /api/v1, /auth/*, SSE at /api/v1/events on :8080 |
internal ingress |
| hooks | wfm serve --mode=hooks |
POST /hooks/{provider}/{connectionId}, /scim/v2/* on :8080 |
the only public ingress |
| worker | wfm serve --mode=worker |
River job workers | none |
| migrate | wfm migrate |
schema migrations (Job) | none |
Every mode serves /metrics, /healthz and /readyz on :9090; web and hooks also serve
/healthz (liveness) and /readyz (pings the database) on :8080. Configuration is environment
variables only (configuration.md); logs are JSON on stdout.
Artifacts¶
Each vX.Y.Z tag runs .github/workflows/release.yml:
| Artifact | Where |
|---|---|
Image (linux/amd64 + linux/arm64, distroless static-debian12:nonroot, UID 65532) |
ghcr.io/workflowproj/workflow:X.Y.Z |
| Keyless Cosign signature, SPDX SBOM attestation (Syft), SLSA provenance (BuildKit) | same repository |
| Helm chart (OCI, Cosign-signed) | oci://ghcr.io/workflowproj/charts/wfm version X.Y.Z |
| Offline bundles (amd64, arm64), SBOM, checksums | GitHub release assets |
Verify before deploying (or enforce it with Kyverno / the Sigstore policy-controller):
IMG=ghcr.io/workflowproj/workflow@sha256:...
cosign verify "$IMG" \
--certificate-identity-regexp '^https://github.com/workflowproj/workflow/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
cosign verify-attestation "$IMG" --type spdxjson \
--certificate-identity-regexp '^https://github.com/workflowproj/workflow/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
Pin image.digest in production; the tag is then ignored.
Build locally with make image (or docker build --build-arg VERSION=dev .). The Dockerfile
builds the UI in a Node 22 stage, embeds it in a static Go binary (CGO_ENABLED=0, -trimpath,
version stamped through -ldflags), and copies only the binary into distroless.
--build-arg UI_STAGE=ui-none skips the UI (the binary then serves a "UI not built" page).
Prerequisites¶
| k3s | EKS | |
|---|---|---|
| Kubernetes | 1.30+ (the chart uses preStop.sleep) |
1.30+ |
| CloudNativePG operator | 1.30.x (kubectl apply --server-side -f https://raw.githubusercontent.com/cloudnative-pg/cloudnative-pg/release-1.30/releases/cnpg-1.30.1.yaml, or its Helm chart) |
same |
| Ingress | bundled Traefik | AWS Load Balancer Controller (or Envoy Gateway via gatewayAPI.*) |
| TLS | cert-manager (DNS-01 for internal names) | ACM certificates on the ALBs |
| Secrets | SOPS (helm-secrets / KSOPS) or a pre-created Secret | External Secrets Operator ≥ 0.17 (external-secrets.io/v1) |
| Worker autoscaling | optional (fixed replicas, or a CPU HPA) | KEDA 2.x |
| NetworkPolicy enforcement | built in | VPC CNI with enableNetworkPolicy: "true" (or Cilium/Calico) |
| Storage | local-path |
EBS CSI driver with a gp3 StorageClass |
| Metrics | optional | Prometheus Operator (serviceMonitor.enabled), with a Grafana dashboard (grafanaDashboard.enabled) and alerts (prometheusRule.enabled) |
Every CRD-backed template (CNPG Cluster, KEDA, ExternalSecret, ServiceMonitor, PrometheusRule, HTTPRoute)
is off unless enabled by values, except the CNPG cluster, which is on by default. Disable it with
database.cnpg.enabled=false and point at another PostgreSQL (below).
The chart¶
deploy/helm/wfm — the main values (see values.yaml for every option; values.schema.json
rejects typos):
| Values | Purpose |
|---|---|
image.* |
repository, tag (defaults to the chart's appVersion), digest |
config |
non-secret env → ConfigMap loaded by every pod (WFM_*, OTEL_*) |
secrets.* |
secret env: existingSecret, a chart-created Secret, or an ExternalSecret |
database.cnpg.* / database.external.* |
CloudNativePG cluster, or any PostgreSQL 17 |
web, hooks, worker |
replicas, resources, autoscaling (HPA), worker.keda, pdb, topologySpread, scheduling, probes, extra env/volumes |
migrations.* |
the wfm migrate Job and its hook wiring |
ingress.web, ingress.hooks |
two Ingresses: internal UI/API and public /hooks + /scim |
gatewayAPI.web, gatewayAPI.hooks |
the same as Gateway API HTTPRoutes |
networkPolicy.* |
default-deny ingress, per-component ingress/egress, database ingress |
serviceMonitor.* |
Prometheus Operator scraping |
grafanaDashboard.* |
the "Workflow" Grafana dashboard as a ConfigMap for the kube-prometheus-stack sidecar (label grafana_dashboard: "1"): traffic, 5xx ratio, latency and slowest routes, request limits, background jobs, Git webhooks, live streams, CPU/memory/goroutines. The JSON is also in deploy/helm/wfm/dashboards/wfm.json to import by hand |
prometheusRule.* |
alerts: high server-error ratio, slow requests (p95), failing jobs, failing Git webhooks, a component with no healthy target; thresholds in values |
extraObjects |
extra manifests (Traefik middlewares, SecretStores, ...) |
Example overlays: values-k3s.yaml (one node, one CNPG instance, Traefik, SOPS) and
values-eks.yaml (3 CNPG instances across zones with WAL archiving to S3, ALBs, External Secrets,
KEDA, ServiceMonitor). deploy/k3d/values-dev.yaml is the development/CI profile.
Pods run as UID 65532 with a read-only root filesystem (an emptyDir on /tmp), no capabilities,
RuntimeDefault seccomp, no service account token, and GOMEMLIMIT at 90% of the memory limit.
Database¶
CloudNativePG (default)¶
The chart creates a postgresql.cnpg.io/v1 Cluster named <release>-db (database.cnpg.name)
with database and owner wfm. The operator generates the Secret <cluster>-app; the app, the
migration job and KEDA read WFM_DATABASE_URL from its uri key. The Cluster carries
helm.sh/resource-policy: keep and argocd.argoproj.io/sync-options: Prune=false,Delete=false,
so deleting the release or the Argo CD Application never deletes the database.
- k3s:
instances: 1onlocal-path. Single-node means no failover; enable backups. - EKS:
instances: 3with required zone anti-affinity, separate WAL volumes, synchronous failover handled by CNPG; PodDisruptionBudgets come from the operator. - Connections: each app pod opens up to 10 connections (more on large nodes). Without a pooler,
size
max_connectionsfor(web + hooks + worker max replicas) × 10 + 20. Once replica counts make that large, turn on the pooler below.
Connection pooling¶
database.cnpg.pooler.enabled: true puts PgBouncer in front of the primary (a CNPG Pooler
named <cluster>-pooler-rw, transaction mode), so adding app pods adds client connections to
PgBouncer rather than PostgreSQL sessions.
database:
cnpg:
pooler:
enabled: true
instances: 2
parameters:
default_pool_size: "40" # server connections per database/user
max_client_conn: "1000"
max_prepared_statements: "200" # pgx prepared statements in transaction mode
What changes when it is on:
| Connection | Goes to | Why |
|---|---|---|
| App queries (web, hooks, worker) | the pooler (WFM_DATABASE_POOLER_HOST) |
transaction pooling keeps PostgreSQL sessions flat as pods scale |
| Live events on each web pod | the primary, one session per pod | LISTEN needs a session of its own, which transaction pooling cannot give |
| River (worker) | the pooler, in poll-only mode | workers look for jobs every worker.pollInterval (default 1s) instead of LISTENing; leader election also polls |
| Migration job, KEDA scaler | the primary | schema changes and the queue-depth query use the direct address in <cluster>-app |
Size default_pool_size for the busiest moment: roughly the jobs and requests that run at
once, not the number of pods. PostgreSQL then needs max_connections of about
default_pool_size × pooler instances + web replicas + 20. Prepared statements need PgBouncer
1.21 or later with max_prepared_statements above zero (the CNPG pooler images qualify). The
app uses no session state (SET, advisory locks, temporary tables), so transaction mode is
safe. With network policies on, the chart allows app pods to reach the pooler, the pooler to
reach the primary, and Prometheus to scrape the pooler.
For an external PostgreSQL behind your own PgBouncer, set WFM_DATABASE_URL to the pooler and
WFM_DATABASE_DIRECT_URL to the server itself (through secrets.* or extraEnv).
Backups and restore¶
database.cnpg.backup turns on continuous WAL archiving plus a daily base backup
(ScheduledBackup, six-field cron) to S3 or any S3-compatible store:
method: barmanObjectStoreuses CNPG's in-tree Barman Cloud support (deprecated upstream since 1.26 but still served); it needs a*-system-*PostgreSQL image (the default).method: pluginuses the Barman Cloud Plugin: install the plugin, then the chart creates abarmancloud.cnpg.io/v1ObjectStoreand wires it as the WAL archiver.- Credentials: on EKS use IRSA (
s3Credentials.inheritFromIAMRole: trueandserviceAccountAnnotations: {eks.amazonaws.com/role-arn: ...}); elsewhere a Secret with access keys (s3Credentials.secretName), plusendpointURLfor MinIO/Garage/SeaweedFS.
Check with kubectl cnpg status <cluster> or kubectl get backups. To restore (whole cluster or
point in time), bootstrap a new cluster from the object store, then switch the app to it:
database:
cnpg:
name: wfm-db-restored
bootstrap:
recovery:
source: origin
recoveryTarget: {targetTime: "2026-10-20T09:00:00Z"} # omit for the latest WAL
externalClusters:
- name: origin
barmanObjectStore: # same destinationPath/s3Credentials as the original cluster
destinationPath: s3://example-wfm-backups/prod
serverName: wfm-db
s3Credentials: {inheritFromIAMRole: true}
Give the restored cluster a different backup destinationPath (or serverName) so the two
clusters never archive into the same place.
External PostgreSQL¶
Set database.cnpg.enabled=false and one of:
database.external.existingSecret/existingSecretKey: a Secret holding the URL (RDS via External Secrets, for example);database.external.url: rendered into a chart Secret (development);- nothing: the app Secret (
secrets.*) must containWFM_DATABASE_URL.
With NetworkPolicies on, list the database's addresses in networkPolicy.egress.database.to.
Secrets¶
All keys of the app Secret become environment variables (envFrom). It must contain
WFM_SECRET_KEY (32+ characters; keep it stable, it encrypts stored SCM keys and webhook secrets)
and whatever else is secret: WFM_GOOGLE_CLIENT_SECRET, WFM_OIDC_<ID>_CLIENT_SECRET,
WFM_GOOGLE_SERVICE_ACCOUNT_KEY, WFM_KEYCLOAK_CLIENT_SECRET, WFM_S3_ACCESS_KEY/WFM_S3_SECRET_KEY
(omit with IRSA).
- Production (EKS): External Secrets.
secrets.externalSecret.enabled=truewith aClusterSecretStore(AWS Secrets Manager, Vault, ...).dataFrom.extractmaps a JSON document to env vars;datamaps individual keys. - k3s: SOPS. Keep
secrets.valuesin a SOPS-encrypted file next to your values and let Argo CD decrypt it (the helm-secrets plugin with asecrets://value file, or KSOPS), orhelm secrets upgrade ...from a workstation. Alternatively create the Secret yourself (Sealed Secrets,kubectl create secret) and setsecrets.existingSecret. secrets.createwith plain values is for development only. WithWFM_ENV=productionthe chart refuses to render withoutWFM_SECRET_KEY.
Pods roll automatically when chart-rendered config or the chart-created Secret changes. For
externally managed Secrets, add Stakater Reloader (podAnnotations) or restart the Deployments.
Migrations and upgrades¶
wfm migrate applies the goose migrations, then River's queue schema. It retries the connection
for about a minute, and the Job retries after that (migrations.backoffLimit).
Migrations are expand, then contract: a release only adds (tables, nullable columns, indexes
built concurrently, new enum values) and backfills in jobs; it removes or tightens things in a
later release, once no running version uses them. So the new schema is applied while the old
pods are still serving, then the Deployments roll with maxUnavailable: 0, and a rollback of the
Deployments is always safe (the schema is never rolled back).
How the Job is ordered:
| Tool | Wiring | Order |
|---|---|---|
| Argo CD | argocd.argoproj.io/hook: Sync, sync wave -1, BeforeHookCreation |
wave −3: ServiceAccount, ConfigMap, Secrets/ExternalSecret, NetworkPolicies → wave −2: CNPG Cluster (Argo CD waits until Healthy) → wave −1: migration Job (must succeed) → wave 0: Deployments, Services, Ingresses, autoscalers |
| Helm | helm.sh/hook: post-install,pre-upgrade |
first install: after all objects exist; upgrades: before anything changes |
Why not a PreSync hook: PreSync runs before any object of the release exists, so on the first
install the job would wait forever for a database and Secret that this same release creates. A
Sync-phase hook in an early wave gives the same guarantee (no pod rolls before the schema is
migrated, and a failed migration stops the sync) and also works on day one. When the database and
the Secret are managed outside the release (another Application, RDS), migrations.argocd.hook:
PreSync is fine. Argo CD ignores the helm.sh/hook annotations because the Argo CD ones are set.
Argo CD must know when the CNPG Cluster is healthy. Recent Argo CD versions include a health
check for postgresql.cnpg.io/Cluster; if yours reports it Healthy straight away, add:
# argocd-cm
data:
resource.customizations.health.postgresql.cnpg.io_Cluster: |
hs = {status = "Progressing", message = "Waiting for the cluster"}
if obj.status ~= nil and obj.status.conditions ~= nil then
for _, c in ipairs(obj.status.conditions) do
if c.type == "Ready" then
if c.status == "True" then hs.status = "Healthy" end
hs.message = c.message or obj.status.phase
end
end
end
return hs
With plain Helm, --wait works on the first install too: pods retry the database connection
while it comes up, and the worker waits (Ready, but idle) until the post-install migration has
created its tables, then starts processing jobs.
Traffic¶
Only hooks faces the internet, and it serves nothing but webhook deliveries, SCIM and the
signed one-click unsubscribe links of notification emails (/hooks/email/unsubscribe, which
mail providers call from the internet). The UI, the API and OIDC sign-in are on web behind an
internal entry point. Set WFM_PUBLIC_URL to the internal URL and WFM_HOOKS_PUBLIC_URL to the
public one: webhook URLs shown to admins, the SCIM base URL and unsubscribe links use the latter.
Server-sent events (/api/v1/events) need a proxy that streams: no response buffering and an
idle timeout above the 25-second keep-alive the server sends. The server also sets
X-Accel-Buffering: no.
k3s (Traefik). values-k3s.yaml routes both hosts through the bundled Traefik. Only the hooks
host is in public DNS / port-forwarded; the web router gets an ipAllowList middleware limiting
it to private ranges (declared via extraObjects). For stronger separation run a second Traefik
on another address and give it its own IngressClass. Traefik streams SSE without extra settings.
TLS comes from cert-manager (ingress.*.certManager.clusterIssuer; internal names need DNS-01).
EKS (AWS Load Balancer Controller). Two ALBs: scheme: internal for web, internet-facing
(with WAF) for hooks, IP targets, ACM certificates, healthcheck-path: /readyz. The web ALB gets a
3600 s idle timeout for SSE. Label the namespace elbv2.k8s.aws/pod-readiness-gate-inject=enabled
so rollouts wait for target registration; preStopSleepSeconds: 15 keeps terminating pods serving
while the ALB deregisters them.
Gateway API. gatewayAPI.web / gatewayAPI.hooks render HTTPRoutes attached to Gateways
you own (parentRefs; listeners and certificates live on the Gateway). The web route gives
/api/v1/events timeouts.request: 0s, because Envoy-based implementations otherwise cut
streams at their default route timeout.
ingress-nginx users: add nginx.ingress.kubernetes.io/proxy-buffering: "off" and
proxy-read-timeout: "3600" to ingress.web.annotations.
NetworkPolicies¶
With networkPolicy.enabled (default) the chart renders:
| Policy | Allows |
|---|---|
<release>-default-deny-ingress |
nothing: every pod in the namespace denies ingress unless allowed below |
<release>-web |
:8080 from networkPolicy.web.ingressFrom (internal ingress controller); :9090 from metrics.from; egress DNS, database, HTTPS (OIDC token exchange, SCM APIs, S3) |
<release>-hooks |
:8080 from networkPolicy.hooks.ingressFrom (public controller only); :9090 from metrics.from; egress DNS and database only |
<release>-worker |
:9090 from metrics.from; egress DNS, database, HTTPS |
<release>-migrate |
no ingress; egress DNS and database |
<release>-database (CNPG) |
:5432 from the app pods and the KEDA namespace, :8000/:5432 from the CNPG operator namespace, everything between the cluster's own pods, :9187 from metrics.from |
HTTPS egress is 0.0.0.0/0 on 443 except the instance metadata endpoint; add rules with
networkPolicy.egress.extraRules (OTLP collector, a self-hosted GitLab on another port, EKS Pod
Identity's agent at 169.254.170.23:80). Ingress peers per cluster:
- k3s: Traefik pods in
kube-system(values-k3s.yaml). - EKS with ALB IP targets: traffic comes from the load balancers' ENIs, so allow the subnets each
ALB lives in: private subnets for the internal ALB → web, public subnets for the internet-facing
ALB → hooks (
values-eks.yaml).
Scaling and availability¶
- web, hooks:
autoscaling/v2HPAs on CPU (memory optional). When an autoscaler is enabled the chart omitsreplicas, so Argo CD does not fight the HPA. - worker: with
worker.keda.enabled, a KEDAScaledObjectrunsSELECT count(*) FROM river_job WHERE state = 'available' AND scheduled_at <= now()through KEDA's PostgreSQL scaler (connection string from the same Secret as the app, via aTriggerAuthentication) and targetstargetQueryValuerunnable jobs per replica. It never scales below 1: the River leader that enqueues periodic jobs (directory sync, SCM reconcile) runs in a worker.fallbackholds a fixed replica count if the query fails; without KEDA,worker.autoscalingis a plain CPU HPA. - Disruption and placement: PDBs (
maxUnavailable: 1), topology spread across zones and nodes (ScheduleAnyway; switch toDoNotSchedulefor hard spreading), rolling updates withmaxUnavailable: 0. - Shutdown: web/hooks sleep
preStopSleepSeconds, then the binary drains for up to 25 s; the worker stops fetching and waits for running jobs. Grace periods are set accordingly.
Deploying with Argo CD¶
k3s, chart from this repository, secrets decrypted by the helm-secrets plugin:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: wfm
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/workflowproj/workflow
targetRevision: v1.2.3
path: deploy/helm/wfm
helm:
valueFiles:
- values-k3s.yaml
- secrets://../../../clusters/home/wfm-secrets.enc.yaml # SOPS
destination:
server: https://kubernetes.default.svc
namespace: wfm
syncPolicy:
automated: {prune: true, selfHeal: true}
syncOptions: [CreateNamespace=true]
EKS, signed OCI chart plus values from a GitOps repository (register ghcr.io/workflowproj/charts
as a Helm repository with enableOCI: "true"):
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: wfm
namespace: argocd
spec:
project: platform
sources:
- repoURL: ghcr.io/workflowproj/charts
chart: wfm
targetRevision: 1.2.3
helm:
valueFiles:
- $gitops/clusters/prod-eks/wfm/values.yaml # based on values-eks.yaml
- repoURL: https://github.com/example/gitops
targetRevision: main
ref: gitops
destination:
server: https://kubernetes.default.svc
namespace: wfm
syncPolicy:
automated: {prune: true, selfHeal: true}
syncOptions: [CreateNamespace=true]
Install the operators (CNPG, KEDA, External Secrets, AWS Load Balancer Controller, cert-manager) as their own Applications in earlier waves of an app-of-apps.
k3s step by step¶
# 1. operator
kubectl apply --server-side -f https://raw.githubusercontent.com/cloudnative-pg/cloudnative-pg/release-1.30/releases/cnpg-1.30.1.yaml
# 2. values: copy values-k3s.yaml, set hosts, URLs and sign-in; encrypt the secrets with SOPS
# 3. install (or create the Argo CD Application above)
helm upgrade --install wfm deploy/helm/wfm -n wfm --create-namespace \
-f my-values.yaml -f secrets://my-secrets.enc.yaml
kubectl -n wfm logs job/wfm-migrate
kubectl -n wfm get cluster.postgresql.cnpg.io,pods
EKS step by step¶
- Cluster add-ons: AWS Load Balancer Controller, EBS CSI driver +
gp3StorageClass, VPC CNI with network policy enabled, CloudNativePG, KEDA, External Secrets with aClusterSecretStore, Prometheus Operator. - IAM: an IRSA role for the CNPG instances (S3 backup bucket) and one for the app (attachments
bucket), referenced in
database.cnpg.serviceAccountAnnotationsandserviceAccount.annotations. - Secrets Manager: a JSON secret with
WFM_SECRET_KEYand the IdP client secret(s). - ACM certificates for both hosts; DNS for the internal host in a private zone.
- Adapt
values-eks.yaml(ARNs, subnets CIDRs, hosts, buckets) and create the Application.
Air-gapped installs¶
Each release attaches wfm-offline-X.Y.Z-linux-{amd64,arm64}.tar.gz, built by
scripts/bundle.sh: every image the install needs (docker save, zstd/gzip, ready for the k3s
airgap images directory or k3s ctr images import), the packaged chart, the CNPG operator
manifest with imagePullPolicy: IfNotPresent, a k3s HelmChart manifest with the chart embedded
(installs through k3s' helm-controller without the helm CLI), the example values, the wfm CLI,
SHA256SUMS and an INSTALL.md with the exact steps.
Build one yourself (from a machine with registry access):
scripts/bundle.sh --version 1.2.3 --arch arm64 --out dist
scripts/bundle.sh --version dev --no-pull --image ghcr.io/workflowproj/workflow:dev # a local build
EXTRA_IMAGES="ghcr.io/kedacore/keda:2.18.0" scripts/bundle.sh --version 1.2.3 # extra images
Inside the air gap set WFM_WEBHOOK_ALLOW_PRIVATE=true if outbound webhooks target internal
services, and use an internal OIDC provider and S3-compatible store.
Observability¶
- Metrics:
/metricson :9090 in every mode:wfm_http_requests_totalandwfm_http_request_duration_secondsby route,wfm_jobs_totalandwfm_job_duration_secondsby job kind and outcome,wfm_scm_webhooks_received_total, plus Go runtime metrics.serviceMonitor.enabledscrapes all three components (with acomponentlabel);database.cnpg.enablePodMonitorscrapes PostgreSQL. Useful alerts: runnableriver_jobbacklog growing (the KEDA-managed HPA shows it as an external metric),wfm_jobs_totalfailures, 5xx rate on hooks (webhook senders retry, but not forever), readiness flapping (database reachability), CNPG replication lag and the age of the last archived WAL. - Probes: liveness
/healthznever touches the database; readiness/readyzpings it, so a database outage drains traffic instead of restarting pods. - Logs: JSON on stdout (
WFM_LOG_LEVEL); ship them with the cluster's agent. - Traces: set
OTEL_EXPORTER_OTLP_ENDPOINTinconfig(OTLP/HTTP). The chart addsk8s.namespace.name,k8s.pod.name,k8s.node.nameandk8s.deployment.nametoOTEL_RESOURCE_ATTRIBUTES; allow egress to the collector innetworkPolicy.egress.extraRules.
Development¶
To work on Workflow itself (a local k3d cluster with Tilt, the development Keycloak realm, the tests and CI), see CONTRIBUTING.md.