Argo Events
Every other tool on this shelf reacts to something already sitting still — a Git commit Argo CD compares against, forever; a Rollout object Argo Rollouts steps through once it exists; a DAG Argo Workflows was handed to run. Argo Events reacts to something happening — a webhook firing, a file landing in a bucket, a message arriving on a queue, a cron tick — and its whole job is turning that moment into a Kubernetes object nothing asked for a second earlier. It is the smallest of the four Argo siblings, worth only 12% of the CAPA blueprint on its own, and the one most people meet last. It is also the piece that turns three independent reconcilers into one connected loop: a webhook can start a Workflow, a Workflow can commit a tag Argo CD reconciles, and a canary Argo Rollouts is halfway through can be paused or promoted by a signal from entirely outside the cluster. This page covers the three-piece architecture, the actual YAML you write for each piece, how a Sensor's filters and conditions decide whether to fire, the trigger types on the menu, day-to-day commands, the gotchas, and the comparison everyone reaches for and gets slightly wrong: Argo Events versus KEDA.
Picture a tiny, very fast mail courier who stands at a mailbox all day watching for letters to arrive — a webhook, a file dropped in a folder, an alarm clock going off at 6am. The instant a letter shows up, the courier doesn't open it or decide anything; she just flies it straight to a sorting desk and drops it in a tray. Sitting at that desk is a clerk with a very specific set of rules taped to the wall: "if a letter says 'push' AND it's from the right sender, stamp it and send it to the workshop." The clerk never watches the mailbox, and the courier never reads the rules on the wall — each does exactly one job, and the letter passing hand to hand is the only thing connecting them.
Event-driven creation, not continuous reconciliation
☺ Like you're 10: Argo CD keeps checking its booklet forever, whether or not anything happened. Argo Events does nothing at all until something happens, and then it acts exactly once.
The Argo ecosystem covers how four independently-shipped projects share a name and little else, and Argo Events is where that independence is most visible, because its whole trigger is the opposite shape from its siblings'. Argo CD's application-controller and Argo Rollouts' controller both run a loop that never stops: poll, diff, decide, repeat, on a timer, forever, whether or not the world changed. Argo Events runs no such loop. It sits idle — genuinely idle, no polling, no periodic diff — until a real occurrence arrives from outside, and then it does one thing exactly once and goes back to waiting. That distinction is not a minor implementation detail; it's the reason Argo Events is the piece capable of starting the whole delivery loop in the first place. Something has to be the part that notices a pull request merged, and "continuously reconcile against a booklet" cannot be that part, because nothing wrote the booklet yet.
Three independent custom resources do the whole job, and each owns exactly one hop: an EventSource listens to the outside world, an EventBus carries what it hears, and a Sensor decides whether that's enough to act on and, if so, fires a trigger. None of the three can do either of the other two's jobs — a Sensor never listens to a webhook directly, and an EventSource never decides whether to act. That separation is what makes the pieces individually simple and the wiring between them almost mechanical to reason about.
Architecture: EventSource, EventBus, Sensor
☺ Like you're 10: One piece watches the mailbox, one piece is the sorting desk, one piece is the clerk who reads the rules and decides what happens next.
A standard install lands in its own argo-events namespace, and — unlike Argo CD's handful of separate controller Deployments — everything runs inside one controller-manager Deployment, reconciling EventBus, EventSource and Sensor objects alike from a single binary: kubectl get deploy -n argo-events shows exactly one entry, not three. What that one process actually does differs sharply by CRD, though, and it's worth keeping the two halves distinct in your head under the names the rest of this page uses — eventsource-controller and sensor-controller. For every EventSource object, it spins up a dedicated listener pod that does the actual watching: opening an HTTP port for a webhook, polling an SQS queue, subscribing to a Kafka topic, or firing on a cron schedule. For every Sensor object, it spins up a dedicated evaluator pod that subscribes to the events that Sensor depends on and runs its filters and conditions against them. Neither listener pod nor evaluator pod knows the other project exists; the only thing connecting an EventSource pod to a Sensor pod is the EventBus sitting between them.
The EventBus itself is self-managed Kubernetes-native infrastructure, not something you point at a service you already run. Out of the box it's backed by NATS: the classic native bus — still what the quickstart installs — runs NATS Streaming as a StatefulSet Argo Events manages for you inside argo-events; jetstream is the newer NATS backend the project is steering new installs toward, since NATS Streaming itself is being phased out upstream, and Kafka is also a supported backend on recent releases for platforms that already standardize on it. Whichever backend you pick, the bus is pure transport — it has no idea what a "push event" or a "signature check" means, it just moves messages between the two things that do. Delivery is at-least-once, not exactly-once, which matters more than it sounds like it should — see the gotchas below.
None of the other three Argo projects check on Argo Events, and Argo Events has no drift detector watching itself. If the EventBus goes down, every EventSource keeps accepting webhooks and every Sensor keeps its object sitting there looking healthy — but nothing gets published, nothing gets evaluated, and nothing fires. There is no error banner, no Degraded status propagating anywhere obvious; the first symptom most teams see is "why hasn't a Workflow started in two hours," and the fix is kubectl get eventbus -n argo-events, not a Workflow-side investigation.
The EventSource: what it watches
☺ Like you're 10: This is the courier's whole job description — which mailbox to stand at, and what counts as a letter worth carrying.
An EventSource can watch dozens of different kinds of thing, and each spec key names one type of listener it runs. The handful worth knowing cold:
| Type | What it watches | Worth knowing |
|---|---|---|
webhook | An HTTP endpoint it opens and exposes via a Service | No built-in signature verification — pair it with a Sensor data filter checking a shared secret, or reach for a typed source below instead |
calendar | A cron expression or a fixed interval | The closest thing Argo Events ships to a scheduler — useful for a nightly trigger with no real external event behind it |
resource | Create/update/delete on Kubernetes objects matching a GVK and label selector | The bridge into GitOps-adjacent triggers — react to a CR going Degraded, or a namespace appearing, without polling for it yourself |
sqs / kafka | A queue or a topic | A long-lived subscription the listener pod holds open, not a poll loop you tuned an interval for |
minio | Bucket notifications on a MinIO server | The listener pod subscribes directly via the MinIO client SDK — no separate bucket-side webhook to wire up. MinIO only, not raw AWS S3; use the sqs/sns sources for that |
github / gitlab | Repository webhooks, typed | Unlike raw webhook, signature verification and event-type parsing are handled for you |
apiVersion: argoproj.io/v1alpha1
kind: EventSource
metadata:
name: git-webhook
namespace: argo-events
spec:
service:
ports:
- port: 12000
targetPort: 12000
webhook:
push: # this key becomes the eventName a Sensor depends on
port: "12000"
endpoint: /push
method: POST
signature-ok: # a second endpoint on the same EventSource
port: "12000"
endpoint: /verify
method: POST
---
apiVersion: argoproj.io/v1alpha1
kind: EventSource
metadata:
name: nightly-report
namespace: argo-events
spec:
calendar:
daily-run:
schedule: "0 6 * * *" # 06:00 UTC, every day
timezone: "UTC"Every key under a type block — push and signature-ok above, daily-run in the second — becomes an eventName a Sensor names in its own dependencies. One EventSource, one listener pod, can expose several independent events at once; a Sensor picks and chooses which of them it cares about.
The Sensor: dependencies, filters, and conditions
☺ Like you're 10: The clerk's rules, written down: which mailboxes to watch, what a letter has to say to count, and whether it needs one letter or several before doing anything.
A Sensor's dependencies array names the events it subscribes to — each entry pairs an eventSourceName with one of that source's eventNames, and gives the pair a short local name the rest of the Sensor refers back to. Each dependency can carry its own filters: a data filter reaches into the event payload by path, coerces it to a declared type, and compares it against one or more allowed values — the standard way to say "only main, not every branch." A context filter compares metadata about the event itself rather than its body. When a trigger depends on more than one dependency, its own conditions field is a small boolean expression over their names — "push && signed" for AND, "push || manual-run" for OR — deciding whether that specific trigger fires, independently of any other trigger the same Sensor might also define.
apiVersion: argoproj.io/v1alpha1
kind: Sensor
metadata:
name: on-push
namespace: argo-events
spec:
dependencies:
- name: push
eventSourceName: git-webhook
eventName: push
filters:
data:
- path: body.ref
type: string
value:
- "refs/heads/main" # only main, not every branch
- name: signed
eventSourceName: git-webhook
eventName: signature-ok
triggers:
- template:
name: run-ci-pipeline
conditions: "push && signed" # both dependencies must fire — AND, not OR
argoWorkflow:
operation: submit
source:
resource:
apiVersion: argoproj.io/v1alpha1
kind: Workflow
metadata:
generateName: ci-
spec:
workflowTemplateRef:
name: ci-pipeline
parameters:
- src:
dependencyName: push
dataKey: body.after # the commit SHA out of the webhook payload
dest: spec.arguments.parameters.0.valueThat last block, parameters, is the part that makes triggers more than a blunt on/off switch: it copies a value out of the event's own payload (src) into a field of the object being created (dest), so the Workflow that gets submitted actually carries the commit SHA that caused it, without a human or a separate step wiring that value through by hand.
Triggers: what actually gets created
☺ Like you're 10: Once the clerk's rules say "yes, act," this is the whole menu of things the clerk is allowed to do next.
| Trigger type | What it does | Typical use |
|---|---|---|
argoWorkflow | Submits, resumes, retries, or resubmits a Workflow | By far the most common trigger — this is the CI-on-event pattern the whole page has been building toward |
k8s | Creates, patches, or deletes any Kubernetes object, generically | An escape hatch — bump a ConfigMap, patch a resource, create a Job with no Argo Workflows involved at all |
http | Calls an arbitrary HTTP endpoint | Notify a system that doesn't live in the cluster at all |
slack | Posts a message to a Slack channel | Human-facing notification, not a workload — nothing gets created in the cluster |
log | Prints the matched event to the Sensor's own log | Debugging a new EventSource/filter combination before wiring a real trigger behind it |
One Sensor can define several triggers, each with its own conditions gating it independently against the same set of dependencies — one event can start a Workflow and post to Slack and patch a ConfigMap, from one Sensor object, with three different gates if you want them to differ.
On a throwaway cluster, install Argo Events, a native EventBus, the git-webhook EventSource and the on-push Sensor above — minus the signed dependency and the conditions line, to start. Port-forward the EventSource's Service and curl its /push endpoint with a small JSON body by hand — no real GitHub webhook required. Watch a Workflow appear from nothing. Then add the signed dependency back and the conditions: "push && signed" line, curl only /push again, and confirm nothing fires until you also hit /verify — the exact AND-gate behavior the YAML above describes, made concrete in about ten minutes.
Argo Events vs. KEDA: two different meanings of "event-driven"
☺ Like you're 10: Argo Events makes brand-new things appear. KEDA makes more — or fewer — copies of a thing that's already there.
Both projects put "event-driven" in their pitch, and CAPA study guides warn about this collision for a reason: they solve genuinely different problems and share almost no mechanism. KEDA wraps an existing, already-running Deployment (or a Job-shaped workload) with a ScaledObject or ScaledJob, watches an external signal — queue depth, consumer lag, a PromQL result — and adjusts that workload's replica count, all the way to zero and back. Nothing new is ever created in the sense Argo Events means it; the same Deployment persists the whole time, and only the number attached to it moves. Argo Events never touches a replica count at all — every successful trigger produces a genuinely new object with its own identity, typically a Workflow, that did not exist a moment before and that KEDA has no concept of creating.
The one place the two get legitimately close is ScaledJob: unlike ScaledObject, it does create real Kubernetes Job objects, one per unit of queued work, with no HPA involved at all — which sounds a lot like "creation" until you look at what it creates. Every Job a ScaledJob spawns is identical, parameterless, and produced purely by counting a queue; it can't read a webhook payload, apply a filter, satisfy a boolean condition across several sources, or call Slack. A Sensor's trigger is shaped by the specific event that caused it. A ScaledJob's Job is shaped only by how many messages were waiting.
"I spent an afternoon trying to make KEDA 'trigger a build' before someone pointed out I had the wrong tool entirely. KEDA answered a question I wasn't asking — 'how many of these should be running?' — when what I actually wanted was 'make one new thing happen, once, with this exact payload attached.' Once I said it out loud like that, it was obviously a Sensor, not a ScaledObject."
Day-to-day commands
☺ Like you're 10: There's no special app for this one — just plain kubectl against three kinds of object, plus reading logs to see whether a letter actually got delivered.
Unlike Argo CD, Argo Events ships no dedicated CLI binary — every day-to-day interaction is kubectl against its three CRDs, plus reading the right pod's logs.
# install (CRDs + the one controller-manager) and a native EventBus
$ kubectl create namespace argo-events
$ kubectl apply -f https://raw.githubusercontent.com/argoproj/argo-events/stable/manifests/install.yaml
$ kubectl apply -n argo-events -f https://raw.githubusercontent.com/argoproj/argo-events/stable/examples/eventbus/native.yaml
$ kubectl apply -n argo-events -f eventsource-git-webhook.yaml
$ kubectl apply -n argo-events -f sensor-on-push.yaml
$ kubectl get eventbus,eventsource,sensor -n argo-events # all three CRDs, one namespace
$ kubectl get pods -n argo-events # one pod per EventSource, one per Sensor, plus the bus
$ kubectl logs -n argo-events -l eventsource-name=git-webhook -f # did the event even arrive here?
$ kubectl logs -n argo-events -l sensor-name=on-push -f # did the conditions evaluate true?
$ kubectl port-forward -n argo-events svc/git-webhook-eventsource-svc 12000:12000
$ curl -X POST http://localhost:12000/push -d '{"ref":"refs/heads/main"}' # fire it by hand, no real webhook needed
$ kubectl describe sensor on-push -n argo-events # last trigger time, per-dependency statusGotchas and failure modes
☺ Like you're 10: Most surprises here come from something being genuinely silent by design, not from an error message you just haven't found yet.
At-least-once delivery means your trigger target must tolerate duplicates
The EventBus guarantees at-least-once delivery, not exactly-once — a listener pod restart, a slow acknowledgment, or a network blip can all cause the same event to reach a Sensor twice. A Workflow created with generateName shrugs this off by design, since a duplicate submission just becomes a second harmless Workflow run. A k8s trigger that patches a live object, or an http trigger calling a payment API, does not get that same free pass — idempotency has to be designed into whatever the trigger actually does, not assumed from the platform underneath it.
A Sensor's ServiceAccount needs its own RBAC for whatever it creates
A freshly installed Sensor has no inherent right to create a Workflow, patch a ConfigMap, or touch anything else — its pod runs under its own ServiceAccount, and that account needs a Role or ClusterRole granting exactly the verbs and resources its triggers use. Get this wrong and the failure is quiet in the place you'd expect it to be loud: the target namespace shows nothing happened, and the actual "forbidden" error sits in the Sensor pod's own log, not anywhere near the object you were expecting to see appear.
An unauthenticated raw webhook is a public trigger for anyone who finds the URL
A plain webhook EventSource has no built-in signature check — it fires on any POST to its endpoint that reaches it, from anyone who knows or guesses the URL. Treating the URL's obscurity as the security boundary is the mistake; the fix is a Sensor data filter checking a shared-secret header or field in every payload before any trigger fires, or reaching for a typed source like github/gitlab that verifies a real signature for you before the event ever reaches a Sensor's filters at all.
Because a Sensor's k8s trigger can create, patch, or delete any Kubernetes object, it's technically capable of writing straight to an Argo CD-managed Application or a live Rollout, skipping Git entirely. The moment it does, that object's live state disagrees with what Git says, and Argo CD's selfHeal — built specifically to notice and correct exactly that kind of disagreement — reverts the Sensor's own change on the very next reconcile pass. Route the change through Git like the rest of the loop does; a Sensor being fast is not the same thing as a Sensor being a legitimate shortcut around a reconciler built to undo shortcuts.
Argo Events vs. the alternatives
☺ Like you're 10: A few other tools also react to things happening — they just trade away the filters, the CRD-native shape, or the "anything can be a trigger" flexibility.
| Option | Model | Best when | Costs you |
|---|---|---|---|
| Argo Events | Event-driven creation; EventSource/EventBus/Sensor; any Kubernetes object (or Slack, HTTP call) as the output | A discrete occurrence outside the cluster needs to start something new — a build, a notification, a one-off object | A third CRD family and a bus to run; at-least-once delivery means the trigger target must tolerate duplicates |
| KEDA | Event-driven scaling; ScaledObject/ScaledJob wrap the HPA, or spawn identical Jobs | An existing long-running or job-shaped workload should track a queue or metric up and down, including to zero | Never creates anything genuinely new — only ever changes a replica count, or spawns parameterless Jobs, on a workload that already exists |
| Knative Eventing | A CloudEvents-native broker/trigger model for routing events at platform scale | The whole platform is already built around CloudEvents and Knative Serving | A far bigger platform commitment than three CRDs — adopting Knative's entire event model, not bolting one small piece on |
| Plain webhook receiver + custom controller | Hand-rolled: an HTTP handler you write, calling client-go yourself | The trigger logic is genuinely one-off and doesn't justify learning another CRD family | Every retry, filter, and RBAC concern Argo Events already solved becomes your own team's code to maintain |
| CronWorkflow (Argo Workflows' own scheduler) | Time-based only — fires on a schedule, no real external "event" | The trigger really is just "on a schedule," and a second project isn't worth running for that alone | Not event-driven at all — can't react to a webhook, a queue message, or a file landing in a bucket |
Argo Events doesn't appear as a domain on the core Kubernetes exams — CKA, CKAD and CKS never touch it — but it's explicit, named content on the CAPA blueprint's smallest domain, and it's the piece The Argo Ecosystem leans on to show how four decoupled projects become one delivery loop without ever calling each other directly.
Foxy: So if I just want a Workflow to start every time someone pushes to main, why do I need three whole CRDs for that?
Pip: Because each one does exactly one job and nothing else. The EventSource catches the webhook, the bus carries it, the Sensor decides whether it counts. Split the courier from the clerk and neither has to understand the other's rules.
Olly: And I'm usually on the other end of that trigger. Pip's Sensor submits me a Workflow, I run the DAG — I never once talk back to her Sensor directly.
Gizmo: Skip the whole chain, then. Have the Sensor's k8s trigger just patch the live Rollout straight to 100% the moment the webhook lands. One hop, done in a second. 😈
Recon the Robot: Do that against anything I'm reconciling and my selfHeal reverts it on the very next pass. Your Sensor's patch and my Git state disagree, and Git wins — every time.
Timmy the Turtle: Route it through Git like the rest of the loop does, Gizmo. A Sensor being fast isn't the same thing as a Sensor being a shortcut around a reconciler built specifically to undo shortcuts.
Pip: I just carry the message. What happens after I hand it off was never my job to fight about.
1. Name the three core Argo Events custom resources and, in one sentence each, what each one owns. 2. What's the fundamental difference between how Argo CD decides to act and how a Sensor decides to fire a trigger? 3. You want a trigger to require two dependencies together, not either one alone. Which field do you set, and what does it look like? 4. In one sentence each: what does Argo Events create that KEDA never does, and what does KEDA change that Argo Events never touches? 5. Your EventBus pod is down. What actually happens to events arriving at an EventSource, and how would you notice? 6. Why does at-least-once delivery mean a trigger target needs to tolerate running twice for the same event? 7. Where does an unauthenticated raw webhook EventSource leave you exposed, and what's the fix?
Check your answers
- An EventSource listens to the outside world and publishes events it hears. An EventBus (usually NATS) carries those events as pure transport, with no filtering logic of its own. A Sensor subscribes to named events, evaluates its dependencies' filters and conditions, and fires a trigger when they're satisfied.
- Argo CD's controller runs a loop that never stops — polling and diffing on a timer whether or not anything changed. A Sensor runs no loop at all; it sits idle until a real event arrives from its EventBus subscription, evaluates it once, and goes back to waiting.
- The trigger's
conditionsfield, a boolean expression over dependency names — e.g."push && signed"for AND (both must fire) versus"push || signed"for OR (either alone is enough). - Argo Events creates a genuinely new object — typically a Workflow — with its own identity, shaped by the specific event that caused it. KEDA never creates anything in that sense; it only changes an existing workload's replica count (or, via ScaledJob, spawns identical parameterless Jobs counted from a queue). Changing a replica count is, in turn, the one thing Argo Events never does.
- Every EventSource keeps listening and accepting events normally, and every Sensor object keeps looking healthy — but nothing gets published or evaluated, so no trigger ever fires. There's no propagated error status anywhere obvious; you notice by seeing nothing happen downstream and checking
kubectl get eventbus -n argo-eventsdirectly. - The EventBus guarantees at-least-once, not exactly-once, delivery — a pod restart or a slow acknowledgment can cause the same event to be redelivered. A trigger target that isn't safe to run twice (a payment call, a non-generateName resource creation) can be duplicated or corrupted by a redelivery the platform itself considers normal behavior.
- A raw
webhookEventSource has no built-in signature verification — anyone who finds or guesses the URL can POST to it and fire a trigger. The fix is a Sensordatafilter checking a shared secret in every payload, or using a typed source likegithub/gitlabthat verifies a real signature before the event ever reaches a Sensor.