← Back to list

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…

Faizan Qazi in Kestra Engineering · 2026-06-18 07:48 · 19 claps · 7.4 min read
#automation #devops #vmware #orchestration #built-with-kestra
Open on Medium ↗
Wiki topics: ☁️ · DevOps & Cloud

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 patchVM subflow 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 in actionState of SUCCESS).
  • 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):

  1. 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() }}).

  1. Pause for Approval: The flow reaches the **waitForApproval** task, a io.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 patchVM task uses the SSH plugin to securely connect to the VM and execute patching commands (apt install -y ...).
  • Conditional Rollback: The rollBackIfRequired task defines rollback conditions with a clear expression: condition: "{{ outputs.patchVM.exitCode != 0 or outputs.waitForApproval.onResume.approvePatch == false }}" A rollback executes if:
  1. The patch failed (patchVM.exitCode != 0).
  2. The patch succeeded but the operator rejected it (approvePatch == false).
  • Rollback Action: If the condition is met, the rollBackVM task executes the SSH command to reinstall the old package version, rolling back to the previous working state. The old version number is extracted from the patchId input string using JINJA templating.
  • Clear Outputs: The subflow uses OutputValues and the outputs section to report the action taken (PATCH or ROLLBACK) and the final state (SUCCESS or FAILED). The main patchOrchestration flow 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 patchVM subflow — 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