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 .md to any kestra.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.

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

ResourceWhere to get it
kestra-migrate flow migration CLIgithub.com/kestra-io/kestra2-flow-migration — download the binary from Releases; outputs in this tutorial were captured from v2.6.11
kestractlInstall guide
Kestra 2.0 Docker imagekestra/kestra:v2.0.0-slim

Prerequisites

  • The latest kestra-migrate release on your PATH. 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; set KESTRA_MIGRATE_NO_UPDATE_CHECK=1 to turn the check off in CI.
  • A backup of your Kestra PostgreSQL or H2 database
  • kestractl v2 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:

SymbolMeaningAction required
✔No changes needed — flow is already v2-compatibleNone
✎Auto-rewritable — the tool will apply the change when you run without --checkRun kestra-migrate -o ./v2-flows/ ./flows/
⚠ ✗Removed or unsupported construct — the tool migrates the rest of the flow and leaves the construct in placeRewrite 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: PT30S

flows/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/json

Flows 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 migration

Exit 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 (✎)

PatternBefore (1.3)After (2.0)
Input namename: my_inputid: my_input
Input and flow output type namesBOOLEAN, ENUMBOOL, SELECT
MULTISELECT input choicesoptions: [...]values: [...]
Required/defaults conflictrequired: false + defaults:Remove required: false
Retry attemptsmaxAttempt: 3maxAttempts: 3
Flow IDs that clash with reserved keywordsid: pauseid: pause-flow
Renamed task and condition typescore.state.*, core.storage.Purge, core.debug.Echo, core.log.Fetch, notifications.slack.*, io.kestra.core.* paths, *Condition suffixescore.kv.*, core.execution.PurgeExecutions, core.log.Log, kestra.logs.Fetch, slack.notifications.*, io.kestra.plugin.core.* paths, no suffix
State Store task propertiesname, datakey (the 1.3 State Store key, so existing state is still read), value
Pause durationdelay: PT1MpauseDuration: PT1M
Removed propertiesSubflow.outputs, Schedule.backfill, trigger minLogLevelRemoved
Script task runnerrunner: DOCKER, docker: {image: ...}taskRunner: {type: ...Docker}, containerImage: ...
fs.local.DeleteNo recursiverecursive: true, which keeps the 1.3 behavior
PurgeKVexpiredOnly: falsebehavior: {type: key, expiredOnly: false}
dbt.cli.Buildtype: ...dbt.cli.Buildtype: ...dbt.cli.DbtCLI with commands: [dbt build]
Exit state and states: listsCANCELEDCANCELLED
Trigger conditionsconditions: [DayWeekCondition, ...]when: "{{ dayOfWeek(...) == ... }}"
Flow trigger dependenciesconditions: [ExecutionFlow, MultipleCondition, ...]dependsOn: [{flowId: ...}]
Check conditionchecks: [{condition: ...}]checks: [{when: ...}]
Pebble json() function{{ json(x) }}{{ fromJson(x) }}
Pebble | json filter{{ value | json }}{{ value | toJson }}
HTTP basic authoptions.basicAuthUser, options.basicAuthPasswordoptions.auth: {type: BASIC, username, password}
HTTP trigger SSL optionssslOptions: {...}options: {ssl: {...}}
HTTP connection pool timeoutoptions.connectionPoolIdleTimeoutRemoved
EE: Worker GroupworkerGroup: {key: gpu}workerSelector: {tags: [gpu], fallback: WAIT}
EE: Git cloneio.kestra.plugin.ee.git.Cloneio.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.

PatternSeverityManual fixGuide
io.kestra.plugin.core.flow.ForEach✗Rewrite as Loop; replace taskrun.value → item.value, taskrun.iteration → item.indexForEach and ForEachItem replaced by Loop
io.kestra.plugin.core.flow.ForEachItem✗Rewrite as Loop + Subflow; see Step 4ForEach and ForEachItem replaced by Loop
io.kestra.plugin.core.flow.EachSequential✗Rewrite as Loop; same expression changes as ForEachForEach and ForEachItem replaced by Loop
io.kestra.plugin.core.flow.EachParallel✗Rewrite as Loop with concurrencyLimit; same expression changesForEach 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 handTrigger conditions redesign
Flow-level pluginDefaults: or taskDefaults:✗OSS: inline values on each task. EE: migrate to a namespace PolicyPlugin 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: blockSDK 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 levelSDK 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.sslHTTP 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-removed

Verify the auto-applied changes with diff -r:

diff -r ./flows/ ./v2-flows/
diff -r flows/05-input-types.yaml v2-flows/05-input-types.yaml
11c11
< type: BOOLEAN
---
> type: BOOL
15c15
< type: ENUM
---
> type: SELECT
24c24
< type: ENUM
---
> type: SELECT
diff -r flows/06-type-renames.yaml v2-flows/06-type-renames.yaml
15,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_count
19c19
< type: io.kestra.plugin.core.storage.Purge
---
> type: io.kestra.plugin.core.execution.PurgeExecutions
23c23
< type: io.kestra.plugin.notifications.slack.SlackIncomingWebhook
---
> type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
diff -r flows/08-trigger-conditions.yaml v2-flows/08-trigger-conditions.yaml
24,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.

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.32.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 pluginDefaults
pluginDefaults:
- 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 task
tasks:
- 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: PT30S

The 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.

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-compatible

Exit 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 kestra

Update docker-compose.yml:

# Image
image: kestra/kestra:v2.0.0-slim
# Auth config key changed from camelCase to kebab-case in 2.0
kestra:
server:
basic-auth: # was: basicAuth
enabled: true
username: admin@kestra.io
password: "Kestra123!"

Start the 2.0 container:

docker compose up kestra -d

Database 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 upgrade
INFO MigrationRunner Migration [2.0.01-schema] applied successfully in 119ms
INFO MigrationRunner Applying migration [2.0.02-queue]: Queue 2.0 upgrade
INFO MigrationRunner Migration [2.0.02-queue] applied successfully in 26ms
INFO MigrationRunner Applying migration [2.0.03-triggers]: Migrate V1 trigger rows to TriggerState
INFO 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 195ms
INFO 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 231ms
INFO 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 2ms
INFO MigrationRunner Applying migration [2.0.09-execution-outputs]: Executions: store the execution outputs in a dedicated table
INFO MigrationRunner Migration [2.0.09-execution-outputs] applied successfully in 1ms
INFO MigrationRunner Applying migration [2.0.11-plugin-auto-install]: Auto-install the plugins referenced by existing flows that are missing from the local plugin registry
INFO V2_0_11PluginAutoInstallMigration Detected 14 plugin types referenced by existing flows but missing from the local registry
INFO 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 successfully
INFO LocalPluginManager Plugin 'io.kestra.plugin:plugin-slack:jar:2.0.2' installed successfully
INFO LocalPluginManager Plugin 'io.kestra.plugin:plugin-jdbc-duckdb:jar:2.0.1' installed successfully
INFO LocalPluginManager Plugin 'io.kestra.plugin:plugin-script-shell:jar:2.0.4' installed successfully
INFO LocalPluginManager Plugin 'io.kestra.plugin:plugin-script-python:jar:2.0.3' installed successfully
INFO LocalPluginManager Plugin 'io.kestra.plugin:plugin-docker:jar:2.0.2' installed successfully
INFO PluginInstallJobRegistry Async plugin install job succeeded
INFO MigrationRunner Migration [2.0.11-plugin-auto-install] applied successfully in 16770ms
INFO MigrationRunner Applying migration [2.0.12-fix-state-duration]: OSS PostgreSQL: fix executions.state_duration to store the total duration in milliseconds
INFO MigrationRunner Migration [2.0.12-fix-state-duration] applied successfully in 18ms
INFO AbstractCommand Starting Kestra 2.0.0 with environments [cli]
INFO VersionService Updating instance version from 1.3.5 to 2.0.0
INFO AbstractCommand Management server running at http://...:8081
INFO AbstractCommand Health endpoint is available at http://...:8081/health

What each migration does:

MigrationWhat it does
2.0.01-schemaApplies the 2.0 schema changes
2.0.02-queueRebuilds the queues table for the 2.0 queue format
2.0.03-triggersConverts 1.3 trigger rows to the new TriggerState format
2.0.04-basic-auth-passwordUpgrades BasicAuth password hash from SHA-512 to bcrypt (CWE-916 fix)
2.0.06-widen-logs-postgresWidens task_id and trigger_id columns in the log store
2.0.09-execution-outputsCreates a dedicated table for execution outputs
2.0.11-plugin-auto-installScans existing flows, detects referenced plugin types missing from the local registry, and installs them automatically
2.0.12-fix-state-durationFixes 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.

Database migration on Enterprise Edition

On EE, the server refuses to start if pending migrations exist. Run them manually before starting:

# Check pending
kestra migrate plan
# Apply
kestra migrate run

In 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_successfully

See 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-environments
HTTP/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")'
false

The 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 --override

Silent 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.Delete directory deletions — confirm subdirectories are still removed where expected (recursive: true required; the default flipped to false). kestra-migrate adds it to tasks that do not set recursive, 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: true set 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 listed dependsOn flows. Enable each trigger only after the gating probe below passes.
  • Flow triggers with PAUSED upstream states — confirm any migrated Flow triggers that relied on PAUSED in the default states list now declare states: [SUCCESS, WARNING, PAUSED] explicitly (the default lost PAUSED in 2.0)
  • dependsOn gating 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 dependsOn output access — confirm consumers read trigger.outputs.<flowId>.<key> (scoped shape) rather than the unscoped trigger.outputs.<key> shorthand, which applies only to single-entry dependsOn
  • EE: IMPERSONATE permission — re-grant the USER: IMPERSONATE permission to any roles that previously held IMPERSONATE; 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.zip
unzip -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 InvalidTypeConstraint

No 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=yourpassword

See 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?