<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>Workflows on Dexome</title>
        <link>https://blog.dexome.com/tags/workflows/</link>
        <description>Recent content in Workflows on Dexome</description>
        <generator>Hugo -- gohugo.io</generator>
        <language>en</language>
        <lastBuildDate>Fri, 26 Jun 2026 00:00:00 +0530</lastBuildDate><atom:link href="https://blog.dexome.com/tags/workflows/index.xml" rel="self" type="application/rss+xml" /><item>
        <title>Running Kestra for My Home Automation Workflows</title>
        <link>https://blog.dexome.com/post/running-workflow-orchestration-production/</link>
        <pubDate>Fri, 26 Jun 2026 00:00:00 +0530</pubDate>
        
        <guid>https://blog.dexome.com/post/running-workflow-orchestration-production/</guid>
        <description>&lt;p&gt;I moved a few home automation workflows from n8n to Kestra. One of them listens
for a Frigate MQTT event, applies a two-minute cooldown and sends a camera image
through Apprise.&lt;/p&gt;
&lt;p&gt;The flow YAML was not the difficult part. I had issues with the non-root
container configuration, OSS authentication, the MQTT trigger type, KV expiry
and old realtime triggers which continued running after the flow was disabled.&lt;/p&gt;
&lt;p&gt;This post collects those fixes for Kestra 1.3.24 and 1.3.33. Some of these are
version-specific, so validate them again when using a newer release.&lt;/p&gt;
&lt;h2 id=&#34;running-the-kestra-container-as-a-non-root-user&#34;&gt;Running the Kestra container as a non-root user
&lt;/h2&gt;&lt;p&gt;My Kestra 1.3.24 container ran as a dedicated host UID instead of the image&amp;rsquo;s
baked &lt;code&gt;kestra&lt;/code&gt; user. The image entrypoint tried to materialize the
&lt;code&gt;KESTRA_CONFIGURATION&lt;/code&gt; environment variable at
&lt;code&gt;/app/confs/application.yml&lt;/code&gt;. That image directory was not writable by the
chosen UID, so the container crash-looped before Micronaut started.&lt;/p&gt;
&lt;p&gt;The fix was to render configuration declaratively, mount it read-only, and
point Micronaut directly at it:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;
&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;2
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-text&#34; data-lang=&#34;text&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;host-rendered application.yml -&amp;gt; /app/confs/application.yml:ro
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;MICRONAUT_CONFIG_FILES=/app/confs/application.yml
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;KESTRA_CONFIGURATION unset
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Secret placeholders stayed in the rendered file and resolved from the runtime
environment. The entrypoint&amp;rsquo;s write branch was no longer involved.&lt;/p&gt;
&lt;figure class=&#34;article-diagram&#34;&gt;
    &lt;pre class=&#34;mermaid&#34;&gt;
flowchart TB
	N[Nix renders non-secret config] --&gt; F[Store file with env placeholders]
	S[SOPS runtime environment] --&gt; M[Micronaut]
	F --&gt;|read-only bind mount| M
	E[Image entrypoint config writer] -. skipped .-&gt; M
&lt;/pre&gt;
    &lt;figcaption&gt;A read-only bind mount bypassed the image entrypoint&amp;#39;s assumption that its baked config directory was writable.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;When changing the container user, check files written by the entrypoint before
the application starts. The final Kestra process user alone did not explain this
failure.&lt;/p&gt;
&lt;h2 id=&#34;pocket-id-did-not-replace-kestra-basic-authentication&#34;&gt;Pocket ID did not replace Kestra basic authentication
&lt;/h2&gt;&lt;p&gt;Traefik already protected the Kestra route with Pocket ID forward auth. I
expected that gate to be sufficient. Kestra OSS still presented a &amp;ldquo;create admin
user&amp;rdquo; page.&lt;/p&gt;
&lt;p&gt;These were independent layers:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Pocket ID decided who could reach Kestra through Traefik.&lt;/li&gt;
&lt;li&gt;Kestra OSS basic auth decided who could use its UI and API.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I tried &lt;code&gt;kestra.server.basic-auth.enabled: false&lt;/code&gt;. That property did not exist,
and Kestra ignored the unknown key. Enabling Micronaut security removed the
basic-auth bean but broke an OSS UI endpoint that requires the bean. In this
version, an auth-less OSS UI was not a supported state.&lt;/p&gt;
&lt;p&gt;The working design retained basic auth, provisioned the admin from runtime
secrets, and had a Traefik response middleware set the same &lt;code&gt;BASIC_AUTH&lt;/code&gt; cookie
Kestra&amp;rsquo;s login form would set. The middleware runs after the Pocket ID gate, so
users still encounter one interactive login.&lt;/p&gt;
&lt;p&gt;An injected &lt;code&gt;Authorization&lt;/code&gt; request header did not work because the SPA checked
&lt;code&gt;document.cookie&lt;/code&gt; before routing. Browser behavior, not just server behavior,
was part of the auth contract.&lt;/p&gt;
&lt;h2 id=&#34;validating-flows-with-the-pinned-kestra-image&#34;&gt;Validating flows with the pinned Kestra image
&lt;/h2&gt;&lt;p&gt;Flow schemas and plugin properties move. I validate repository flows with the
CLI from the pinned Kestra image before synchronizing them. The image&amp;rsquo;s
&lt;code&gt;/app/kestra&lt;/code&gt; launcher also had no directly usable shebang, so raw
&lt;code&gt;podman exec kestra /app/kestra ...&lt;/code&gt; returned an exec-format error. Invoking
&lt;code&gt;docker-entrypoint.sh flow validate ...&lt;/code&gt; reproduced the image&amp;rsquo;s normal shell
fallback and worked.&lt;/p&gt;
&lt;p&gt;This catches invalid properties and expressions, but it does not test how a
long-running trigger behaves.&lt;/p&gt;
&lt;h2 id=&#34;use-realtimetrigger-for-mqtt-events&#34;&gt;Use &lt;code&gt;RealtimeTrigger&lt;/code&gt; for MQTT events
&lt;/h2&gt;&lt;p&gt;The first flow used &lt;code&gt;io.kestra.plugin.mqtt.Trigger&lt;/code&gt;. Its name looked correct,
but it polls: connect on an interval, collect messages, disconnect. Frigate&amp;rsquo;s
zone events are transient and non-retained, so the flow was usually absent when
the message arrived.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;io.kestra.plugin.mqtt.RealtimeTrigger&lt;/code&gt; holds a subscription and creates one
execution per message. It also exposes the body at &lt;code&gt;trigger.payload&lt;/code&gt;; the
polling trigger has different outputs.&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;
&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;2
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;3
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;4
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;5
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;6
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-yaml&#34; data-lang=&#34;yaml&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;nt&#34;&gt;triggers&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;  &lt;/span&gt;- &lt;span class=&#34;nt&#34;&gt;id&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;l&#34;&gt;maindoor_person&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;	&lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;type&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;l&#34;&gt;io.kestra.plugin.mqtt.RealtimeTrigger&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;	&lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;server&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s2&#34;&gt;&amp;#34;{{ envs.mqtt_broker }}&amp;#34;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;	&lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;topic&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;l&#34;&gt;frigate/maindoor/person&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;	&lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;serdeType&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;l&#34;&gt;STRING&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;I put the zero-value filter in a task-level &lt;code&gt;If&lt;/code&gt;. In Kestra 1.3.33, placing an
expression condition on the realtime trigger destabilized its subscription and
caused the liveness coordinator to restart it repeatedly.&lt;/p&gt;
&lt;figure class=&#34;article-diagram&#34;&gt;
    &lt;pre class=&#34;mermaid&#34;&gt;
sequenceDiagram
	participant F as Frigate
	participant P as Polling trigger
	participant R as RealtimeTrigger
	P-&gt;&gt;P: disconnected between polls
	F--&gt;&gt;R: payload 1
	Note over P: Event is missed
	R-&gt;&gt;R: create execution
	R-&gt;&gt;R: task-level If reads trigger.payload
&lt;/pre&gt;
    &lt;figcaption&gt;A polling subscriber samples the topic and misses edges; a realtime subscriber remains connected and filters inside the execution.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;h2 id=&#34;optional-output-fields-need-a-default&#34;&gt;Optional output fields need a default
&lt;/h2&gt;&lt;p&gt;The cooldown began with a KV &lt;code&gt;Get&lt;/code&gt; configured with &lt;code&gt;errorOnMissing: false&lt;/code&gt;. I
then checked:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;
&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-text&#34; data-lang=&#34;text&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;{{ outputs.cooldown_get.value is null }}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;When the key did not exist, Kestra omitted &lt;code&gt;value&lt;/code&gt; from the output object. Pebble
did not turn the absent attribute into null; it threw an
&lt;code&gt;IllegalVariableEvaluationException&lt;/code&gt; while deciding the next task.&lt;/p&gt;
&lt;p&gt;Optional outputs need an explicit default:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;
&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-text&#34; data-lang=&#34;text&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;{{ (outputs.cooldown_get.value ?? null) is null }}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;That fixed the exception and exposed the more serious cooldown problem.&lt;/p&gt;
&lt;h2 id=&#34;kv-expiry-did-not-work-as-a-cooldown&#34;&gt;KV expiry did not work as a cooldown
&lt;/h2&gt;&lt;p&gt;The flow set a KV key with &lt;code&gt;ttl: PT2M&lt;/code&gt; and sent another alert only when the key
was absent. After one notification, about 40 later events reached the check and
were suppressed. The key remained readable for hours.&lt;/p&gt;
&lt;p&gt;Even reliable expiry would have been awkward in this version: reading an
expired value could delete it and throw &lt;code&gt;ResourceExpiredException&lt;/code&gt;, which
&lt;code&gt;errorOnMissing: false&lt;/code&gt; did not handle.&lt;/p&gt;
&lt;p&gt;I stopped making correctness depend on deletion. The key now stores the last
alert epoch without a TTL:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;
&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-yaml&#34; data-lang=&#34;yaml&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;nt&#34;&gt;value&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s2&#34;&gt;&amp;#34;{{ now() | timestamp }}&amp;#34;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;&lt;/span&gt;&lt;span class=&#34;nt&#34;&gt;kvType&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;:&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;l&#34;&gt;NUMBER&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The condition compares values:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;
&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-text&#34; data-lang=&#34;text&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;{{ (now() | timestamp) - (outputs.cooldown_get.value ?? 0) &amp;gt;= 120 }}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;I used a new key name because the old key contained an ISO string. The fresh key
self-seeded on the first successful execution and avoided a migration-time type
error.&lt;/p&gt;
&lt;figure class=&#34;article-diagram&#34;&gt;
    &lt;pre class=&#34;mermaid&#34;&gt;
flowchart TB
	EVENT[MQTT event] --&gt; GET[Read last alert epoch]
	GET --&gt; AGE{now minus last is at least 120s?}
	AGE --&gt;|no| SKIP[Finish without notification]
	AGE --&gt;|yes or absent| SEND[Send through Apprise]
	SEND --&gt; SET[Overwrite last alert epoch]
&lt;/pre&gt;
    &lt;figcaption&gt;Cooldown correctness moved from an unreliable expiration side effect to explicit timestamp arithmetic.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;h2 id=&#34;globals-key-was-changed-on-the-worker&#34;&gt;&lt;code&gt;globals&lt;/code&gt; key was changed on the worker
&lt;/h2&gt;&lt;p&gt;I moved the MQTT broker address into &lt;code&gt;kestra.variables.globals.mqttBroker&lt;/code&gt; and
referenced &lt;code&gt;{{ globals.mqttBroker }}&lt;/code&gt;. Every realtime trigger began failing
before task execution with empty trigger variables.&lt;/p&gt;
&lt;p&gt;Micronaut normalized map keys while reconstructing globals on the worker:
&lt;code&gt;mqttBroker&lt;/code&gt; became kebab-case. The template requested a key that no longer
existed. Password secrets still worked because Kestra injected them through a
different path, which made the failure look like a broker problem.&lt;/p&gt;
&lt;p&gt;The fix was an environment variable:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;
&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1
&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class=&#34;lntd&#34;&gt;
&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-text&#34; data-lang=&#34;text&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;container: ENV_MQTT_BROKER=&amp;lt;single-sourced broker endpoint&amp;gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;flow:      {{ envs.mqtt_broker }}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;For values used by a worker or trigger, I now test the template in that execution
context. A successful UI preview or controller-side render did not prove that the
worker received the same key.&lt;/p&gt;
&lt;h2 id=&#34;disabled-realtime-flows-continued-to-run&#34;&gt;Disabled realtime flows continued to run
&lt;/h2&gt;&lt;p&gt;The strangest incident arrived while replacing one realtime flow with another
on Kestra 1.3.33. The old flow was marked &lt;code&gt;disabled: true&lt;/code&gt;. It continued to
receive MQTT messages and create executions.&lt;/p&gt;
&lt;p&gt;Removing its YAML did not help because my namespace update was upsert-only and
did not prune the running flow. Deleting the flow and its &lt;code&gt;triggers&lt;/code&gt; row still
did not finish the job. After a restart, the JDBC liveness coordinator recreated
the subscription from &lt;code&gt;worker_job_running&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The full runtime state spanned four places:&lt;/p&gt;
&lt;figure class=&#34;article-diagram&#34;&gt;
    &lt;pre class=&#34;mermaid&#34;&gt;
flowchart TB
	FLOW[Flow definition] --&gt; TRIG[triggers row]
	TRIG --&gt; RUN[worker_job_running subscription]
	RUN --&gt; MQTT[Live MQTT subscription]
	MQTT --&gt; QUEUE[queues backlog]
	COORD[JDBC liveness coordinator] --&gt;|restarts persisted job| RUN
&lt;/pre&gt;
    &lt;figcaption&gt;A realtime flow exists as definition, trigger registration, queued work, and a persisted running-worker record.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;Retirement required deleting the flow, its trigger registration, matching queue
backlog, and the &lt;code&gt;worker_job_running&lt;/code&gt; record, then restarting Kestra. Verification
meant checking that the old rows stayed absent while a retained flow on the same
topic still received the next event.&lt;/p&gt;
&lt;h2 id=&#34;checks-i-now-use&#34;&gt;Checks I now use
&lt;/h2&gt;&lt;p&gt;For a new workflow, I check these separately:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Deployment:&lt;/strong&gt; can the image start under its real UID and mounted config?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Schema:&lt;/strong&gt; does the exact pinned engine accept the flow?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rendering:&lt;/strong&gt; where is each expression evaluated, and what variables exist there?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;State:&lt;/strong&gt; what persists in KV, queues, registrations, and worker tables?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Lifecycle:&lt;/strong&gt; what does disable, delete, retry, restart, and reconcile actually do?&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The main issue was assuming that the flow YAML was the complete runtime state.
Realtime subscriptions, queued executions and running worker records were stored
in PostgreSQL and survived flow changes and Kestra restarts. When retiring a
realtime flow, check those records and verify using a real MQTT message after the
restart.&lt;/p&gt;
</description>
        </item>
        
    </channel>
</rss>
