Automated, Approved, and Audited VM Patching with Kestra
Patching is slow, risky, and compliance-heavy. We turn this into one resilient automation flow you can read top to bottom in a single YAML…
Automated, Approved, and Audited VM Patching with Kestra
Patching is slow, risky, and compliance-heavy. We turn this into one resilient automation flow you can read top to bottom in a single YAML file.
Every team knows the drill: a CVE drops, a package needs updating, and suddenly you’re balancing two fears at once — the risk of not patching fast enough, and the risk of patching something that takes production down with it. So the work crawls. You patch staging, wait for someone to test it, chase an approval over email, book a change window, and finally repeat the whole thing in production days later, hoping nothing slipped through the cracks.
It doesn’t have to be that way. This post shows how to turn that entire process into a single, resilient Kestra workflow — one that patches automatically, pauses for a human to approve, rolls back on its own when something’s wrong, and records every step along the way.
The patching pain point: UAT to Production
For IT and DevOps teams, patching is a security requirement, but the process itself is often a source of enormous friction. And the core problem is the gaps between environments and the human approval required to bridge them.
Think about where the time actually goes. The patch lands in UAT in seconds. Then it sits there for three days waiting on someone to validate it. The approval happens in a thread or an email chain. Production finally gets patched, and there’s no clean record of who signed off or why. And if the patch misbehaves? The rollback is a manual.
None of that is the patch. It’s the choreography around it. So that’s exactly what we automate.
Simplifying Complex Patching Workflows with Kestra
The **patchOrchestration flow handles the overall orchestration and enforces a standard best practice: patch UAT first, then proceed to Production only if the UAT patch succeeds and is approved by a Human.**

- Sequential Execution: The workflow starts by calling the
patchVMsubflow for the UAT VM (processUatVM). - Conditional Gate: The Production VM task (
processProdVM) is protected by a Kestra expression:runIf: "{{ outputs.processUatVM.outputs.actionPerformed == 'PATCH' and outputs.processUatVM.outputs.actionState == 'SUCCESS' }}"Production patching runs only if the UAT process successfully applied the patch and received approval (resulting inactionStateofSUCCESS). - Final Notifications: The flow concludes with tasks (
logResult,notifyResult) that use Kestra's output mapping to generate a comprehensive result log and send a Slack notification with patch status for both UAT and Production VMs.
One flow, and the entire UAT-to-Prod safety policy is encoded as something that runs the same way every single time:
yaml
id: patchOrchestration
namespace: company.team
inputs:
- id: uatVM
type: STRING
defaults: dummy-uat
displayName: Name of the UAT VM
- id: prodVM
type: STRING
defaults: dummy-prod
displayName: Name of the Prod VM
- id: patchId
type: SELECT
expression: "{{ kv('approvedPacthes') }}"
defaults: "apt/focal-updates 2.0.11 amd64 [upgradable from: 2.0.10]"
displayName: Select the Patch to be applied
tasks:
- id: processUatVM
type: io.kestra.plugin.core.flow.Subflow
namespace: company.team
flowId: patchVM
inputs:
vmType: uat
vmName: "{{ inputs.uatVM }}"
patchId: "{{ inputs.patchId }}"
wait: true
transmitFailed: true
# outputs - actionPerformed, actionState, stateReason, actionCause
- id: processProdVM
type: io.kestra.plugin.core.flow.Subflow
runIf: "{{ outputs.processUatVM.outputs.actionPerformed == 'PATCH' and outputs.processUatVM.outputs.actionState == 'SUCCESS' }}"
namespace: company.team
flowId: patchVM
inputs:
vmType: prod
vmName: "{{ inputs.prodVM }}"
action: patch
patchId: "{{ inputs.patchId }}"
wait: true
transmitFailed: true
# outputs - actionPerformed, actionState, stateReason, actionCause
- id: logResult
type: io.kestra.plugin.core.debug.Return
format: |
VM Patching Workflow Result
- UAT:
- VM Name: {{ inputs.uatVM }}
- Patch Applied: {{ outputs.processUatVM.outputs.actionPerformed == 'PATCH' ? 'Yes' : 'No'}}
- Reason: {{ outputs.processUatVM.outputs.actionCause}}
- Prod:
- VM Name: {{ inputs.prodVM }}
{% if outputs.processUatVM.outputs.actionPerformed != 'PATCH' %}
- Patch Applied: Stage Skipped
{% else %}
- Patch Applied: {{ outputs.processProdVM.outputs.actionPerformed == 'PATCH' ? 'Yes' : 'No'}}
- Reason: {{ outputs.processProdVM.outputs.actionCause}}
{% endif %}
- id: notifyResult
type: io.kestra.plugin.notifications.slack.SlackIncomingWebhook
url: "{{ secret('slackWebhook') }}"
messageText: |
VM Patching Workflow Result
- UAT:
- VM Name: {{ inputs.uatVM }}
- Patch Applied: {{ outputs.processUatVM.outputs.actionPerformed == 'PATCH' ? 'Yes' : 'No'}}
- Reason: {{ outputs.processUatVM.outputs.actionCause}}
- Prod:
- VM Name: {{ inputs.prodVM }}
{% if outputs.processUatVM.outputs.actionPerformed != 'PATCH' %}
- Patch Applied: Stage Skipped
{% else %}
- Patch Applied: {{ outputs.processProdVM.outputs.actionPerformed == 'PATCH' ? 'Yes' : 'No'}}
- Reason: {{ outputs.processProdVM.outputs.actionCause}}
{% endif %}
outputs:
- id: result
type: STRING
value: "{{ outputs.logResult.value }}"
labels:
- key: feature
value: app
- key: feature
value: hitl
- key: feature
value: ssh
- key: domain
value: infra
Effortless Approval Gates: Human-in-the-Loop
Integrating human decisions without stopping automation is one of the trickiest parts of any IT process. Kestra’s HumanTask makes approval requests easy to implement.
In the **patchVM** subflow, after the patch is successfully applied (checkSuccess condition is met):
- Notification: A Slack notification (
notifyChannel) immediately informs the team that the UAT VM has been patched. This notification includes a dynamic link to the Kestra execution:[Here]({{ appLink() }}).

- Pause for Approval: The flow reaches the
**waitForApproval** task, aio.kestra.plugin.ee.flow.HumanTask.
- This task pauses the workflow right after the UAT patch.
- When an operator clicks the Slack link, they’re taken directly to the running Kestra execution.
- The
**onResume** section defines the inputs required to resume the flow, acting as the approval form: approvePatch(Type:BOOL): The critical Approve/Reject switch.reason(Type:STRING): A required field for documenting the decision.

This mechanism transforms a manual, often email-based approval process into a simple, self-documenting step within the automated workflow.
Managing Patches and Rollbacks with Precision
Kestra ensures your patching process isn’t a one-way street, it’s designed to be robust with built-in rollback logic:
- Patch Execution: The
patchVMtask uses the SSH plugin to securely connect to the VM and execute patching commands (apt install -y ...). - Conditional Rollback: The
rollBackIfRequiredtask defines rollback conditions with a clear expression:condition: "{{ outputs.patchVM.exitCode != 0 or outputs.waitForApproval.onResume.approvePatch == false }}"A rollback executes if:
- The patch failed (
patchVM.exitCode != 0). - The patch succeeded but the operator rejected it (
approvePatch == false).
- Rollback Action: If the condition is met, the
rollBackVMtask executes the SSH command to reinstall the old package version, rolling back to the previous working state. The old version number is extracted from thepatchIdinput string using JINJA templating. - Clear Outputs: The subflow uses
OutputValuesand theoutputssection to report the action taken (PATCHorROLLBACK) and the final state (SUCCESSorFAILED). The mainpatchOrchestrationflow uses these outputs for conditional logic.
: who approved it, why, what happened, and what the system did about it are all recorded automatically, on every run, without anyone lifting a finger.
Here’s the full
patchVMsubflow — snapshot, patch, the approval gate, and the rollback logic, all in one place:
id: patchVM
namespace: company.team
inputs:
- id: vmType
type: SELECT
values:
- uat
- prod
defaults: uat
- id: vmName
type: STRING
defaults: dummy-uat
- id: patchId
type: STRING
defaults: "apt/focal-updates 2.0.11 amd64 [upgradable from: 2.0.10]"
tasks:
- id: takeSnapshot
type: io.kestra.plugin.core.log.Log
message: Taking Snapshot! 🚀
- id: patchVM
type: io.kestra.plugin.fs.ssh.Command
host: "{{ inputs.vmName }}"
port: "22"
authMethod: PASSWORD
username: root
password: "{{ secret('ubuntuPW') }}"
commands:
- apt update
- apt install -y {{ (inputs.patchId | split("/"))[0] }}
- id: checkSuccess
type: io.kestra.plugin.core.flow.If
condition: "{{ outputs.patchVM.exitCode == 0 }}"
then:
- id: notifyChannel
type: io.kestra.plugin.notifications.slack.SlackIncomingWebhook
url: "{{ secret('slackWebhook') }}"
messageText: |
Patch: **{{ inputs.patchId }}** has been successfully applied on {{ inputs.vmType | upper }} VM: **{{ inputs.vmName }}**.
Please validate and approve the patch {% if inputs.vmType == 'uat'%}to production VM {% endif %}or rollback.
Approve/Reject: [Here]({{ appLink() }})
- id: waitForApproval
type: io.kestra.plugin.ee.flow.HumanTask
assignment:
users:
- it-admin@company.com
- infrastructure-lead@company.com
onResume:
- id: approvePatch
description: Whether to approve the patch
type: BOOL
defaults: false
- id: reason
description: Reason for approval or rejection
type: STRING
- id: rollBackIfRequired
type: io.kestra.plugin.core.flow.If
condition: "{{ outputs.patchVM.exitCode != 0 or outputs.waitForApproval.onResume.approvePatch == false }}"
then:
- id: rollBackVM
type: io.kestra.plugin.fs.ssh.Command
host: "{{ inputs.vmName }}"
port: "22"
authMethod: PASSWORD
username: root
password: "{{ secret('ubuntuPW') }}"
commands:
- apt update
- "apt install -y {{ (inputs.patchId | split('/'))[0] }}={{ ((inputs.patchId | split('from: '))[1] | split(']'))[0] }}"
- id: patchState
type: io.kestra.plugin.core.output.OutputValues
values:
state: "{{ (outputs.patchVM.exitCode == 0 and outputs.waitForApproval.onResume.approvePatch == true) ? 'SUCCESS' : 'FAILED' }}"
reason: "{{ outputs.patchVM.exitCode != 0 ? 'Patch failed' : outputs.waitForApproval.onResume.reason }}"
- id: rollBackState
type: io.kestra.plugin.core.output.OutputValues
runIf: "{{ outputs.rollBackIfRequired.evaluationResult == true }}"
values:
state: "{{ outputs.rollBackVM.values.exitCode == 0 ? 'SUCCESS' : 'FAILED' }}"
reason: "{{ outputs.rollBackVM.values.exitCode != 0 ? 'Rollback failed' : 'Rollback Success' }}"
outputs:
- id: actionPerformed
type: STRING
value: "{{ outputs.rollBackIfRequired.evaluationResult == true ? 'ROLLBACK' : 'PATCH' }}"
- id: actionState
type: STRING
value: "{{ outputs.rollBackIfRequired.evaluationResult == true ? outputs.rollBackState.values.state : outputs.patchState.values.state }}"
- id: stateReason
type: STRING
value: "{{ outputs.rollBackIfRequired.evaluationResult == true ? outputs.rollBackState.values.reason : outputs.patchState.values.reason }}"
- id: actionCause
type: STRING
value: "{{ outputs.waitForApproval.onResume.reason ?? 'Patching Failed. Not went for approval.' }}"
- id: vmName
type: STRING
value: "{{ inputs.vmName }}"
labels:
- key: feature
value: app
- key: feature
value: hitl
- key: feature
value: ssh
- key: domain
value: infra
Putting a button on it: the Kestra App
There’s one more layer worth adding. None of this should require the person requesting a patch to open the YAML. A Kestra App wraps both flows in a clean UI — a form and a Submit button to kick things off, a live log view while it runs, an Approve/Reject screen when it pauses, and the final result when it’s done. Your platform team gets a self-service patch console; everyone else gets a button and a status they can actually understand.

Two small app definitions are all it takes — one for the orchestration, one for a single VM:
id: company_team_patchOrchestration
type: io.kestra.plugin.ee.apps.Execution
namespace: company.team
displayName: Patch Orchestration
flowId: patchOrchestration
access:
type: PRIVATE
layout:
- on: OPEN
blocks:
- type: io.kestra.plugin.ee.apps.core.blocks.Markdown
content: |
## Execute Flow
- type: io.kestra.plugin.ee.apps.execution.blocks.CreateExecutionForm
- type: io.kestra.plugin.ee.apps.execution.blocks.CreateExecutionButton
text: Submit
- on: RUNNING
blocks:
- type: io.kestra.plugin.ee.apps.core.blocks.Markdown
content: |
## Execution is in progress.
- type: io.kestra.plugin.ee.apps.core.blocks.Loading
- type: io.kestra.plugin.ee.apps.execution.blocks.Logs
- type: io.kestra.plugin.ee.apps.execution.blocks.CancelExecutionButton
text: Cancel request
- on: SUCCESS
blocks:
- type: io.kestra.plugin.ee.apps.core.blocks.Markdown
content: |
### VM Patch Orchestration
- type: io.kestra.plugin.ee.apps.core.blocks.Alert
style: INFO
showIcon: true
content: Your execution result
- type: io.kestra.plugin.ee.apps.core.blocks.Markdown
content: |
{{ printContext().execution.outputs.result }}
id: company_team_patchVM
type: io.kestra.plugin.ee.apps.Execution
namespace: company.team
displayName: Patch VM
flowId: patchVM
access:
type: PRIVATE
layout:
- on: RUNNING
blocks:
- type: io.kestra.plugin.ee.apps.core.blocks.Markdown
content: |
## Execution is in progress.
- type: io.kestra.plugin.ee.apps.core.blocks.Loading
- type: io.kestra.plugin.ee.apps.execution.blocks.Logs
- type: io.kestra.plugin.ee.apps.execution.blocks.CancelExecutionButton
text: Cancel request
- on: SUCCESS
blocks:
- type: io.kestra.plugin.ee.apps.core.blocks.Markdown
content: |
## Execution successfully completed
- type: io.kestra.plugin.ee.apps.core.blocks.Alert
style: SUCCESS
showIcon: true
content: Your execution outputs
- type: io.kestra.plugin.ee.apps.execution.blocks.Outputs
- on: PAUSE
blocks:
- type: io.kestra.plugin.ee.apps.execution.blocks.ResumeExecutionForm
- type: io.kestra.plugin.ee.apps.execution.blocks.ResumeExecutionButton
The bigger picture
By abstracting infrastructure complexity into simple YAML definitions and introducing a seamless human approval gate, Kestra makes what was once a complicated, error-prone patching process reliable, transparent, and easy to manage.

메타데이터
- post_id
- be399aa7cfd2
- slug
- automated-approved-and-audited-vm-patching-with-kestra-be399aa7cfd2
- url
- https://medium.com/kestra-engineering/automated-approved-and-audited-vm-patching-with-kestra-be399aa7cfd2
- canonical_url
- https://medium.com/kestra-engineering/automated-approved-and-audited-vm-patching-with-kestra-be399aa7cfd2
- author_url
- https://medium.com/@caxefaizan
- status
- ok
- fetched_at
- 2026-06-21 07:44:09