Step-by-step migration tutorial: Kestra 1.3 to 2.0
For the complete documentation index, see llms.txt. For a full content snapshot, see llms-full.txt. Append.mdto anykestra.io/docs/*URL for plain Markdown.
This tutorial walks through a complete migration from Kestra 1.3 to 2.0. Every command and output shown here was captured from a real 1.3.5 → 2.0.0 run against a Kestra OSS instance backed by PostgreSQL.
This tutorial covers Kestra OSS running in standalone mode (server standalone) on Docker Compose with a PostgreSQL backend. The flow migration steps apply equally to distributed and Helm deployments, but the server upgrade procedure differs. Enterprise Edition users must also run database migrations manually before starting the server; EE-specific steps are called out where they differ.
This tutorial assumes you are already running Kestra 1.3.x. If you are on an older version, complete the required metadata migrations first: KV Store and Secrets migration (1.1), Namespace Files migration (1.2), or the LTS migration guide (1.0 to 1.3) if you are upgrading from 1.0.
For a reference list of every breaking change, see the 2.0 migration index.
This tutorial follows seven steps: export your flows → scan with kestra-migrate --check → apply the automated migration → fix the items the tool cannot rewrite → switch the server to 2.0 → verify behavior → complete and sign off. Steps 1–4 are non-destructive and can be repeated; Step 5 is the cutover.
Resources
| Resource | Where to get it |
|---|---|
kestra-migrate flow migration CLI | github.com/kestra-io/kestra2-flow-migration — download the binary from Releases; outputs in this tutorial were captured from v2.6.11 |
kestractl | Install guide |
| Kestra 2.0 Docker image | kestra/kestra:v2.0.0-slim |
Prerequisites
- The latest
kestra-migraterelease on yourPATH. New rules ship continuously, and an older binary skips them without an error. Each run prints a reminder on stderr when a newer release exists; setKESTRA_MIGRATE_NO_UPDATE_CHECK=1to turn the check off in CI. - A backup of your Kestra PostgreSQL or H2 database
kestractlv2 if you are on Enterprise Edition
Step 1 — Export your flows
Export your flows from the 1.3 instance before making any changes to it.
kestractl flows export --namespace company.team --output-file company-team-flows.zip
unzip company-team-flows.zip -d ./flows/Repeat for each namespace you want to migrate.
Step 2 — Scan with kestra-migrate --check
Run the check against your exported flows:
kestra-migrate --check ./flows/kestra-migrate uses three output symbols to categorize each flow:
| Symbol | Meaning | Action required |
|---|---|---|
✔ | No changes needed — flow is already v2-compatible | None |
✎ | Auto-rewritable — the tool will apply the change when you run without --check | Run kestra-migrate -o ./v2-flows/ ./flows/ |
⚠ ✗ | Removed or unsupported construct — the tool migrates the rest of the flow and leaves the construct in place | Rewrite manually before importing to 2.0 |
⚠ ✗ warnings have two severities. v2-incompatible (red ✗) means Kestra 2.0 rejects the flow on save and it cannot be deployed at all. Advisory (yellow ⚠) means the flow deploys but breaks at runtime (for example, read() with a removed version= argument). Each entry includes a ↳ docs: line pointing to the relevant migration guide page.
When you scan many flows, add --summary to end the run with the warnings grouped by family. Each family’s docs link then prints once instead of under every warning.
The eight example flows below cover the main migration patterns. Each snippet shows only the flagged construct — the --check output that follows shows what the tool reports for each one.
Flows flagged ⚠ ✗ (manual fix required):
flows/01-foreach.yaml — ForEach task, removed in 2.0:
- id: deploy type: io.kestra.plugin.core.flow.ForEach values: '["dev", "staging", "production"]' tasks: - message: "Deploying to {{ taskrun.value }} (step {{ taskrun.iteration }})"flows/02-foreachitem-processor.yaml — subflow target, already compatible (✔).
flows/03-foreachitem.yaml — ForEachItem task, removed in 2.0:
- id: each_item type: io.kestra.plugin.core.flow.ForEachItem items: "{{ inputs.report_file }}" batch: rows: 1 flowId: process-report-item inputs: item: "{{ taskrun.items }}"flows/04-plugin-defaults.yaml — flow-level pluginDefaults, removed in 2.0:
pluginDefaults: - type: io.kestra.plugin.core.http.Request values: method: GET headers: Accept: application/json X-API-Version: "2" options: connectTimeout: PT5S readTimeout: PT30Sflows/07-task-defaults.yaml — flow-level taskDefaults, removed in 2.0:
taskDefaults: - type: io.kestra.plugin.core.http.Request values: method: GET headers: Accept: application/jsonFlows flagged ✎ (auto-rewritten):
flows/05-input-types.yaml — BOOLEAN and ENUM input types renamed in 2.0:
inputs: - id: enabled type: BOOLEAN # → BOOL - id: log_level type: ENUM # → SELECT values: [DEBUG, INFO, WARNING, ERROR]flows/06-type-renames.yaml — deprecated plugin type paths:
- type: io.kestra.plugin.core.state.Get # → kv.Get- type: io.kestra.plugin.core.storage.Purge # → PurgeExecutions- type: io.kestra.plugin.notifications.slack.SlackIncomingWebhook # → plugin.slack.notifications.*flows/08-trigger-conditions.yaml — conditions: blocks on Schedule triggers:
triggers: - id: weekday_mornings type: io.kestra.plugin.core.trigger.Schedule cron: "0 8 * * *" conditions: - type: io.kestra.plugin.core.condition.DayWeekCondition dayOfWeek: MONDAY # ... WEDNESDAY and FRIDAY entries
- id: business_hours_only type: io.kestra.plugin.core.trigger.Schedule cron: "0 */2 * * *" conditions: - type: io.kestra.plugin.core.condition.DateTimeBetweenCondition before: "18:00:00" after: "08:00:00"kestra-migrate --check output
⚠ 01-foreach.yaml ✗ deploy uses io.kestra.plugin.core.flow.ForEach (removed in v2; rewrite manually as io.kestra.plugin.core.flow.Loop (taskrun.value→item.value, taskrun.iteration→item.index, declare outputs, AllowFailure→transmitFailed)) ↳ docs: https://kestra.io/docs/migration-guide/v2.0.0/foreach-loop✔ 02-foreachitem-processor.yaml⚠ 03-foreachitem.yaml ✗ each_item uses io.kestra.plugin.core.flow.ForEachItem (removed in v2; rewrite manually as io.kestra.plugin.core.flow.Loop (taskrun.value→item.value, taskrun.iteration→item.index, declare outputs, AllowFailure→transmitFailed)) ↳ docs: https://kestra.io/docs/migration-guide/v2.0.0/foreach-loop⚠ 04-plugin-defaults.yaml ✗ flow-level `pluginDefaults` is removed in v2 and must be rewritten manually (EE: a Policy with `Add` rules, referenced via `policyRefs:`; OSS: inline the values onto each task or use flow `variables:`) ↳ docs: https://kestra.io/docs/migration-guide/v2.0.0/plugin-defaults-removed✎ 05-input-types.yaml--- original+++ migrated@@ -8,11 +8,11 @@
inputs: - id: enabled- type: BOOLEAN+ type: BOOL defaults: true
- id: log_level- type: ENUM+ type: SELECT values: - DEBUG - INFO@@ -21,7 +21,7 @@ defaults: INFO
- id: environment- type: ENUM+ type: SELECT values: - staging - production✎ 06-type-renames.yaml--- original+++ migrated@@ -12,15 +12,15 @@
tasks: - id: load_count- type: io.kestra.plugin.core.state.Get- name: execution_count+ type: io.kestra.plugin.core.kv.Get+ key: type-renames_states_tasks-states_execution_count
- id: purge_old- type: io.kestra.plugin.core.storage.Purge+ type: io.kestra.plugin.core.execution.PurgeExecutions endDate: "{{ now() | dateAdd(-30, 'DAYS') }}"
- id: notify- type: io.kestra.plugin.notifications.slack.SlackIncomingWebhook+ type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook url: "{{ secret('SLACK_WEBHOOK') }}" payload: | {"text": "Cleanup complete"}⚠ 07-task-defaults.yaml ✗ flow-level `taskDefaults` is removed in v2 and must be rewritten manually (EE: a Policy with `Add` rules, referenced via `policyRefs:`; OSS: inline the values onto each task or use flow `variables:`) ↳ docs: https://kestra.io/docs/migration-guide/v2.0.0/plugin-defaults-removed✎ 08-trigger-conditions.yaml--- original+++ migrated@@ -21,19 +21,10 @@ - id: weekday_mornings type: io.kestra.plugin.core.trigger.Schedule cron: "0 8 * * *"- conditions:- - type: io.kestra.plugin.core.condition.DayWeekCondition- dayOfWeek: MONDAY- - type: io.kestra.plugin.core.condition.DayWeekCondition- dayOfWeek: WEDNESDAY- - type: io.kestra.plugin.core.condition.DayWeekCondition- dayOfWeek: FRIDAY+ when: "{{ (dayOfWeek(trigger.date) == 'MONDAY') and (dayOfWeek(trigger.date) == 'WEDNESDAY') and (dayOfWeek(trigger.date) == 'FRIDAY') }}"
- id: business_hours_only type: io.kestra.plugin.core.trigger.Schedule cron: "0 */2 * * *"- conditions:- - type: io.kestra.plugin.core.condition.DateTimeBetweenCondition- before: "18:00:00"- after: "08:00:00"+ when: "{{ hourOfDay(trigger.date) >= 8 and hourOfDay(trigger.date) < 18 }}"
⚠ 7/8 flows need migrationExit code is 1 whenever any flow needs changes, making it useful as a CI gate. Four flows (⚠ ✗) require manual rewrites before Step 4. Three flows (✎) will be rewritten automatically when you run the migration command.
What kestra-migrate rewrites automatically (✎)
| Pattern | Before (1.3) | After (2.0) |
|---|---|---|
Input name | name: my_input | id: my_input |
| Input and flow output type names | BOOLEAN, ENUM | BOOL, SELECT |
MULTISELECT input choices | options: [...] | values: [...] |
| Required/defaults conflict | required: false + defaults: | Remove required: false |
| Retry attempts | maxAttempt: 3 | maxAttempts: 3 |
| Flow IDs that clash with reserved keywords | id: pause | id: pause-flow |
| Renamed task and condition types | core.state.*, core.storage.Purge, core.debug.Echo, core.log.Fetch, notifications.slack.*, io.kestra.core.* paths, *Condition suffixes | core.kv.*, core.execution.PurgeExecutions, core.log.Log, kestra.logs.Fetch, slack.notifications.*, io.kestra.plugin.core.* paths, no suffix |
| State Store task properties | name, data | key (the 1.3 State Store key, so existing state is still read), value |
Pause duration | delay: PT1M | pauseDuration: PT1M |
| Removed properties | Subflow.outputs, Schedule.backfill, trigger minLogLevel | Removed |
| Script task runner | runner: DOCKER, docker: {image: ...} | taskRunner: {type: ...Docker}, containerImage: ... |
fs.local.Delete | No recursive | recursive: true, which keeps the 1.3 behavior |
PurgeKV | expiredOnly: false | behavior: {type: key, expiredOnly: false} |
dbt.cli.Build | type: ...dbt.cli.Build | type: ...dbt.cli.DbtCLI with commands: [dbt build] |
Exit state and states: lists | CANCELED | CANCELLED |
| Trigger conditions | conditions: [DayWeekCondition, ...] | when: "{{ dayOfWeek(...) == ... }}" |
| Flow trigger dependencies | conditions: [ExecutionFlow, MultipleCondition, ...] | dependsOn: [{flowId: ...}] |
| Check condition | checks: [{condition: ...}] | checks: [{when: ...}] |
Pebble json() function | {{ json(x) }} | {{ fromJson(x) }} |
Pebble | json filter | {{ value | json }} | {{ value | toJson }} |
| HTTP basic auth | options.basicAuthUser, options.basicAuthPassword | options.auth: {type: BASIC, username, password} |
| HTTP trigger SSL options | sslOptions: {...} | options: {ssl: {...}} |
| HTTP connection pool timeout | options.connectionPoolIdleTimeout | Removed |
| EE: Worker Group | workerGroup: {key: gpu} | workerSelector: {tags: [gpu], fallback: WAIT} |
| EE: Git clone | io.kestra.plugin.ee.git.Clone | io.kestra.plugin.git.Clone, with strictHostKeyChecking: true when the task uses privateKey |
The Worker Group rewrite also prints an advisory ⚠: 2.0 routes tasks by Worker Queue tag, so create a Worker Queue with each converted tag. See Worker Groups.
What kestra-migrate flags but does not rewrite (⚠ ✗)
The tool detects these patterns and prints a message with a docs link. It migrates the rest of the flow and leaves the flagged construct in place, so apply the fix manually before importing to 2.0. A red ✗ means 2.0 rejects the flow on save; a yellow ⚠ means the flow saves but fails at runtime.
| Pattern | Severity | Manual fix | Guide |
|---|---|---|---|
io.kestra.plugin.core.flow.ForEach | ✗ | Rewrite as Loop; replace taskrun.value → item.value, taskrun.iteration → item.index | ForEach and ForEachItem replaced by Loop |
io.kestra.plugin.core.flow.ForEachItem | ✗ | Rewrite as Loop + Subflow; see Step 4 | ForEach and ForEachItem replaced by Loop |
io.kestra.plugin.core.flow.EachSequential | ✗ | Rewrite as Loop; same expression changes as ForEach | ForEach and ForEachItem replaced by Loop |
io.kestra.plugin.core.flow.EachParallel | ✗ | Rewrite as Loop with concurrencyLimit; same expression changes | ForEach and ForEachItem replaced by Loop |
Trigger conditions the tool cannot convert, including calendar conditions with a custom date | ✗ | Write the when expression or dependsOn entries by hand | Trigger conditions redesign |
Flow-level pluginDefaults: or taskDefaults: | ✗ | OSS: inline values on each task. EE: migrate to a namespace Policy | Plugin defaults removed |
| Schedule or Webhook trigger that does not supply a required input | ✗ | Supply the input under the trigger’s inputs:, add a defaults, or set required: false | — |
git.SyncFlow, git.TenantSync, git.NamespaceSync, ee.git.* sync and push tasks without auth: | ✗ | Add an inline auth: block | SDK authentication |
io.kestra.plugin.kestra.*, git.SyncFlows, git.Sync, git.PushFlows, ai.tool.KestraFlow without auth: | ⚠ | Add an auth: block, or configure credentials at the namespace, tenant, or server level | SDK authentication |
databricks.job.CreateJob without jobName | ✗ | Set jobName | — |
io.kestra.plugin.core.execution.Count | ✗ | Use a KV Store task or custom script | — |
io.kestra.plugin.core.execution.Resume | ✗ | Use the Kestra SDK | — |
io.kestra.plugin.core.trigger.Toggle | ✗ | Use the Kestra API or SDK | — |
io.kestra.plugin.git.Push | ✗ | io.kestra.plugin.git.SyncFlows | — |
io.kestra.plugin.scripts.nashorn.* | ✗ | Migrate to GraalJS or another script task | — |
HTTP trigger sslOptions shared through a YAML anchor, alias, or merge key | ✗ | Move it to options.ssl | HTTP task and trigger properties |
read() on the output of an ION-producing task | ⚠ | Wrap it in fromIon() | ION binary format |
read() or fileURI() with the version= argument | ⚠ | Rename the argument to revision= | — |
kestra-migrate neither rewrites nor flags the other removed HTTP properties, such as options.connectTimeout or a root-level allowFailed. Move them under options as described in HTTP task and trigger properties.
Step 3 — Apply the automated migration
Apply the migration to an output directory:
kestra-migrate -o ./v2-flows/ ./flows/The command prints a warning for each construct it cannot rewrite, and still writes every flow to the output directory. Auto-rewritable flows are migrated silently. The command exits 0 even when it prints warnings, so check stderr or run --check against the output directory afterward:
⚠ 01-foreach.yaml: deploy uses io.kestra.plugin.core.flow.ForEach (removed in v2; rewrite manually...) ↳ docs: https://kestra.io/docs/migration-guide/v2.0.0/foreach-loop⚠ 03-foreachitem.yaml: each_item uses io.kestra.plugin.core.flow.ForEachItem (removed in v2; rewrite manually...) ↳ docs: https://kestra.io/docs/migration-guide/v2.0.0/foreach-loop⚠ 04-plugin-defaults.yaml: flow-level `pluginDefaults` is removed in v2 and must be rewritten manually... ↳ docs: https://kestra.io/docs/migration-guide/v2.0.0/plugin-defaults-removed⚠ 07-task-defaults.yaml: flow-level `taskDefaults` is removed in v2 and must be rewritten manually... ↳ docs: https://kestra.io/docs/migration-guide/v2.0.0/plugin-defaults-removedVerify the auto-applied changes with diff -r:
diff -r ./flows/ ./v2-flows/diff -r flows/05-input-types.yaml v2-flows/05-input-types.yaml11c11< type: BOOLEAN---> type: BOOL15c15< type: ENUM---> type: SELECT24c24< type: ENUM---> type: SELECTdiff -r flows/06-type-renames.yaml v2-flows/06-type-renames.yaml15,16c15,16< type: io.kestra.plugin.core.state.Get< name: execution_count---> type: io.kestra.plugin.core.kv.Get> key: type-renames_states_tasks-states_execution_count19c19< type: io.kestra.plugin.core.storage.Purge---> type: io.kestra.plugin.core.execution.PurgeExecutions23c23< type: io.kestra.plugin.notifications.slack.SlackIncomingWebhook---> type: io.kestra.plugin.slack.notifications.SlackIncomingWebhookdiff -r flows/08-trigger-conditions.yaml v2-flows/08-trigger-conditions.yaml24,30c24< conditions:< - type: io.kestra.plugin.core.condition.DayWeekCondition< dayOfWeek: MONDAY< - type: io.kestra.plugin.core.condition.DayWeekCondition< dayOfWeek: WEDNESDAY< - type: io.kestra.plugin.core.condition.DayWeekCondition< dayOfWeek: FRIDAY---> when: "{{ (dayOfWeek(trigger.date) == 'MONDAY') and (dayOfWeek(trigger.date) == 'WEDNESDAY') and (dayOfWeek(trigger.date) == 'FRIDAY') }}"35,38c29< conditions:< - type: io.kestra.plugin.core.condition.DateTimeBetweenCondition< before: "18:00:00"< after: "08:00:00"---> when: "{{ hourOfDay(trigger.date) >= 8 and hourOfDay(trigger.date) < 18 }}"Flows 01, 03, 04, and 07 have no diff because the tool wrote them through unchanged. The ⚠ warnings above are your signal that manual work is still required on those four before you move to Step 4.
Bulk deployments with incompatible flows
If you are deploying a large namespace where some flows still need manual rewrites, use --disable-v2-incompatible instead of fixing everything first. The flag rewrites each incompatible flow into a disabled placeholder tagged v2-migration: needs-manual-rewrite, with the migrated definition preserved as comments. The placeholder deploys without error, so a single kestractl flows deploy can land the entire namespace.
Compatible flows run immediately. Incompatible ones are visible in the UI, searchable by label, and ready for manual review. Re-running the tool over its own output is a no-op: a disabled placeholder has no live v2-incompatible construct, so it produces no warning and is written through unchanged.
kestra-migrate --disable-v2-incompatible -o ./v2-flows/ ./flows/This flag cannot be combined with --stay-v1-compatible.
Parallel v1/v2 deployments
If you need migrated flows to run on a 1.3 instance while preparing for the 2.0 cutover, use --stay-v1-compatible. This skips every rule whose output 1.3 cannot parse, such as the trigger conditions → when rewrite, and applies only backward-compatible changes:
kestra-migrate --stay-v1-compatible -o ./v2-flows-compat/ ./flows/Step 4 — Fix manual items
After kestra-migrate, grep for patterns the tool does not rewrite and fix them before importing to 2.0.
Two AI options can help with the manual rewrites in this step:
-
migrate-kestra-2Claude Code skill — an interactive agent that reads your flow files, runs a pre-flight audit, then steps through each unsupported pattern with a three-tab decision UI (ForEach/ForEachItem, Defaults, Triggers) showing the flagged YAML and the proposed rewrite before asking for confirmation. It edits the files in place. Install from kestra-io/agent-skills, then open a Claude Code session in your flows directory and say “migrate my Kestra flows to 2.0”.Phase 1 — Pre-flight audit (example output):
┌─────────────────────────────────────────────────────────┐│ Kestra Migration Pre-flight Audit ││ Source: ./flows/ (8 flows) │├───────────────────────┬─────────────────────────────────┤│ Pattern │ Flows affected │├───────────────────────┼─────────────────────────────────┤│ ForEach │ 01-foreach.yaml ││ ForEachItem │ 03-foreachitem.yaml ││ pluginDefaults │ 04-plugin-defaults.yaml ││ taskDefaults │ 07-task-defaults.yaml ││ Flow trigger │ 08-trigger-conditions.yaml │└───────────────────────┴─────────────────────────────────┘Phase 2 — Triage complete (example summary):
┌──────────────────────────────────┬──────────────────────────────────────┬──────────┐│ Flow │ Change applied │ Status │├──────────────────────────────────┼──────────────────────────────────────┼──────────┤│ 01-foreach.yaml │ ForEach → Loop (confirmed) │ ✔ ││ 03-foreachitem.yaml │ ForEachItem → Split+Loop+Subflow │ ✔ ││ 04-plugin-defaults.yaml │ pluginDefaults inlined │ ✔ ││ 07-task-defaults.yaml │ taskDefaults inlined │ ✔ ││ 08-trigger-conditions.yaml │ Trigger shipped disabled: true │ ✔ │└──────────────────────────────────┴──────────────────────────────────────┴──────────┘⚠ Attention items:1. 07-task-defaults.yaml — transform_data calls json.dumps without import json.Pre-existing bug on 1.3, not migration-caused; carried through unchanged.2. 08-trigger-conditions.yaml — trigger ships disabled: true. Enable after thegating probe confirms correct dependsOn firing behavior. -
AI Copilot — available directly in the Kestra UI. Open the flow editor, switch to Edit mode, and describe the migration: “Rewrite this flow to replace ForEach with Loop”. The Copilot rewrites the YAML in place and waits for your confirmation before saving.
ForEach and ForEachItem
grep -rn "plugin.core.flow.ForEach\|plugin.core.flow.EachSequential\|plugin.core.flow.EachParallel" ./v2-flows/All four flow-iteration task types (ForEach, ForEachItem, EachSequential, and EachParallel) are removed in 2.0 and replaced by Loop. kestra-migrate flags each with ⚠ ✗ and writes the files through unchanged — the grep confirms which flows still need rewriting after Step 3.
kestra-migrate copies unit test suites unchanged and warns when a fixture or assertion targets a task inside a ForEach. Migrate each suite in the same run as its flow so the warning is precise rather than a guess, then update the suite as described in Unit tests.
ForEach → Loop
The minimum change is a type rename and expression rename:
# Before (1.3) — ForEach- id: deploy type: io.kestra.plugin.core.flow.ForEach values: '["dev", "staging", "production"]' tasks: - id: run_deploy type: io.kestra.plugin.core.log.Log message: "Deploying to {{ taskrun.value }} (step {{ taskrun.iteration }})"# After (2.0) — Loop- id: deploy type: io.kestra.plugin.core.flow.Loop values: '["dev", "staging", "production"]' tasks: - id: run_deploy type: io.kestra.plugin.core.log.Log message: "Deploying to {{ item.value }} (step {{ item.index }})"Expression changes:
| 1.3 | 2.0 |
|---|---|
{{ taskrun.value }} | {{ item.value }} |
{{ taskrun.iteration }} | {{ item.index }} |
{{ taskrun.key }} | {{ item.key }} |
{{ outputs.loopTask | length }} | {{ outputs.loopTask.iterationCount }} |
The last row requires two additional changes on the Loop task itself — without them outputs.loopTask is empty on 2.0:
- id: deploy type: io.kestra.plugin.core.flow.Loop values: '["dev", "staging", "production"]' fetchType: FETCH # collect iteration outputs outputs: - iterationCount # declare what you'll consume downstream tasks: - ...ForEachItem → Split + Loop + Subflow
ForEachItem split a file into batches and dispatched each to a subflow execution. In 2.0, use Split to produce per-batch URIs, then Loop over those URIs, dispatching to the subflow with an inline Subflow task:
# Before (1.3) — ForEachItem- id: each_item type: io.kestra.plugin.core.flow.ForEachItem items: "{{ inputs.report_file }}" batch: rows: 1 wait: true namespace: company.migration flowId: process-report-item inputs: item: "{{ taskrun.items }}"# After (2.0) — Split + Loop + Subflow- id: split type: io.kestra.plugin.core.storage.Split from: "{{ inputs.report_file }}" rows: 1
- id: each_item type: io.kestra.plugin.core.flow.Loop values: "{{ outputs.split.uris }}" tasks: - id: run_child type: io.kestra.plugin.core.flow.Subflow namespace: company.migration flowId: process-report-item wait: true transmitFailed: true inputs: item: "{{ item.value }}"The child subflow (process-report-item) still receives a URI string via item, exactly as taskrun.items did under batch.rows: 1. No change to the subflow itself is required. See the ForEach and ForEachItem replaced by Loop guide for the full pattern including output handling.
pluginDefaults at flow level
Flow-level pluginDefaults is removed in 2.0. kestra-migrate flags it with ⚠ ✗ and writes the file through unchanged. Grep to confirm which flows in your output directory still carry it:
grep -rn "pluginDefaults:" ./v2-flows/Any match requires manual work before the flow will behave correctly on 2.0. There is no automatic migration path: you must copy every property from the pluginDefaults block onto each individual task that needs it. If you skip this step, the tasks run without those values. Shared headers, timeouts, credentials, or method settings are simply not applied, and the flow produces different results than it did on 1.3.
The flow editor shows a 1 Error(s) badge with Validation error: Unrecognized field "pluginDefaults". Flows already in the database from before the upgrade can still execute, but the defaults are dropped. Fresh imports via the API return HTTP 422.
OSS: inline the values on each task:
# Before (1.3) — shared pluginDefaultspluginDefaults: - type: io.kestra.plugin.core.http.Request values: method: GET headers: Accept: application/json X-API-Version: "2" options: connectTimeout: PT5S readTimeout: PT30S
tasks: - id: fetch_orders type: io.kestra.plugin.core.http.Request uri: https://example.com/api/orders
- id: fetch_products type: io.kestra.plugin.core.http.Request uri: https://example.com/api/products# After (2.0, OSS) — inline on each tasktasks: - id: fetch_orders type: io.kestra.plugin.core.http.Request uri: https://example.com/api/orders method: GET headers: Accept: application/json X-API-Version: "2" options: timeout: connectTimeout: PT5S readIdleTimeout: PT30S
- id: fetch_products type: io.kestra.plugin.core.http.Request uri: https://example.com/api/products method: GET headers: Accept: application/json X-API-Version: "2" options: timeout: connectTimeout: PT5S readIdleTimeout: PT30SThe 1.3 options.connectTimeout and options.readTimeout properties are also removed in 2.0, so they move under options.timeout as you inline them. See HTTP task and trigger properties for the other renamed HTTP options.
EE: move defaults to a namespace Policy:
See Plugin defaults removed for the Policy syntax.
taskDefaults
taskDefaults: and pluginDefaults: are both removed in 2.0. kestra-migrate flags taskDefaults: as a ⚠ ✗ item and writes the file through unchanged; the key stays in your output directory as-is. Both keys produce HTTP 422 on import:
# taskDefaults: (original 1.3 key)HTTP 422 {"detail":"Unrecognized field \"taskDefaults\""}
# pluginDefaults: (also rejected)HTTP 422 {"detail":"Unrecognized field \"pluginDefaults\""}Grep for both in your output directory and inline the values or migrate to a Policy:
grep -rln "taskDefaults:" ./v2-flows/grep -rln "pluginDefaults:" ./v2-flows/Other patterns to grep for
The tool does not detect these removed HTTP properties:
# Removed HTTP options (move under options.timeout, options.proxy, options.logs)grep -rnE "^\s*(connectTimeout|readTimeout|proxyType|proxyAddress|proxyPort|proxyUsername|proxyPassword|logLevel|readIdleTimeout|maxContentLength):" ./v2-flows/
# Root-level allowFailed or sslOptions on HTTP tasks and triggers (move under options)grep -rnE "^\s*(allowFailed|sslOptions):" ./v2-flows/Both patterns also match valid 2.0 YAML, such as options.timeout.connectTimeout or options.allowFailed, so check each match against the HTTP task and trigger properties table.
kestra-migrate rewrites the script task runner: and docker: keys, Pebble json() and | json, and adds recursive: true to fs.local.Delete. It flags ION read() expressions and tasks that need SDK authentication. If you ran an older version of the tool, re-run the migration against your original flows to apply the newer rules.
Verify all flows are v2-compatible
Once you have applied all manual fixes, re-run --check against your output directory:
kestra-migrate --check ./v2-flows/When every flow is clean, the output is all ✔ and the tool exits 0:
✔ 01-foreach.yaml✔ 02-foreachitem-processor.yaml✔ 03-foreachitem.yaml✔ 04-plugin-defaults.yaml✔ 05-input-types.yaml✔ 06-type-renames.yaml✔ 07-task-defaults.yaml✔ 08-trigger-conditions.yaml
✔ All 8 flows are v2-compatibleExit code 0 is your gate to proceed to Step 5. If any ⚠ or ✎ lines remain, complete those fixes before switching the server.
Step 5 — Switch to Kestra 2.0
Stop the Kestra container (leave Postgres running):
docker compose stop kestraUpdate docker-compose.yml:
# Imageimage: kestra/kestra:v2.0.0-slim
# Auth config key changed from camelCase to kebab-case in 2.0kestra: server: basic-auth: # was: basicAuth enabled: true username: admin@kestra.io password: "Kestra123!"Start the 2.0 container:
docker compose up kestra -dDatabase migration log
On OSS, database migrations run automatically on first startup against a pre-2.0 schema. The migration runner detects the existing Flyway-managed schema and applies all pending 2.0 migrations:
INFO MigrationRunner Detected existing Flyway-managed schema. Init scripts will be marked as applied without execution.INFO MigrationRunner Migration [0-init] recorded as applied without execution (Flyway upgrade: schema pre-existing)INFO MigrationRunner Migration [0-init-queue] recorded as applied without execution (Flyway upgrade: schema pre-existing)INFO MigrationRunner Applying migration [2.0.01-schema]: Kestra 2.0 schema upgradeINFO MigrationRunner Migration [2.0.01-schema] applied successfully in 119msINFO MigrationRunner Applying migration [2.0.02-queue]: Queue 2.0 upgradeINFO MigrationRunner Migration [2.0.02-queue] applied successfully in 26msINFO MigrationRunner Applying migration [2.0.03-triggers]: Migrate V1 trigger rows to TriggerStateINFO V2_0_03TriggerMigration Trigger migration complete: 5 row(s) migrated, 0 already in V2 format.INFO MigrationRunner Migration [2.0.03-triggers] applied successfully in 195msINFO MigrationRunner Applying migration [2.0.04-basic-auth-password]: Migrate BasicAuth password hash from SHA-512 to bcrypt(SHA-512) (CWE-916 fix)INFO V2_0_04BasicAuthPasswordMigration BasicAuth migration: password successfully upgraded to bcrypt (cost 12).INFO MigrationRunner Migration [2.0.04-basic-auth-password] applied successfully in 231msINFO MigrationRunner Applying migration [2.0.06-widen-logs-postgres]: Dedicated log store: widen task_id and trigger_id to VARCHAR(256)INFO MigrationRunner Migration [2.0.06-widen-logs-postgres] applied successfully in 2msINFO MigrationRunner Applying migration [2.0.09-execution-outputs]: Executions: store the execution outputs in a dedicated tableINFO MigrationRunner Migration [2.0.09-execution-outputs] applied successfully in 1msINFO MigrationRunner Applying migration [2.0.11-plugin-auto-install]: Auto-install the plugins referenced by existing flows that are missing from the local plugin registryINFO V2_0_11PluginAutoInstallMigration Detected 14 plugin types referenced by existing flows but missing from the local registryINFO PluginInstallJobRegistry Queued async plugin install job for artifacts: [plugin-serdes, plugin-slack, plugin-jdbc-duckdb, plugin-script-shell, plugin-script-python, plugin-docker]INFO LocalPluginManager Plugin 'io.kestra.plugin:plugin-serdes:jar:2.0.3' installed successfullyINFO LocalPluginManager Plugin 'io.kestra.plugin:plugin-slack:jar:2.0.2' installed successfullyINFO LocalPluginManager Plugin 'io.kestra.plugin:plugin-jdbc-duckdb:jar:2.0.1' installed successfullyINFO LocalPluginManager Plugin 'io.kestra.plugin:plugin-script-shell:jar:2.0.4' installed successfullyINFO LocalPluginManager Plugin 'io.kestra.plugin:plugin-script-python:jar:2.0.3' installed successfullyINFO LocalPluginManager Plugin 'io.kestra.plugin:plugin-docker:jar:2.0.2' installed successfullyINFO PluginInstallJobRegistry Async plugin install job succeededINFO MigrationRunner Migration [2.0.11-plugin-auto-install] applied successfully in 16770msINFO MigrationRunner Applying migration [2.0.12-fix-state-duration]: OSS PostgreSQL: fix executions.state_duration to store the total duration in millisecondsINFO MigrationRunner Migration [2.0.12-fix-state-duration] applied successfully in 18msINFO AbstractCommand Starting Kestra 2.0.0 with environments [cli]INFO VersionService Updating instance version from 1.3.5 to 2.0.0INFO AbstractCommand Management server running at http://...:8081INFO AbstractCommand Health endpoint is available at http://...:8081/healthWhat each migration does:
| Migration | What it does |
|---|---|
2.0.01-schema | Applies the 2.0 schema changes |
2.0.02-queue | Rebuilds the queues table for the 2.0 queue format |
2.0.03-triggers | Converts 1.3 trigger rows to the new TriggerState format |
2.0.04-basic-auth-password | Upgrades BasicAuth password hash from SHA-512 to bcrypt (CWE-916 fix) |
2.0.06-widen-logs-postgres | Widens task_id and trigger_id columns in the log store |
2.0.09-execution-outputs | Creates a dedicated table for execution outputs |
2.0.11-plugin-auto-install | Scans existing flows, detects referenced plugin types missing from the local registry, and installs them automatically |
2.0.12-fix-state-duration | Fixes the state_duration column to store milliseconds correctly |
The plugin auto-install migration (2.0.11) is the slowest: it downloads plugins from Maven Central. In our run it took 16 seconds for 6 plugins. The count depends on how many plugins your flows reference that are not bundled in the base 2.0 image.
The plugin auto-install migration (2.0.11) detects io.kestra.plugin.core.flow.ForEach and io.kestra.plugin.core.flow.ForEachItem as “missing types” and attempts to install plugins for them. They are removed types, not missing plugins. The install attempt will fail silently for these, and the scheduler will emit WARN lines for any flow that references them:
WARN FlowListeners Can't parse flow company.migration/deploy-environments with errors 'InvalidTypeConstraintViolationException: Invalid type: io.kestra.plugin.core.flow.ForEach'WARN FlowListeners Can't parse flow company.migration/process-report with errors 'InvalidTypeConstraintViolationException: Invalid type: io.kestra.plugin.core.flow.ForEachItem'These flows need to be rewritten before they can run on 2.0.
Database migration on Enterprise Edition
On EE, the server refuses to start if pending migrations exist. Run them manually before starting:
# Check pendingkestra migrate plan
# Applykestra migrate runIn Docker Compose, run migrations as a one-time service before starting the main server:
services: kestra-migrate: image: kestra/kestra-ee:v2.0.0 command: migrate run environment: KESTRA_CONFIGURATION: | # same as your kestra service config depends_on: postgres: condition: service_healthy
kestra: image: kestra/kestra-ee:v2.0.0 depends_on: kestra-migrate: condition: service_completed_successfullySee Database migrations for the full EE reference.
Step 6 — Verify behavior on 2.0
Flows in the database
All flows stored in the 1.3 database survive the upgrade and appear in the namespace listing immediately:
curl -g -s -u "admin@kestra.io:Kestra123!" \ "http://localhost:8080/api/v1/flows/search?filters[namespace][EQUALS]=company.migration" \ | jq '{total: .total, ids: [.results[].id]}'{ "total": 8, "ids": [ "deploy-environments", "fetch-api-data", "input-types-migration", "process-report", "process-report-item", "task-defaults-migration", "trigger-conditions", "type-renames" ]}All eight flows are present. The next step is to verify which ones actually execute.
Attempting to run ForEach on 2.0
kestractl executions run company.migration deploy-environmentsHTTP/1.1 500 Internal Server Error{"message":"Flow company.migration/deploy-environments failed to parse: Invalid type: io.kestra.plugin.core.flow.ForEach"}The flow exists in the database, but the 2.0 scheduler cannot parse it. The API also returns an empty task list for these flows:
curl -s -u "admin@kestra.io:Kestra123!" \ "http://localhost:8080/api/v1/flows/company.migration/deploy-environments" \ | jq '.tasks'[]ForEachItem behaves the same way. Any attempt to trigger process-report returns HTTP 500.
Attempting to run pluginDefaults on 2.0
The fetch-api-data flow executes without error:
kestractl executions run company.migration fetch-api-data{"id": "3Rp7LmWxqhK91BN2P5t4GU", "state": "RUNNING"}The execution completes with SUCCESS. However, the pluginDefaults block was dropped. No error is returned by the execution API. Check the flow definition on 2.0:
curl -s -u "admin@kestra.io:Kestra123!" \ "http://localhost:8080/api/v1/flows/company.migration/fetch-api-data" \ | jq 'has("pluginDefaults")'falseThe pluginDefaults field is not present in the 2.0 server’s representation of the flow. Each HTTP task ran with its own defaults, without the shared Accept: application/json, X-API-Version: 2, connectTimeout, or readTimeout from the original block. The execution reports success, but the behavior changed. The flow editor surfaces this as a validation error (1 Error(s) badge), but a DB-migrated flow can still execute, making the regression easy to miss in automated test runs.
Import migrated flows not yet in the database
For flows that exist only as local YAML files (not yet uploaded to the 2.0 instance), import them from the v2-flows/ directory after completing all manual fixes:
kestractl flows deploy ./v2-flows/ --namespace company.team --overrideSilent behavior changes — smoke-test checklist
Some 2.0 changes parse without error and only surface at runtime. Run through this checklist before signing off on the migration:
-
fs.local.Deletedirectory deletions — confirm subdirectories are still removed where expected (recursive: truerequired; the default flipped tofalse).kestra-migrateadds it to tasks that do not setrecursive, but flows deployed without the tool keep the new default. - ION output reads — confirm expressions that read ION-producing task outputs produce correct values (
fromIon()wrapping required) - Flow triggers — deploy disabled first — import any migrated Flow triggers with
disabled: trueset on the trigger before enabling them. A known issue in early 2.0 builds caused migrated Flow triggers to fire on every flow in the namespace rather than only the listeddependsOnflows. Enable each trigger only after the gating probe below passes. - Flow triggers with
PAUSEDupstream states — confirm any migrated Flow triggers that relied onPAUSEDin the default states list now declarestates: [SUCCESS, WARNING, PAUSED]explicitly (the default lostPAUSEDin 2.0) -
dependsOngating probe — enable one trigger at a time and confirm: triggering one upstream flow does not fire it; triggering all upstreams inside the window fires exactly once; triggering an unrelated flow in the namespace does not fire it - Multi-entry
dependsOnoutput access — confirm consumers readtrigger.outputs.<flowId>.<key>(scoped shape) rather than the unscopedtrigger.outputs.<key>shorthand, which applies only to single-entrydependsOn - EE:
IMPERSONATEpermission — re-grant theUSER: IMPERSONATEpermission to any roles that previously heldIMPERSONATE; it was dropped in 2.0 and must be granted manually under the new action model
Step 7 — Verify and complete
Health check:
curl http://localhost:8081/health{"name":"kestra","status":"UP","details":{"service":{"name":"kestra","status":"UP"},"jdbc":{"name":"kestra","status":"UP"}}}Validate all imported flows:
kestractl flows export-by-query --output-file flows.zipunzip -q -o flows.zip -d flows/kestractl flows validate ./flows/Any flow that fails to parse on the 2.0 server will be reported here. Fix reported flows before proceeding.
Run a test execution on a migrated flow:
kestractl executions run company.migration input-types-migration --wait--wait blocks until the execution reaches a terminal state. The command exits non-zero on FAILED, KILLED, or CANCELLED.
Check the 2.0 server logs for any remaining WARN lines about unparseable flows. Any InvalidTypeConstraintViolationException lines name flows that still need manual rewriting:
docker compose logs kestra | grep InvalidTypeConstraintNo output means all flows loaded cleanly.
Review RBAC if you use Enterprise Edition. The RBAC action model changed from CRUD to resource+action in 2.0.
Create the Instance Owner account (EE):
kestra auths users create \ --instance-owner \ --username=admin@yourcompany.com \ --password=yourpasswordSee Instance Owner for full details.
Troubleshooting
kestra-migrate exits 1 when I expected 0
In --check mode, the tool exits 1 whenever any flow needs migration. Check the output for ✎ (auto-rewritable) and ⚠ ✗ (manual required) markers. Each ⚠ ✗ line includes a ↳ docs: link to the relevant migration guide. Without --check, the migration exits 0 even when it prints warnings.
A flow passes kestra-migrate but fails to import on 2.0
kestra-migrate leaves every ⚠ ✗ construct in place, and those flows fail on the 2.0 server. Re-run --check against your output directory and fix every remaining ✗:
kestra-migrate --check ./v2-flows/If --check is clean but the import still fails, look for the removed HTTP properties the tool does not detect, listed in Other patterns to grep for. Also confirm you are running the latest kestra-migrate release.
The trigger when expression ANDs multiple day conditions
On 1.3, a trigger fires only when all of its conditions are true. kestra-migrate keeps that behavior and joins multiple DayWeekCondition entries with and, so the migrated trigger fires only if the date is simultaneously Monday, Wednesday, and Friday, which never happens. The original 1.3 trigger never fired either. If you meant any of those days, rewrite the expression with or:
when: > {{ (dayOfWeek(trigger.date) == 'MONDAY') or (dayOfWeek(trigger.date) == 'WEDNESDAY') or (dayOfWeek(trigger.date) == 'FRIDAY') }}See Trigger conditions redesign for the complete when expression reference.
A Flow trigger used windowAdvance
windowAdvance is removed in 2.0 with no direct equivalent. If your trigger relied on it, discuss the intended behavior with your team and either remodel it using the available window options (every, lookback, from/to, deadline) or disable the trigger until a replacement strategy is confirmed.
Login fails after upgrading to 2.0
The basicAuth: config key changed to basic-auth: (kebab-case) in 2.0. Update your KESTRA_CONFIGURATION or application.yaml before starting the 2.0 server.
EE server refuses to start after upgrade
If you see Pending migrations exist, server startup blocked, run kestra migrate run before starting the server. See Database migrations.
pluginDefaults flows execute but behavior changed
If a flow ran correctly on 1.3 but produces different results on 2.0, check whether it used pluginDefaults. The 2.0 server drops the block and the flow editor shows a 1 Error(s) badge, but DB-migrated flows can still execute without the defaults applied. Verify with the API: GET /api/v1/flows/{namespace}/{id}. If pluginDefaults is absent from the response, inline the values on each task (OSS) or migrate to a namespace Policy (EE).
Was this page helpful?