on_deploy
Post-rollout webhooks — Slack, PagerDuty, anything HTTP.
on_deploy {} declares webhooks voodu fires after a rollout settles. Success and failure are separate slots so you can route them differently — and each slot accepts multiple targets, fired in parallel.
Accepted on deployment and app. (Not on statefulset, job, cronjob — those have different lifecycles.)
Delivery is best-effort: 3 attempts with backoff, failure to deliver does not fail the deploy. Each target gets its own retry budget — a slow PagerDuty doesn't delay Slack.
Synopsis
Single target per slot — the common case:
deployment "prod" "api" {
image = "ghcr.io/myorg/api:1.7"
on_deploy {
success {
url = "${SLACK_WEBHOOK_URL}"
}
failure {
url = "https://events.pagerduty.com/v2/enqueue"
method = "POST"
headers = {
"X-Routing-Key" = "${PD_ROUTING_KEY}"
}
file = "${asset.prod.webhooks.pagerduty}"
}
}
}Multiple targets per slot — fan out:
deployment "prod" "api" {
on_deploy {
# Success → Slack + Datadog + internal dashboard
success { url = "${SLACK_WEBHOOK_URL}" }
success {
url = "https://api.datadoghq.com/api/v1/events"
headers = { "DD-API-KEY" = "${DD_API_KEY}" }
}
success { url = "https://dashboard.example.com/internal/deploys" }
# Failure → PagerDuty + OpsGenie
failure {
url = "https://events.pagerduty.com/v2/enqueue"
headers = { "X-Routing-Key" = "${PD_ROUTING_KEY}" }
}
failure {
url = "https://api.opsgenie.com/v2/alerts"
headers = { "Authorization" = "GenieKey ${OPSGENIE_KEY}" }
}
}
}Each declared block becomes one HTTP target, fired in its own goroutine with its own retry loop. Order is declaration order, but deliveries race — no serialization.
success {} and failure {} slots
Repeatable. Each block is one target. Fields:
| Field | Type | Required | Default | Meaning |
|---|---|---|---|---|
url | string | yes | — | Target URL. |
method | string | no | "POST" | One of POST, PUT, PATCH, DELETE. |
headers | map | no | {} | Extra HTTP headers. |
body | object | no | default payload (see below) | Inline body. Mutex with file. |
file | string | no | — | Asset ref ${asset.X.Y.Z}. Mutex with body. Bare paths rejected. |
Default payload
When neither body nor file is set, voodu sends this JSON:
{
"kind": "deployment",
"scope": "prod",
"name": "api",
"release_id": "lyhmf6ab",
"image": "ghcr.io/myorg/api:1.7",
"status": "success",
"error": "",
"started_at": "2026-05-20T12:00:00Z",
"completed_at": "2026-05-20T12:00:11Z"
}Content-Type: application/json is set by default but can be overridden via headers. User-Agent: voodu-deploy-webhook is forced and not overridable — it's set after operator headers are applied.
release_id is a <base36-unix-seconds><2-hex-chars> token like lyhmf6ab (no rel- prefix).
Validation
The apply is rejected when:
urlis empty.methodis not one ofPOST,PUT,PATCH,DELETE.- Both
bodyandfileare set in the same sub-block. fileis not a${asset.…}reference.
Interpolation — two contexts
| Syntax | When | Source |
|---|---|---|
${VAR} / ${VAR:-default} | Parse-time (CLI, your machine) | Shell env + env_from'd config buckets |
{{field}} | Fire-time (controller, after rollout) | Rollout metadata |
So ${SLACK_WEBHOOK_URL} is resolved before the manifest ships. {{release_id}} is filled in by the controller when the webhook actually fires.
Available {{...}} tokens
{{kind}}, {{scope}}, {{name}}, {{release_id}}, {{image}}, {{status}}, {{error}}, {{started_at}}, {{completed_at}}.
Unknown {{...}} tokens are left literal — they won't break handlebars-style templates in receiver-side text.
Substitution recurses into nested maps and arrays — you can template every string value inside body.
Examples
Slack on success, PagerDuty on failure
deployment "prod" "api" {
on_deploy {
success {
url = "${SLACK_WEBHOOK_URL}"
}
failure {
url = "https://events.pagerduty.com/v2/enqueue"
method = "POST"
headers = {
"X-Routing-Key" = "${PD_ROUTING_KEY}"
}
}
}
}Both URLs and routing keys come from a config bucket loaded via env_from = ["prod/shared"] — the operator never exports them in their shell.
Inline body with templating
deployment "prod" "api" {
on_deploy {
success {
url = "${SLACK_WEBHOOK_URL}"
body = {
text = "deployed {{name}} ({{release_id}}) at {{completed_at}}"
channel = "#deploys"
}
}
}
}Asset-backed template
For complex payloads (PagerDuty events, custom CI integrations):
asset "prod" "webhooks" {
pagerduty = file("./webhooks/pagerduty.json")
}
deployment "prod" "api" {
on_deploy {
failure {
url = "https://events.pagerduty.com/v2/enqueue"
file = "${asset.prod.webhooks.pagerduty}"
}
}
}./webhooks/pagerduty.json:
{
"routing_key": "{{...}}",
"event_action": "trigger",
"payload": {
"summary": "Deploy {{name}} failed at {{completed_at}}",
"source": "voodu",
"severity": "error",
"custom_details": {
"release_id": "{{release_id}}",
"error": "{{error}}"
}
}
}The file is rendered with {{...}} at fire-time on the controller, then POSTed.
Custom HTTP method
on_deploy {
success {
url = "https://ci.example.com/builds/{{release_id}}/status"
method = "PATCH"
body = {
status = "{{status}}"
image = "{{image}}"
}
}
}Per-env routing via env_from
deployment "prod" "api" {
env_from = ["prod/notifications"] # SLACK_WEBHOOK_URL, PD_ROUTING_KEY
on_deploy {
success { url = "${SLACK_WEBHOOK_URL}" }
}
}
deployment "staging" "api" {
env_from = ["staging/notifications"] # different SLACK_WEBHOOK_URL
on_deploy {
success { url = "${SLACK_WEBHOOK_URL}" }
}
}Same manifest shape across envs; values come from per-env buckets.
Trade-offs
Delivery is best-effort, per-target. Each declared success {} / failure {} block gets its own goroutine, its own 3-attempt retry budget (backoff 1s, 5s, 30s), and its own drop-on-floor outcome. A failure on one target doesn't affect the others, and no target failure ever fails the deploy.
Parallel fan-out, no ordering guarantees. With multiple targets per slot, all goroutines start essentially simultaneously. Total wall-clock = max(per-target latency), not sum. Log lines are interleaved; voodu identifies them via target=<i>/<n> when more than one target is declared.
Per-target identity in logs. A single target logs on_deploy webhook (success) dropped after retries: .... Two or more targets log on_deploy webhook (success[1/3]) dropped after retries: ..., with the index matching the block's order in the manifest.
Webhooks fire only on actual churn. Success webhooks fire when the rolling restart produced visible work. A no-op reconcile (same spec hash, nothing changed) does not fire success — that would be noise. Failure webhooks fire regardless.
{{field}} is fire-time, ${VAR} is parse-time. Use ${VAR} for things resolved on your machine (Slack URL from your env_from bucket); use {{field}} for things only known when the webhook actually fires (release ID, completion timestamp).
Webhook config is NOT in the spec hash. Rotating a Slack URL or PagerDuty routing key does NOT trigger a rolling restart. Reconcile picks up the new value next time it fires.
Content-Type defaults to application/json. If your receiver needs a different content type, override it via headers = { "Content-Type" = "application/x-www-form-urlencoded" }. Note that voodu still ships a JSON body — overriding the header doesn't change the encoding.
User-Agent: voodu-deploy-webhook is forced. Set after operator headers are applied; not overridable. Useful for filtering in receiver logs.
file must be asset-backed. No raw paths. Force-routing through asset keeps the file content reproducible across hosts and folded into the asset digest.
Mutex: body OR file, never both. Pick one shape per block. If you need both static structure and templated fields, put it all in the asset file.
Validation errors include the index when multiple targets exist (on_deploy.failure[1].url is required), so you can pinpoint which block is broken. Single-target validation stays terse (on_deploy.failure.url is required).
Not available on jobs / cronjobs / statefulsets. Different lifecycle assumptions. For job/cronjob completion notifications, write a tail command in the workload itself.
See also
asset— forfile = "${asset.…}"references- Interpolation reference —
${VAR}vs{{field}} deployment,app