diff --git a/docs/docs.json b/docs/docs.json
index 29171af0..e94d7a95 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -209,6 +209,40 @@
}
]
},
+ {
+ "tab": "Tutorials",
+ "groups": [
+ {
+ "group": "Fleet & nodes",
+ "pages": [
+ "tutorials/enroll-a-remote-node",
+ "tutorials/set-up-fleet-secrets",
+ "tutorials/set-up-fleet-federation",
+ "tutorials/configure-recovery-vault-backups"
+ ]
+ },
+ {
+ "group": "Deploy & automate",
+ "pages": [
+ "tutorials/create-and-approve-a-blueprint",
+ "tutorials/connect-a-git-source",
+ "tutorials/schedule-an-operation",
+ "tutorials/configure-auto-update-policies",
+ "tutorials/configure-auto-heal-policies",
+ "tutorials/set-up-deploy-enforcement"
+ ]
+ },
+ {
+ "group": "Secure & integrate",
+ "pages": [
+ "tutorials/set-up-sso",
+ "tutorials/configure-environment-guardrails",
+ "tutorials/set-up-a-webhook",
+ "tutorials/resolve-stack-drift"
+ ]
+ }
+ ]
+ },
{
"tab": "API Reference",
"openapi": "openapi.yaml",
diff --git a/docs/images/tutorials/configure-auto-heal-policies/active-policy-web.png b/docs/images/tutorials/configure-auto-heal-policies/active-policy-web.png
new file mode 100644
index 00000000..6f9c6ca2
Binary files /dev/null and b/docs/images/tutorials/configure-auto-heal-policies/active-policy-web.png differ
diff --git a/docs/images/tutorials/configure-auto-heal-policies/add-policy-form-filled.png b/docs/images/tutorials/configure-auto-heal-policies/add-policy-form-filled.png
new file mode 100644
index 00000000..f38d9d3c
Binary files /dev/null and b/docs/images/tutorials/configure-auto-heal-policies/add-policy-form-filled.png differ
diff --git a/docs/images/tutorials/configure-auto-heal-policies/container-healthy-recovered.png b/docs/images/tutorials/configure-auto-heal-policies/container-healthy-recovered.png
new file mode 100644
index 00000000..80da857e
Binary files /dev/null and b/docs/images/tutorials/configure-auto-heal-policies/container-healthy-recovered.png differ
diff --git a/docs/images/tutorials/configure-auto-heal-policies/container-unhealthy-triggered.png b/docs/images/tutorials/configure-auto-heal-policies/container-unhealthy-triggered.png
new file mode 100644
index 00000000..fe6b8252
Binary files /dev/null and b/docs/images/tutorials/configure-auto-heal-policies/container-unhealthy-triggered.png differ
diff --git a/docs/images/tutorials/configure-auto-heal-policies/dashboard-stack-restarts-autoheal.png b/docs/images/tutorials/configure-auto-heal-policies/dashboard-stack-restarts-autoheal.png
new file mode 100644
index 00000000..2e3f82e9
Binary files /dev/null and b/docs/images/tutorials/configure-auto-heal-policies/dashboard-stack-restarts-autoheal.png differ
diff --git a/docs/images/tutorials/configure-auto-heal-policies/monitor-sheet-empty.png b/docs/images/tutorials/configure-auto-heal-policies/monitor-sheet-empty.png
new file mode 100644
index 00000000..6db34928
Binary files /dev/null and b/docs/images/tutorials/configure-auto-heal-policies/monitor-sheet-empty.png differ
diff --git a/docs/images/tutorials/configure-auto-heal-policies/policy-recent-activity-restarted.png b/docs/images/tutorials/configure-auto-heal-policies/policy-recent-activity-restarted.png
new file mode 100644
index 00000000..e934e299
Binary files /dev/null and b/docs/images/tutorials/configure-auto-heal-policies/policy-recent-activity-restarted.png differ
diff --git a/docs/images/tutorials/configure-auto-heal-policies/sidebar-context-menu-auto-heal.png b/docs/images/tutorials/configure-auto-heal-policies/sidebar-context-menu-auto-heal.png
new file mode 100644
index 00000000..60006fac
Binary files /dev/null and b/docs/images/tutorials/configure-auto-heal-policies/sidebar-context-menu-auto-heal.png differ
diff --git a/docs/images/tutorials/configure-auto-update-policies/all-tasks-row.png b/docs/images/tutorials/configure-auto-update-policies/all-tasks-row.png
new file mode 100644
index 00000000..d0ad8a6a
Binary files /dev/null and b/docs/images/tutorials/configure-auto-update-policies/all-tasks-row.png differ
diff --git a/docs/images/tutorials/configure-auto-update-policies/all-tasks-success.png b/docs/images/tutorials/configure-auto-update-policies/all-tasks-success.png
new file mode 100644
index 00000000..cbc63cde
Binary files /dev/null and b/docs/images/tutorials/configure-auto-update-policies/all-tasks-success.png differ
diff --git a/docs/images/tutorials/configure-auto-update-policies/context-menu-new-label-form.png b/docs/images/tutorials/configure-auto-update-policies/context-menu-new-label-form.png
new file mode 100644
index 00000000..cd59d045
Binary files /dev/null and b/docs/images/tutorials/configure-auto-update-policies/context-menu-new-label-form.png differ
diff --git a/docs/images/tutorials/configure-auto-update-policies/new-schedule-label-filled.png b/docs/images/tutorials/configure-auto-update-policies/new-schedule-label-filled.png
new file mode 100644
index 00000000..9d7bc302
Binary files /dev/null and b/docs/images/tutorials/configure-auto-update-policies/new-schedule-label-filled.png differ
diff --git a/docs/images/tutorials/configure-auto-update-policies/readiness-board-up-to-date.png b/docs/images/tutorials/configure-auto-update-policies/readiness-board-up-to-date.png
new file mode 100644
index 00000000..7c25716e
Binary files /dev/null and b/docs/images/tutorials/configure-auto-update-policies/readiness-board-up-to-date.png differ
diff --git a/docs/images/tutorials/configure-auto-update-policies/run-history-two-runs.png b/docs/images/tutorials/configure-auto-update-policies/run-history-two-runs.png
new file mode 100644
index 00000000..6e512888
Binary files /dev/null and b/docs/images/tutorials/configure-auto-update-policies/run-history-two-runs.png differ
diff --git a/docs/images/tutorials/configure-auto-update-policies/run-history.png b/docs/images/tutorials/configure-auto-update-policies/run-history.png
new file mode 100644
index 00000000..ff12c2b0
Binary files /dev/null and b/docs/images/tutorials/configure-auto-update-policies/run-history.png differ
diff --git a/docs/images/tutorials/configure-auto-update-policies/sidebar-auto-update-group.png b/docs/images/tutorials/configure-auto-update-policies/sidebar-auto-update-group.png
new file mode 100644
index 00000000..6f9b5965
Binary files /dev/null and b/docs/images/tutorials/configure-auto-update-policies/sidebar-auto-update-group.png differ
diff --git a/docs/images/tutorials/configure-environment-guardrails/activity-tab-block-and-fix.png b/docs/images/tutorials/configure-environment-guardrails/activity-tab-block-and-fix.png
new file mode 100644
index 00000000..fe09fe72
Binary files /dev/null and b/docs/images/tutorials/configure-environment-guardrails/activity-tab-block-and-fix.png differ
diff --git a/docs/images/tutorials/configure-environment-guardrails/compose-env-file-mistake.png b/docs/images/tutorials/configure-environment-guardrails/compose-env-file-mistake.png
new file mode 100644
index 00000000..d756b4f5
Binary files /dev/null and b/docs/images/tutorials/configure-environment-guardrails/compose-env-file-mistake.png differ
diff --git a/docs/images/tutorials/configure-environment-guardrails/compose-required-password.png b/docs/images/tutorials/configure-environment-guardrails/compose-required-password.png
new file mode 100644
index 00000000..f532b3b4
Binary files /dev/null and b/docs/images/tutorials/configure-environment-guardrails/compose-required-password.png differ
diff --git a/docs/images/tutorials/configure-environment-guardrails/deploy-guardrails-setting-on.png b/docs/images/tutorials/configure-environment-guardrails/deploy-guardrails-setting-on.png
new file mode 100644
index 00000000..68978222
Binary files /dev/null and b/docs/images/tutorials/configure-environment-guardrails/deploy-guardrails-setting-on.png differ
diff --git a/docs/images/tutorials/configure-environment-guardrails/env-file-password-cleared.png b/docs/images/tutorials/configure-environment-guardrails/env-file-password-cleared.png
new file mode 100644
index 00000000..d6e743d1
Binary files /dev/null and b/docs/images/tutorials/configure-environment-guardrails/env-file-password-cleared.png differ
diff --git a/docs/images/tutorials/configure-environment-guardrails/env-tab-db-password-required.png b/docs/images/tutorials/configure-environment-guardrails/env-tab-db-password-required.png
new file mode 100644
index 00000000..b554b43b
Binary files /dev/null and b/docs/images/tutorials/configure-environment-guardrails/env-tab-db-password-required.png differ
diff --git a/docs/images/tutorials/configure-environment-guardrails/update-blocked-notification.png b/docs/images/tutorials/configure-environment-guardrails/update-blocked-notification.png
new file mode 100644
index 00000000..bac67621
Binary files /dev/null and b/docs/images/tutorials/configure-environment-guardrails/update-blocked-notification.png differ
diff --git a/docs/images/tutorials/configure-environment-guardrails/update-succeeded-health-gate.png b/docs/images/tutorials/configure-environment-guardrails/update-succeeded-health-gate.png
new file mode 100644
index 00000000..e2bb887c
Binary files /dev/null and b/docs/images/tutorials/configure-environment-guardrails/update-succeeded-health-gate.png differ
diff --git a/docs/images/tutorials/connect-a-git-source/branch-error.png b/docs/images/tutorials/connect-a-git-source/branch-error.png
new file mode 100644
index 00000000..7a844baa
Binary files /dev/null and b/docs/images/tutorials/connect-a-git-source/branch-error.png differ
diff --git a/docs/images/tutorials/connect-a-git-source/browse-picker.png b/docs/images/tutorials/connect-a-git-source/browse-picker.png
new file mode 100644
index 00000000..57741c3c
Binary files /dev/null and b/docs/images/tutorials/connect-a-git-source/browse-picker.png differ
diff --git a/docs/images/tutorials/connect-a-git-source/git-source-panel-empty.png b/docs/images/tutorials/connect-a-git-source/git-source-panel-empty.png
new file mode 100644
index 00000000..940fe86f
Binary files /dev/null and b/docs/images/tutorials/connect-a-git-source/git-source-panel-empty.png differ
diff --git a/docs/images/tutorials/connect-a-git-source/last-applied-commit.png b/docs/images/tutorials/connect-a-git-source/last-applied-commit.png
new file mode 100644
index 00000000..cfd1d744
Binary files /dev/null and b/docs/images/tutorials/connect-a-git-source/last-applied-commit.png differ
diff --git a/docs/images/tutorials/connect-a-git-source/pull-preview-diff.png b/docs/images/tutorials/connect-a-git-source/pull-preview-diff.png
new file mode 100644
index 00000000..ec620b76
Binary files /dev/null and b/docs/images/tutorials/connect-a-git-source/pull-preview-diff.png differ
diff --git a/docs/images/tutorials/connect-a-git-source/stack-redeployed.png b/docs/images/tutorials/connect-a-git-source/stack-redeployed.png
new file mode 100644
index 00000000..e8343982
Binary files /dev/null and b/docs/images/tutorials/connect-a-git-source/stack-redeployed.png differ
diff --git a/docs/images/tutorials/create-and-approve-a-blueprint/audit-log-blueprint-actions.png b/docs/images/tutorials/create-and-approve-a-blueprint/audit-log-blueprint-actions.png
new file mode 100644
index 00000000..d8441a04
Binary files /dev/null and b/docs/images/tutorials/create-and-approve-a-blueprint/audit-log-blueprint-actions.png differ
diff --git a/docs/images/tutorials/create-and-approve-a-blueprint/blueprint-created-pending.png b/docs/images/tutorials/create-and-approve-a-blueprint/blueprint-created-pending.png
new file mode 100644
index 00000000..b65b21ff
Binary files /dev/null and b/docs/images/tutorials/create-and-approve-a-blueprint/blueprint-created-pending.png differ
diff --git a/docs/images/tutorials/create-and-approve-a-blueprint/blueprint-editor-compose.png b/docs/images/tutorials/create-and-approve-a-blueprint/blueprint-editor-compose.png
new file mode 100644
index 00000000..964a3d5f
Binary files /dev/null and b/docs/images/tutorials/create-and-approve-a-blueprint/blueprint-editor-compose.png differ
diff --git a/docs/images/tutorials/create-and-approve-a-blueprint/blueprint-editor-selector-drift.png b/docs/images/tutorials/create-and-approve-a-blueprint/blueprint-editor-selector-drift.png
new file mode 100644
index 00000000..9daa789a
Binary files /dev/null and b/docs/images/tutorials/create-and-approve-a-blueprint/blueprint-editor-selector-drift.png differ
diff --git a/docs/images/tutorials/create-and-approve-a-blueprint/catalog-active.png b/docs/images/tutorials/create-and-approve-a-blueprint/catalog-active.png
new file mode 100644
index 00000000..093292ec
Binary files /dev/null and b/docs/images/tutorials/create-and-approve-a-blueprint/catalog-active.png differ
diff --git a/docs/images/tutorials/create-and-approve-a-blueprint/deployment-failed-port-conflict.png b/docs/images/tutorials/create-and-approve-a-blueprint/deployment-failed-port-conflict.png
new file mode 100644
index 00000000..a99c4c49
Binary files /dev/null and b/docs/images/tutorials/create-and-approve-a-blueprint/deployment-failed-port-conflict.png differ
diff --git a/docs/images/tutorials/create-and-approve-a-blueprint/detail-sheet-active.png b/docs/images/tutorials/create-and-approve-a-blueprint/detail-sheet-active.png
new file mode 100644
index 00000000..0b6ee3c1
Binary files /dev/null and b/docs/images/tutorials/create-and-approve-a-blueprint/detail-sheet-active.png differ
diff --git a/docs/images/tutorials/create-and-approve-a-blueprint/node-labels-add-edge.png b/docs/images/tutorials/create-and-approve-a-blueprint/node-labels-add-edge.png
new file mode 100644
index 00000000..3dc2a021
Binary files /dev/null and b/docs/images/tutorials/create-and-approve-a-blueprint/node-labels-add-edge.png differ
diff --git a/docs/images/tutorials/create-and-approve-a-blueprint/rollout-preview.png b/docs/images/tutorials/create-and-approve-a-blueprint/rollout-preview.png
new file mode 100644
index 00000000..c3347e80
Binary files /dev/null and b/docs/images/tutorials/create-and-approve-a-blueprint/rollout-preview.png differ
diff --git a/docs/images/tutorials/enroll-a-remote-node/add-node-filled.png b/docs/images/tutorials/enroll-a-remote-node/add-node-filled.png
new file mode 100644
index 00000000..885e82ad
Binary files /dev/null and b/docs/images/tutorials/enroll-a-remote-node/add-node-filled.png differ
diff --git a/docs/images/tutorials/enroll-a-remote-node/edit-node-regenerate.png b/docs/images/tutorials/enroll-a-remote-node/edit-node-regenerate.png
new file mode 100644
index 00000000..58b46c41
Binary files /dev/null and b/docs/images/tutorials/enroll-a-remote-node/edit-node-regenerate.png differ
diff --git a/docs/images/tutorials/enroll-a-remote-node/fleet-view-two-nodes.png b/docs/images/tutorials/enroll-a-remote-node/fleet-view-two-nodes.png
new file mode 100644
index 00000000..fb3287ec
Binary files /dev/null and b/docs/images/tutorials/enroll-a-remote-node/fleet-view-two-nodes.png differ
diff --git a/docs/images/tutorials/enroll-a-remote-node/node-switcher-popover.png b/docs/images/tutorials/enroll-a-remote-node/node-switcher-popover.png
new file mode 100644
index 00000000..ae7ce7e0
Binary files /dev/null and b/docs/images/tutorials/enroll-a-remote-node/node-switcher-popover.png differ
diff --git a/docs/images/tutorials/enroll-a-remote-node/nodes-table-online.png b/docs/images/tutorials/enroll-a-remote-node/nodes-table-online.png
new file mode 100644
index 00000000..3e6cb8f3
Binary files /dev/null and b/docs/images/tutorials/enroll-a-remote-node/nodes-table-online.png differ
diff --git a/docs/images/tutorials/enroll-a-remote-node/pilot-enroll-modal.png b/docs/images/tutorials/enroll-a-remote-node/pilot-enroll-modal.png
new file mode 100644
index 00000000..7ac38620
Binary files /dev/null and b/docs/images/tutorials/enroll-a-remote-node/pilot-enroll-modal.png differ
diff --git a/docs/images/tutorials/resolve-stack-drift/activity-drift-resolved.png b/docs/images/tutorials/resolve-stack-drift/activity-drift-resolved.png
new file mode 100644
index 00000000..392cbb93
Binary files /dev/null and b/docs/images/tutorials/resolve-stack-drift/activity-drift-resolved.png differ
diff --git a/docs/images/tutorials/resolve-stack-drift/drift-baseline-in-sync.png b/docs/images/tutorials/resolve-stack-drift/drift-baseline-in-sync.png
new file mode 100644
index 00000000..e6d16097
Binary files /dev/null and b/docs/images/tutorials/resolve-stack-drift/drift-baseline-in-sync.png differ
diff --git a/docs/images/tutorials/resolve-stack-drift/drift-detected-image-finding.png b/docs/images/tutorials/resolve-stack-drift/drift-detected-image-finding.png
new file mode 100644
index 00000000..9e8e795b
Binary files /dev/null and b/docs/images/tutorials/resolve-stack-drift/drift-detected-image-finding.png differ
diff --git a/docs/images/tutorials/resolve-stack-drift/drift-recheck-still-drifted.png b/docs/images/tutorials/resolve-stack-drift/drift-recheck-still-drifted.png
new file mode 100644
index 00000000..17b0562b
Binary files /dev/null and b/docs/images/tutorials/resolve-stack-drift/drift-recheck-still-drifted.png differ
diff --git a/docs/images/tutorials/resolve-stack-drift/drift-resolved-in-sync.png b/docs/images/tutorials/resolve-stack-drift/drift-resolved-in-sync.png
new file mode 100644
index 00000000..eda735d2
Binary files /dev/null and b/docs/images/tutorials/resolve-stack-drift/drift-resolved-in-sync.png differ
diff --git a/docs/images/tutorials/resolve-stack-drift/update-readiness-drift-warning.png b/docs/images/tutorials/resolve-stack-drift/update-readiness-drift-warning.png
new file mode 100644
index 00000000..8c7c221a
Binary files /dev/null and b/docs/images/tutorials/resolve-stack-drift/update-readiness-drift-warning.png differ
diff --git a/docs/images/tutorials/schedule-an-operation/all-tasks-row.png b/docs/images/tutorials/schedule-an-operation/all-tasks-row.png
new file mode 100644
index 00000000..440d0d83
Binary files /dev/null and b/docs/images/tutorials/schedule-an-operation/all-tasks-row.png differ
diff --git a/docs/images/tutorials/schedule-an-operation/all-tasks-success.png b/docs/images/tutorials/schedule-an-operation/all-tasks-success.png
new file mode 100644
index 00000000..7b2f3f0f
Binary files /dev/null and b/docs/images/tutorials/schedule-an-operation/all-tasks-success.png differ
diff --git a/docs/images/tutorials/schedule-an-operation/create-task-filled.png b/docs/images/tutorials/schedule-an-operation/create-task-filled.png
new file mode 100644
index 00000000..40844df6
Binary files /dev/null and b/docs/images/tutorials/schedule-an-operation/create-task-filled.png differ
diff --git a/docs/images/tutorials/schedule-an-operation/more-menu-schedules.png b/docs/images/tutorials/schedule-an-operation/more-menu-schedules.png
new file mode 100644
index 00000000..912a706f
Binary files /dev/null and b/docs/images/tutorials/schedule-an-operation/more-menu-schedules.png differ
diff --git a/docs/images/tutorials/schedule-an-operation/new-schedule-action-picker.png b/docs/images/tutorials/schedule-an-operation/new-schedule-action-picker.png
new file mode 100644
index 00000000..4c9e9bb0
Binary files /dev/null and b/docs/images/tutorials/schedule-an-operation/new-schedule-action-picker.png differ
diff --git a/docs/images/tutorials/schedule-an-operation/run-history.png b/docs/images/tutorials/schedule-an-operation/run-history.png
new file mode 100644
index 00000000..aea575ce
Binary files /dev/null and b/docs/images/tutorials/schedule-an-operation/run-history.png differ
diff --git a/docs/images/tutorials/schedule-an-operation/schedules-timeline-empty.png b/docs/images/tutorials/schedule-an-operation/schedules-timeline-empty.png
new file mode 100644
index 00000000..c248e3c3
Binary files /dev/null and b/docs/images/tutorials/schedule-an-operation/schedules-timeline-empty.png differ
diff --git a/docs/images/tutorials/schedule-an-operation/timeline-pill.png b/docs/images/tutorials/schedule-an-operation/timeline-pill.png
new file mode 100644
index 00000000..eb027bd3
Binary files /dev/null and b/docs/images/tutorials/schedule-an-operation/timeline-pill.png differ
diff --git a/docs/images/tutorials/set-up-a-webhook/webhook-create-form-filled.png b/docs/images/tutorials/set-up-a-webhook/webhook-create-form-filled.png
new file mode 100644
index 00000000..6215a16b
Binary files /dev/null and b/docs/images/tutorials/set-up-a-webhook/webhook-create-form-filled.png differ
diff --git a/docs/images/tutorials/set-up-a-webhook/webhook-recent-executions.png b/docs/images/tutorials/set-up-a-webhook/webhook-recent-executions.png
new file mode 100644
index 00000000..246a683e
Binary files /dev/null and b/docs/images/tutorials/set-up-a-webhook/webhook-recent-executions.png differ
diff --git a/docs/images/tutorials/set-up-a-webhook/webhook-save-only-menu.png b/docs/images/tutorials/set-up-a-webhook/webhook-save-only-menu.png
new file mode 100644
index 00000000..eb53b16b
Binary files /dev/null and b/docs/images/tutorials/set-up-a-webhook/webhook-save-only-menu.png differ
diff --git a/docs/images/tutorials/set-up-a-webhook/webhook-secret-reveal.png b/docs/images/tutorials/set-up-a-webhook/webhook-secret-reveal.png
new file mode 100644
index 00000000..cd3bb083
Binary files /dev/null and b/docs/images/tutorials/set-up-a-webhook/webhook-secret-reveal.png differ
diff --git a/docs/images/tutorials/set-up-a-webhook/webhook-storefront-redeployed.png b/docs/images/tutorials/set-up-a-webhook/webhook-storefront-redeployed.png
new file mode 100644
index 00000000..7869311e
Binary files /dev/null and b/docs/images/tutorials/set-up-a-webhook/webhook-storefront-redeployed.png differ
diff --git a/docs/images/tutorials/set-up-deploy-enforcement/audit-log-bypass-entry.png b/docs/images/tutorials/set-up-deploy-enforcement/audit-log-bypass-entry.png
new file mode 100644
index 00000000..cb9fea41
Binary files /dev/null and b/docs/images/tutorials/set-up-deploy-enforcement/audit-log-bypass-entry.png differ
diff --git a/docs/images/tutorials/set-up-deploy-enforcement/deploy-blocked-dialog.png b/docs/images/tutorials/set-up-deploy-enforcement/deploy-blocked-dialog.png
new file mode 100644
index 00000000..9c6b5e08
Binary files /dev/null and b/docs/images/tutorials/set-up-deploy-enforcement/deploy-blocked-dialog.png differ
diff --git a/docs/images/tutorials/set-up-deploy-enforcement/new-policy-form-filled.png b/docs/images/tutorials/set-up-deploy-enforcement/new-policy-form-filled.png
new file mode 100644
index 00000000..a7943820
Binary files /dev/null and b/docs/images/tutorials/set-up-deploy-enforcement/new-policy-form-filled.png differ
diff --git a/docs/images/tutorials/set-up-deploy-enforcement/policy-created-prod-block.png b/docs/images/tutorials/set-up-deploy-enforcement/policy-created-prod-block.png
new file mode 100644
index 00000000..6f7c7ff9
Binary files /dev/null and b/docs/images/tutorials/set-up-deploy-enforcement/policy-created-prod-block.png differ
diff --git a/docs/images/tutorials/set-up-deploy-enforcement/policy-pattern-mismatch.png b/docs/images/tutorials/set-up-deploy-enforcement/policy-pattern-mismatch.png
new file mode 100644
index 00000000..cbd19820
Binary files /dev/null and b/docs/images/tutorials/set-up-deploy-enforcement/policy-pattern-mismatch.png differ
diff --git a/docs/images/tutorials/set-up-deploy-enforcement/stack-running-after-bypass.png b/docs/images/tutorials/set-up-deploy-enforcement/stack-running-after-bypass.png
new file mode 100644
index 00000000..0e88e8f9
Binary files /dev/null and b/docs/images/tutorials/set-up-deploy-enforcement/stack-running-after-bypass.png differ
diff --git a/docs/images/tutorials/set-up-fleet-federation/audit-log-federation-entries.png b/docs/images/tutorials/set-up-fleet-federation/audit-log-federation-entries.png
new file mode 100644
index 00000000..ac2db0c1
Binary files /dev/null and b/docs/images/tutorials/set-up-fleet-federation/audit-log-federation-entries.png differ
diff --git a/docs/images/tutorials/set-up-fleet-federation/blueprint-migrated-pinned-badge.png b/docs/images/tutorials/set-up-fleet-federation/blueprint-migrated-pinned-badge.png
new file mode 100644
index 00000000..345ecf43
Binary files /dev/null and b/docs/images/tutorials/set-up-fleet-federation/blueprint-migrated-pinned-badge.png differ
diff --git a/docs/images/tutorials/set-up-fleet-federation/blueprint-pinned-banner-pending.png b/docs/images/tutorials/set-up-fleet-federation/blueprint-pinned-banner-pending.png
new file mode 100644
index 00000000..b097e0c3
Binary files /dev/null and b/docs/images/tutorials/set-up-fleet-federation/blueprint-pinned-banner-pending.png differ
diff --git a/docs/images/tutorials/set-up-fleet-federation/cordon-confirm-dialog.png b/docs/images/tutorials/set-up-fleet-federation/cordon-confirm-dialog.png
new file mode 100644
index 00000000..69b4eec2
Binary files /dev/null and b/docs/images/tutorials/set-up-fleet-federation/cordon-confirm-dialog.png differ
diff --git a/docs/images/tutorials/set-up-fleet-federation/federation-tab-after-migration.png b/docs/images/tutorials/set-up-fleet-federation/federation-tab-after-migration.png
new file mode 100644
index 00000000..f72457c8
Binary files /dev/null and b/docs/images/tutorials/set-up-fleet-federation/federation-tab-after-migration.png differ
diff --git a/docs/images/tutorials/set-up-fleet-federation/federation-tab-before-pin.png b/docs/images/tutorials/set-up-fleet-federation/federation-tab-before-pin.png
new file mode 100644
index 00000000..5c6cf0c1
Binary files /dev/null and b/docs/images/tutorials/set-up-fleet-federation/federation-tab-before-pin.png differ
diff --git a/docs/images/tutorials/set-up-fleet-federation/node-actions-cordon-menu.png b/docs/images/tutorials/set-up-fleet-federation/node-actions-cordon-menu.png
new file mode 100644
index 00000000..551722cb
Binary files /dev/null and b/docs/images/tutorials/set-up-fleet-federation/node-actions-cordon-menu.png differ
diff --git a/docs/images/tutorials/set-up-fleet-federation/node-card-cordoned.png b/docs/images/tutorials/set-up-fleet-federation/node-card-cordoned.png
new file mode 100644
index 00000000..3a644aca
Binary files /dev/null and b/docs/images/tutorials/set-up-fleet-federation/node-card-cordoned.png differ
diff --git a/docs/images/tutorials/set-up-fleet-federation/pin-applied-effective.png b/docs/images/tutorials/set-up-fleet-federation/pin-applied-effective.png
new file mode 100644
index 00000000..e0441186
Binary files /dev/null and b/docs/images/tutorials/set-up-fleet-federation/pin-applied-effective.png differ
diff --git a/docs/images/tutorials/set-up-fleet-federation/pin-dropdown-open.png b/docs/images/tutorials/set-up-fleet-federation/pin-dropdown-open.png
new file mode 100644
index 00000000..3ceb38ad
Binary files /dev/null and b/docs/images/tutorials/set-up-fleet-federation/pin-dropdown-open.png differ
diff --git a/docs/images/tutorials/set-up-fleet-federation/rollout-preview-migration.png b/docs/images/tutorials/set-up-fleet-federation/rollout-preview-migration.png
new file mode 100644
index 00000000..939c9a53
Binary files /dev/null and b/docs/images/tutorials/set-up-fleet-federation/rollout-preview-migration.png differ
diff --git a/docs/images/tutorials/set-up-fleet-secrets/audit-log-push.png b/docs/images/tutorials/set-up-fleet-secrets/audit-log-push.png
new file mode 100644
index 00000000..50c13633
Binary files /dev/null and b/docs/images/tutorials/set-up-fleet-secrets/audit-log-push.png differ
diff --git a/docs/images/tutorials/set-up-fleet-secrets/bundle-created.png b/docs/images/tutorials/set-up-fleet-secrets/bundle-created.png
new file mode 100644
index 00000000..6fd874ee
Binary files /dev/null and b/docs/images/tutorials/set-up-fleet-secrets/bundle-created.png differ
diff --git a/docs/images/tutorials/set-up-fleet-secrets/create-bundle-filled.png b/docs/images/tutorials/set-up-fleet-secrets/create-bundle-filled.png
new file mode 100644
index 00000000..714ed094
Binary files /dev/null and b/docs/images/tutorials/set-up-fleet-secrets/create-bundle-filled.png differ
diff --git a/docs/images/tutorials/set-up-fleet-secrets/push-failed-typo.png b/docs/images/tutorials/set-up-fleet-secrets/push-failed-typo.png
new file mode 100644
index 00000000..fe0c8097
Binary files /dev/null and b/docs/images/tutorials/set-up-fleet-secrets/push-failed-typo.png differ
diff --git a/docs/images/tutorials/set-up-fleet-secrets/push-preview.png b/docs/images/tutorials/set-up-fleet-secrets/push-preview.png
new file mode 100644
index 00000000..0deb7cb3
Binary files /dev/null and b/docs/images/tutorials/set-up-fleet-secrets/push-preview.png differ
diff --git a/docs/images/tutorials/set-up-fleet-secrets/push-results.png b/docs/images/tutorials/set-up-fleet-secrets/push-results.png
new file mode 100644
index 00000000..4ecc4e6b
Binary files /dev/null and b/docs/images/tutorials/set-up-fleet-secrets/push-results.png differ
diff --git a/docs/images/tutorials/set-up-fleet-secrets/push-target.png b/docs/images/tutorials/set-up-fleet-secrets/push-target.png
new file mode 100644
index 00000000..455bde78
Binary files /dev/null and b/docs/images/tutorials/set-up-fleet-secrets/push-target.png differ
diff --git a/docs/images/tutorials/set-up-fleet-secrets/secrets-tab-empty.png b/docs/images/tutorials/set-up-fleet-secrets/secrets-tab-empty.png
new file mode 100644
index 00000000..d9fd2bb9
Binary files /dev/null and b/docs/images/tutorials/set-up-fleet-secrets/secrets-tab-empty.png differ
diff --git a/docs/images/tutorials/set-up-sso/custom-oidc-filled.png b/docs/images/tutorials/set-up-sso/custom-oidc-filled.png
new file mode 100644
index 00000000..a43afa33
Binary files /dev/null and b/docs/images/tutorials/set-up-sso/custom-oidc-filled.png differ
diff --git a/docs/images/tutorials/set-up-sso/idp-login-redirect.png b/docs/images/tutorials/set-up-sso/idp-login-redirect.png
new file mode 100644
index 00000000..7cb4c0d8
Binary files /dev/null and b/docs/images/tutorials/set-up-sso/idp-login-redirect.png differ
diff --git a/docs/images/tutorials/set-up-sso/login-page-sso-button.png b/docs/images/tutorials/set-up-sso/login-page-sso-button.png
new file mode 100644
index 00000000..d2d92031
Binary files /dev/null and b/docs/images/tutorials/set-up-sso/login-page-sso-button.png differ
diff --git a/docs/images/tutorials/set-up-sso/profile-badge-viewer.png b/docs/images/tutorials/set-up-sso/profile-badge-viewer.png
new file mode 100644
index 00000000..763096de
Binary files /dev/null and b/docs/images/tutorials/set-up-sso/profile-badge-viewer.png differ
diff --git a/docs/images/tutorials/set-up-sso/test-connection-success.png b/docs/images/tutorials/set-up-sso/test-connection-success.png
new file mode 100644
index 00000000..ddd8785e
Binary files /dev/null and b/docs/images/tutorials/set-up-sso/test-connection-success.png differ
diff --git a/docs/images/tutorials/set-up-sso/users-list-provisioned.png b/docs/images/tutorials/set-up-sso/users-list-provisioned.png
new file mode 100644
index 00000000..c9e65130
Binary files /dev/null and b/docs/images/tutorials/set-up-sso/users-list-provisioned.png differ
diff --git a/docs/tutorials/configure-auto-heal-policies.mdx b/docs/tutorials/configure-auto-heal-policies.mdx
new file mode 100644
index 00000000..7ecd2dca
--- /dev/null
+++ b/docs/tutorials/configure-auto-heal-policies.mdx
@@ -0,0 +1,127 @@
+---
+title: Automatically Restart a Container When It Goes Unhealthy
+sidebarTitle: Auto-restart an unhealthy container
+description: Add an Auto-Heal policy that restarts a container when its healthcheck fails or it crashes, then prove it fires against a real failure.
+---
+
+Say a small internal service starts failing its healthcheck at 3 AM: a dependency hiccups, a worker wedges, whatever the cause. Nobody wants to be paged for a problem a restart would fix. This walks through adding an Auto-Heal policy to a two-service stack, `ops-status` (an nginx `web` service in front of a `redis` cache), so that if `web` stays unhealthy past a threshold you set, Sencho restarts it on its own, then confirms the policy actually fired by breaking the healthcheck for real and watching the restart happen.
+
+This tutorial covers one Auto-Heal policy scoped to a single Compose service. It does not cover alert rules (a related but separate tab in the same sheet), stack-wide **All services** policies, or the crash-based healing path (no healthcheck required, triggered by a non-zero exit instead). See the [Auto-Heal Policies](/features/auto-heal-policies) feature page for the full picture, including the four safety rails and the multi-node behavior.
+
+## Prerequisites
+
+- An account with edit access to this stack (admin by default, or a custom role granted `stack:edit` for this stack). Viewing existing policies is open to every signed-in role.
+- A running stack with at least one service that declares a Docker `HEALTHCHECK`. Auto-Heal's healthcheck-based path only sees containers that report a health status; a service with no `HEALTHCHECK` block never goes `unhealthy`, no matter how broken it is.
+- This tutorial uses a small stack called `ops-status`:
+
+ ```yaml
+ services:
+ web:
+ image: nginx:alpine
+ restart: unless-stopped
+ ports:
+ - "8092:80"
+ healthcheck:
+ test: ["CMD-SHELL", "wget -q --spider http://localhost/ || exit 1"]
+ interval: 15s
+ timeout: 5s
+ retries: 3
+ start_period: 5s
+ cache:
+ image: redis:alpine
+ restart: unless-stopped
+ ```
+
+ `web` has the healthcheck; `cache` doesn't need one for this tutorial. Deploy this stack (or adapt an existing one with a `HEALTHCHECK` block) before continuing.
+
+
+ Auto-Heal restarts the container in place; it does not recreate it. If the thing making a container unhealthy lives in its writable layer (a moved file, a corrupted local state), a restart alone won't fix it and the container will go unhealthy again on the next check. This matters for the verification step below.
+
+
+
+
+ Right-click the `ops-status` stack in the sidebar (or focus it and press **H**) and select **Auto-Heal** in the **Inspect** group. The Monitor sheet opens directly on the **Auto-heal** tab, with **Active policies** showing `No auto-heal policies configured for this stack.`
+
+
+
+
+
+
+
+
+
+
+ In **Add new policy**, open the **Service** combobox and pick **web** instead of the default **All services**, since this policy should only watch the service that has a healthcheck. Set **Unhealthy for (minutes)** to `1` and **Cooldown (minutes)** to `1` so you don't have to wait long to see it fire; leave **Max restarts / hr** at `3` and **Auto-disable after (failures)** at `5`.
+
+
+
+
+
+ In production you'd typically set **Unhealthy for** higher (5 minutes or more) so a brief blip doesn't trigger a restart. The 1-minute value here is only to make the next step observable without a long wait.
+
+
+ Click **Add Policy**. A toast confirms `Policy added.`, and the policy now appears in **Active policies**: `web` with the summary `Unhealthy for 1 min · Cooldown: 1 min · Max 3/hr` and its **ON** toggle already enabled.
+
+
+
+
+
+
+
+## Verify it worked
+
+The policy is saved and enabled, but that alone doesn't prove it fires. Auto-Heal evaluates every 30 seconds in the background, and there's no manual "run now" button, so the only real proof is causing an actual failure and watching Sencho react to it.
+
+If you have shell access to the host running this stack, break the `web` container's healthcheck on purpose:
+
+```bash
+docker exec ops-status-web-1 mv /usr/share/nginx/html/index.html /usr/share/nginx/html/index.html.bak
+```
+
+Nginx now returns an error for every request, so the healthcheck's `wget --spider` fails. Within about a minute, the stack header and the `web` container both flip to **unhealthy**:
+
+
+
+
+
+Give it another 30–60 seconds for the next evaluation tick, then reopen the Monitor sheet and expand the policy's history chevron. **Recent activity** shows a **Restarted** entry with the reason `Container unhealthy for 1 minute(s); auto-restarted.`:
+
+
+
+
+
+Because a restart doesn't recreate the container, the moved file is still missing after the restart, so the container goes unhealthy again and the policy keeps restarting it every cooldown window (you can see two **Restarted** entries above, roughly two minutes apart). Restore the file to let it actually recover:
+
+```bash
+docker exec ops-status-web-1 mv /usr/share/nginx/html/index.html.bak /usr/share/nginx/html/index.html
+```
+
+The next few healthchecks pass, and the container settles back to **healthy** on its own, with no further restarts needed:
+
+
+
+
+
+Check from a second, independent surface too: open the dashboard's **Stack Restarts (7d)** card. It shows an `ops-status` row tagged **AUTO-HEAL** with a restart count, confirming the restarts came from the policy and not a manual action:
+
+
+
+
+
+## If something goes wrong
+
+**The policy never fires, and the container never shows a health word at all.** Compare the container card's status line: a container with a working healthcheck reads `up X minutes · healthy` (or `unhealthy`); a container with no `HEALTHCHECK` declared just reads `up X minutes`, with nothing after it. If your service is missing the health word entirely, Auto-Heal's healthcheck path has nothing to evaluate: add a `healthcheck` block to that service in `docker-compose.yml` (see the [Prerequisites](#prerequisites) snippet above) and redeploy the stack. See [Auto-Heal Policies · Troubleshooting](/features/auto-heal-policies#troubleshooting) for the other ways a policy can fail to fire.
+
+## Related
+
+
+
+ The full mechanics: safety rails, stack vs. service scope, multi-node behavior, and notifications.
+
+
+ Pair a policy with an alert rule on the same Monitor sheet for visibility before a restart even fires.
+
+
+ Another hands-off stack policy: keep images current instead of watching for unhealthy containers.
+
+
diff --git a/docs/tutorials/configure-auto-update-policies.mdx b/docs/tutorials/configure-auto-update-policies.mdx
new file mode 100644
index 00000000..a716530f
--- /dev/null
+++ b/docs/tutorials/configure-auto-update-policies.mdx
@@ -0,0 +1,105 @@
+---
+title: Automatically Patch a Stack With an Auto-Update Label
+sidebarTitle: Auto-patch a stack with a label
+description: Tag a stack for hands-off patching, schedule a label-driven check, and confirm a real digest rebuild gets applied without you clicking Apply yourself.
+---
+
+Say you're running a self-hosted app on a rolling image tag like `latest`. The upstream maintainer regularly rebuilds that tag with security patches, same version, same tag name, new image content, and every rebuild is one you'd apply if you noticed it. Nobody wants to check the **Update** board every day for that. This walks through tagging a stack with a **Stack Label** built for exactly this ("safe to patch unattended"), pointing a schedule at that label instead of at one specific stack, and running it once to watch a real pending rebuild get pulled and recreated.
+
+The worked example is `jackett`, a small self-hosted indexer proxy pinned to `lscr.io/linuxserver/jackett:latest`. This tutorial does not cover bumping a stack to a *newer semver tag* (that always needs a manual Compose edit first, since a schedule never rewrites an image reference) or building the label itself from Settings instead of inline. See the [Auto-Update Policies](/features/auto-update-policies) feature page for the semver workflow and risk badges, and [Stack Labels](/features/stack-labels) for every other way to create and manage labels.
+
+## Prerequisites
+
+- **An admin or node-admin account.** Assigning a Stack Label and creating a scheduled task both require one of these roles; viewers and deployers can see the result but can't set it up.
+- **A running stack.** Any stack works, but only a stack whose currently-pinned tag has a real pending **same-tag digest rebuild** actually gets pulled and recreated by the schedule. A stack that's fully up to date, or one pinned to a tag with a *newer* tag available instead, still gets checked on schedule, it just has nothing to apply until you edit its Compose pin (see [Auto-Update Policies · Workflow](/features/auto-update-policies#workflow)).
+- **The hub (Local) selected as the active node for the scheduling step.** Schedules is a hub-level view and isn't available while a remote node is the active selection; labeling the stack itself works from whichever node it lives on.
+
+
+
+ Open the stack that has an update pending. Its **Anatomy** tab shows an **Update available** panel naming the image and marking it `same-tag digest rebuild`, the same signal the sidebar's dot and the **Update** board also carry for this stack.
+
+ Right-click the stack in the sidebar (or use its kebab menu) and choose **Labels → New label**. Type a name that describes the policy, not the app, since you'll likely tag other stacks with it later: `Auto-update`. Pick a color and click **Create**.
+
+
+
+
+
+ Creating a label from this menu assigns it to the stack in the same round trip, no separate assignment step.
+
+
+ The sidebar now splits into label groups instead of one flat list: an **AUTO-UPDATE** section holding this stack, and an **UNLABELED** section for everything else on the node.
+
+
+
+
+
+ This is cosmetic confirmation, not the policy itself: the schedule you create next is what actually drives updates.
+
+
+ Open **More → Schedules**, click **New Schedule**, and set **Action** to **Auto-update stacks by label** (in the **Updates** group). Fill in:
+
+ - **Name**: `Nightly patch check`
+ - **Stack Label**: type `Auto-update` and pick the suggestion that appears (it shows the live match count: `1 stack · 1 node`)
+ - **Scope**: **Entire fleet**. A stack-label schedule resolves membership at run time, so leaving it fleet-wide means any stack you tag with this label later, on any node, is covered automatically without editing the schedule again.
+ - **Schedule**: leave the default **Daily** at **03:00**
+
+ Below the Stack Label field, **Current matches** previews exactly which stacks this task would act on right now, before you save anything.
+
+
+
+
+
+ Click **Create**. See [Schedule an Operation](/tutorials/schedule-an-operation) for the general mechanics of the Schedules timeline and table if this is your first scheduled task; this tutorial goes straight to running it.
+
+
+ Switch to **All tasks**. The new row shows **Auto-update stacks by label** as the action and `Label: Auto-update · Entire fleet` as the target, with **Status** `Never run`.
+
+
+
+
+
+ Click **Run now** (play icon) rather than waiting for 03:00. This is a real pull and recreate, so it takes longer than a restart task, tens of seconds rather than a fraction of one. Click **Refresh** after it finishes.
+
+
+
+
+
+
+
+## Verify it worked
+
+Check from two places, since a status badge alone can't tell you *what* got updated, and a cleared update alone can't tell you it was this schedule that cleared it.
+
+**The Execution history.** Click the **Execution history** button (clock icon) on the row. The run shows **Source** `Manual`, **Status** `Success`, and details naming exactly what happened: `Selector: stack-label="Auto-update" · scope=entire fleet` followed by `Stack "jackett": updated (lscr.io/linuxserver/jackett:latest).`
+
+
+
+
+
+**The Update readiness board.** Open **More → Update**. Where jackett's card used to show `Rebuild available`, the board now reads `Everything is up to date`, and the sidebar's **Updates** filter chip is back to `0`.
+
+
+
+
+
+## If something goes wrong
+
+**A later run reports success but changes nothing.** Run the task again (or let it fire at 03:00 the next night) once jackett is already current, and the row still shows the green **Success** badge, not a failure. Open its Execution history and the details read `Stack "jackett": all images up to date.` instead of naming an updated image. This is the expected steady state, not a bug: the label schedule checks every stack under the label on every run, and "nothing changed because there was nothing to change" is success, not skipped. It only becomes worth investigating if a stack you know has a real pending digest rebuild keeps reporting "up to date"; in that case, confirm the label is still assigned to that stack (right-click it and check the **Labels** submenu for a checkmark) and that the stack's own image reference still uses the same tag Sencho detected the rebuild against.
+
+
+
+
+
+## Related
+
+
+
+ The full readiness board reference: risk badges, the semver-bump workflow this tutorial doesn't cover, and multi-node behavior.
+
+
+ Every other way to create, assign, and manage labels, plus the label-driven Fleet Actions and mute shortcuts.
+
+
+ The full action list and cron reference shared by every scheduled task type, including this one.
+
+
diff --git a/docs/tutorials/configure-environment-guardrails.mdx b/docs/tutorials/configure-environment-guardrails.mdx
new file mode 100644
index 00000000..96e461fa
--- /dev/null
+++ b/docs/tutorials/configure-environment-guardrails.mdx
@@ -0,0 +1,117 @@
+---
+title: Stop a Deploy When a Required Environment Variable Is Missing
+sidebarTitle: Block a missing required var
+description: Turn on the deploy guardrail that refuses a stack update when a required environment variable has no value, and watch it catch a real missing password before anything breaks.
+---
+
+Say a stack declares a required database password with `${DB_PASSWORD:?message}` in its Compose file. Nothing stops a teammate from clearing that value in `.env` by accident, and a normal `docker compose up` would let the mistake ride until the container fails at runtime. This walks through turning on the guardrail that catches it up front instead: you'll deploy a small Postgres stack with a required password, clear the password to simulate the mistake, watch the next update get refused with a clear message naming the variable, then fix it and confirm the update goes through.
+
+This tutorial covers one guardrail: **Block deploy on missing required env vars**. It doesn't cover the rest of the Environment inventory (secret classification, duplicate or shell-only detection, project environment file selection) or the other Deploy Guardrails settings (health observation, rollback retention, automatic external network creation); see the [Environment and Secrets Guardrails](/features/environment-guardrails) feature page for the complete picture.
+
+## Prerequisites
+
+- The `admin` or `node-admin` role. Changing the guardrail setting needs `node:manage` permission, which both roles hold; updating the stack needs `stack:deploy`, which every role except `viewer` and `auditor` holds.
+- Available on every tier; no Admiral requirement.
+- The setting is scoped to one node. If you manage more than one, turn it on for the node you deploy the scenario stack to.
+
+
+
+ Select **Create Stack**, name it `inventory-db`, and replace its Compose file with:
+
+ ```yaml
+ services:
+ db:
+ image: postgres:16
+ restart: always
+ environment:
+ POSTGRES_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD in .env before deploying}
+ POSTGRES_DB: inventory
+ ports:
+ - "5432:5432"
+ ```
+
+ The `${DB_PASSWORD:?message}` syntax tells Compose the variable is required: it refuses to render the stack at all if `DB_PASSWORD` has no value, anywhere in the file. The **.env** tab stays disabled until a project environment file exists, so switch to the **Files** tab, select **New file**, and create one named `.env`. Open it and add `DB_PASSWORD=devpassword123` before your first deploy. Setting it now gets you a clean first deploy; the next steps remove it on purpose to see the guardrail react.
+
+
+
+
+
+ Select **Save & Deploy**. The container comes up healthy.
+
+
+ Open the stack and switch to the **Environment** tab in the Anatomy panel header. `DB_PASSWORD` appears under **present** with **secret** and **required** badges, sourced from `.env` and scoped to interpolation. The lock badge means its value is never read into the inventory, only its presence and status.
+
+
+
+
+
+
+ Open **Settings** → **Infrastructure** → **Stacks** and find the **Deploy Guardrails** section. Turn on **Block deploy on missing required env vars**, then select **Save settings**. It's off by default: without it, Compose only reports a missing required variable when the deploy or update actually runs.
+
+
+
+
+
+
+ Back on `inventory-db`, open the **.env** tab and clear the value so the line reads `DB_PASSWORD=`. Select **Save & Deploy** and choose **Save Only** from the dropdown next to it, so the change is written to disk without triggering a deploy yet.
+
+
+
+
+
+
+ Select **Update**. A readiness dialog summarizes preflight, drift, current containers, and a few other checks; note that none of them mention environment variables; the dialog can say **ready** even though this update is about to be refused. Select **Update now** anyway.
+
+ The update fails immediately, before any image pull or container change, with **Deploy blocked: required environment variable DB_PASSWORD is missing. Define it in a .env or env_file, then deploy again.** The full message is easiest to read from the notification bell in the top bar.
+
+
+
+
+
+
+ Reopen the **.env** tab, set the line back to `DB_PASSWORD=devpassword123`, and save. Select **Update** again. This time it runs to completion: the image pulls, the container recreates, and the health gate passes.
+
+
+
+
+
+
+
+## Verify it worked
+
+Check from two independent surfaces so you're not trusting a single UI element.
+
+**The Activity tab.** Open the stack's **Activity** tab. Reading newest first, you'll see the health gate pass and the successful update sitting right above the earlier **Deploy blocked** entry, so the block and the recovery are both on the record with the account that triggered each one.
+
+
+
+
+
+**The Environment tab.** Reopen it: `DB_PASSWORD` is back under **present** with its **required** badge, and the summary line reads 0 missing.
+
+## If something goes wrong
+
+**The variable still shows missing after you set it, but in the wrong place.** Compose's `${VAR:?message}` interpolation only resolves from the project `.env` file (or the shell), never from a service's `env_file:` entries: those are only injected into the container after interpolation already ran. Moving `DB_PASSWORD` into a separate `env_file:`-declared file instead of the project `.env` reproduces the exact same block, because as far as interpolation is concerned the variable is still unset.
+
+
+
+
+
+Move the value back into the project `.env` file (or add it there in addition to the `env_file`) and redeploy; the guardrail clears as soon as interpolation can see it. See [Environment and Secrets Guardrails](/features/environment-guardrails#interpolation-versus-container-injection) for the full interpolation-versus-injection explanation.
+
+## Related
+
+
+
+ The full inventory: secret classification, env file status, and project environment file selection.
+
+
+ Pre-deploy preflight that also surfaces a missing `env_file:` as a high-risk finding.
+
+
+ Author encrypted env-var bundles on the hub and push them to labeled stacks across the fleet.
+
+
+ The post-deploy health observation configured in the same Deploy Guardrails settings section.
+
+
diff --git a/docs/tutorials/configure-recovery-vault-backups.mdx b/docs/tutorials/configure-recovery-vault-backups.mdx
new file mode 100644
index 00000000..52465356
--- /dev/null
+++ b/docs/tutorials/configure-recovery-vault-backups.mdx
@@ -0,0 +1,26 @@
+---
+title: Configure Recovery Vault Backups
+sidebarTitle: Configure Recovery Vault backups
+description: Step-by-step instructions for configuring and restoring fleet backups with Recovery Vault.
+---
+
+
+ This tutorial is a placeholder pending full content. See the [Fleet Backups feature page](/features/fleet-backups) for the current reference documentation.
+
+
+## What you'll do
+
+## Prerequisites
+
+
+
+
+
+
+## Related
+
+
+
+ Configure Recovery Vault backups and restore stacks or nodes.
+
+
diff --git a/docs/tutorials/connect-a-git-source.mdx b/docs/tutorials/connect-a-git-source.mdx
new file mode 100644
index 00000000..554a74ac
--- /dev/null
+++ b/docs/tutorials/connect-a-git-source.mdx
@@ -0,0 +1,99 @@
+---
+title: Deploy Compose Changes by Pulling From Git Instead of Editing by Hand
+sidebarTitle: Deploy compose changes from Git
+description: Link a running stack to a Git repository, pull a real commit, review the diff, and deploy it, instead of hand-editing the compose file in the browser.
+---
+
+Say `marketing-site` is a small nginx stack you created directly in Sencho, and your team has decided the compose file should live in a Git repository instead, so changes go through a commit and a pull request before they reach the stack. This walks through connecting that already-running stack to a repository, pulling a real commit a teammate pushed, reviewing the diff Sencho builds against what is on disk, and applying it, which both writes the new file and redeploys the container.
+
+This tutorial covers linking an existing stack to a Git source and running one manual pull-review-apply cycle. It doesn't cover creating a brand-new stack directly from a repository, the three webhook-driven apply modes, or multi-file compose sources; see the [Git Sources](/features/git-sources) feature page for all of that.
+
+## Prerequisites
+
+- The `admin` or `node-admin` role. Linking a source and running a pull or apply needs the `stack:edit` permission, which `deployer`, `viewer`, and `auditor` do not hold.
+- A running stack to link. If you don't have one, select **Create Stack**, name it `marketing-site`, and replace its compose file with:
+
+ ```yaml
+ services:
+ web:
+ image: nginx:1.27-alpine
+ restart: always
+ ports:
+ - "8098:80"
+ ```
+
+ Select **Save & Deploy**. This tutorial's steps and screenshots use this exact stack, so if you use a different name or port, adjust accordingly.
+- A public Git repository over HTTPS that you control, with a compose file committed at its root. The screenshots below use a small throwaway demo repository; any repository with the same shape works.
+- Available on every tier, including Community; no Admiral requirement.
+
+
+
+ Open the `marketing-site` stack and select **Git Source** in the editor toolbar. The panel opens empty, since nothing is linked yet.
+
+
+
+
+
+
+ Paste your repository's HTTPS URL into **Repository URL** (for example `https://github.com/your-org/your-repo.git`). Leave **Branch** on its default, `main`, unless your repository uses a different one.
+
+ Select **Browse** to confirm Sencho can actually reach the repository and see its files, rather than trusting the URL is correct. The browser lists every file in the repository; `compose.yaml` is already ticked as the primary compose file, since that name matches the picker's default.
+
+
+
+
+
+ Leave **Authentication** on **Public (no auth)** for a public repository, and **Apply behavior** on **Review only**, the safest default: a pull only stages a diff for you to review, it never writes or deploys on its own. Select **Save**. Sencho runs a reachability check against the repository before persisting anything; if that check fails, nothing is saved and the panel reports why.
+
+
+ Now make a change the way your team actually would: edit the compose file in your repository (not in Sencho) and push a commit. For this tutorial, bump the pinned tag from `nginx:1.27-alpine` to `nginx:1.28-alpine` and push it to the branch you configured.
+
+ Back in the Git Source panel, select **Pull now**. Sencho fetches the branch's current commit and opens a side-by-side diff against what's on disk.
+
+
+
+
+
+ The diff shows only the line that actually changed. If the incoming compose file failed `docker compose config` validation, an error banner would appear here and the **Apply** button would stay disabled; since this pull is clean, Apply is enabled.
+
+
+ Tick **Deploy after apply** at the bottom of the dialog, then select **Apply**. This both writes the incoming file to disk and runs `docker compose up -d` against it, so the running container picks up the new tag immediately instead of just staging the file for a later manual deploy.
+
+ Sencho shows the deploy's progress the same way any other deploy does, ending in a health check on the recreated container.
+
+
+
+## Verify it worked
+
+Check from two places, since neither alone proves the pull was actually applied and deployed.
+
+**The Git Source panel.** Reopen it. The **Last applied commit** stat strip at the bottom now shows the short SHA of the commit you just pulled, with an updated timestamp.
+
+
+
+
+
+**The stack itself.** Reopen `marketing-site`. The container recreated on the new image: a fresh uptime, and its logs now start with `nginx/1.28.3` instead of `1.27.5`. The compose editor tab reflects the same change; if it still shows the old tag, reload the page, since the editor buffer doesn't refresh itself after a Git-driven write.
+
+
+
+
+
+## If something goes wrong
+
+**Saving the source fails with "Repository not found or not accessible," even though the URL is right.** Branch names are case-sensitive, and a typo there (`Main` instead of `main`, for example) surfaces as this same repository-level error rather than a distinct branch error, since Sencho can't always tell a missing branch apart from a missing repository during the reachability check. Double-check the branch name against what your Git host actually shows before assuming the URL itself is wrong.
+
+
+
+
+
+## Related
+
+
+
+ Multi-file sources, the three webhook apply modes, private-repo authentication, and the full troubleshooting table.
+
+
+ Trigger a Git source pull and deploy from CI instead of pulling by hand from the panel.
+
+
diff --git a/docs/tutorials/create-and-approve-a-blueprint.mdx b/docs/tutorials/create-and-approve-a-blueprint.mdx
new file mode 100644
index 00000000..d4851d40
--- /dev/null
+++ b/docs/tutorials/create-and-approve-a-blueprint.mdx
@@ -0,0 +1,116 @@
+---
+title: Create and Approve a Blueprint
+sidebarTitle: Create and approve a blueprint
+description: Author a fleet-wide compose template, target it at a node with a label, and walk through the confirm-before-mutate rollout that puts it on the fleet.
+---
+
+Every other lane in Sencho manages one Compose file on one node. A **Blueprint** is the other lane: you declare a Compose file once, point it at the nodes you want by label or by ID, and Sencho keeps those nodes in sync with that declaration going forward, deploying it where it's missing and telling you the moment a targeted node drifts from what you declared. This walks through the two-part motion every Blueprint goes through before it touches your fleet: you **create** it (author the compose, pick a target, choose how drift gets handled), then you **approve** it (review exactly what Sencho is about to place, and confirm). By the end, a small stateless service is running on a real node, deployed entirely through the Blueprint you approved rather than a manual `docker compose up`.
+
+This tutorial does not cover stateful Blueprints (the extra confirmation step for named volumes and bind mounts), pinning a Blueprint to a specific node, or moving a deployment between nodes. See the [Blueprints](/features/blueprint-model) feature page for stateful workflows, and [Move a Blueprint Deployment to a New Node](/tutorials/set-up-fleet-federation) once you've completed this one and want to relocate a deployment with Federation.
+
+## Prerequisites
+
+- **An admin account.** Creating, editing, and applying a Blueprint all require the admin role. Operators and viewers can read the catalog and the detail sheet but can't act on either.
+- **At least one node**, local or remote, that you can attach a label to. Blueprints are a Community-tier capability; no paid plan is required.
+
+
+
+ Blueprints target nodes by label or by explicit node ID. Open **Settings → Infrastructure → Nodes**, find the node you want this Blueprint to reach, and click the **+** button in its **Labels** column. Type `edge` and click **Add**.
+
+
+
+
+
+ Labels are plain lowercase strings, not key/value pairs. You can add more than one to a node, and a Blueprint's selector can match on any combination of them.
+
+
+ Go to **Fleet → Deployments** and click **New Blueprint**. Fill in:
+
+ - **Name**: `whoami-edge`
+ - **Description**: `Minimal identity endpoint for the edge tier`
+
+ In the **Compose** editor, replace the placeholder YAML with a small, stateless HTTP service that just echoes back which container answered, useful for confirming a deploy actually landed:
+
+ ```yaml
+ services:
+ app:
+ image: traefik/whoami:v1.10
+ restart: unless-stopped
+ ports:
+ - "8080:80"
+ ```
+
+ As you type, the classification banner above the editor updates. With no volumes in this compose, it settles on **Stateless · portable**: Sencho can deploy and evict this Blueprint freely, with none of the extra confirmation prompts a stateful Blueprint requires.
+
+
+
+
+
+ Scroll down to **Selector** and leave **Labels** selected. Click the `edge` pill under **Match nodes with ANY of these labels**. The line below confirms `Resolves to nodes labelled any of [edge]`. Under **Drift policy**, leave **Suggest** selected, the middle option: Sencho will always detect drift on this Blueprint, and Suggest additionally sends a notification when it happens, without redeploying anything on its own. (**Observe** stays silent about drift beyond the deployment row; **Enforce** auto-redeploys to fix it.)
+
+
+
+
+
+ Click **Create blueprint**.
+
+
+ The detail sheet opens on your new Blueprint: `any=[edge] · suggest · rev 1`, with **Apply now**, **Edit**, **Disable**, and **Delete** across the top. The **Deployments** section is still empty, with the note `No matching nodes yet. Add a label or pick a node ID, then click Apply now.`
+
+
+
+
+
+ This is expected, not a bug: creating a Blueprint only declares intent. Sencho computes what it would actually do, and does it, only once you open the rollout preview.
+
+
+ Click **Apply now**. Sencho computes a rollout preview: `Safe 1 · Warnings 1 · Blockers 0`, with a warning noting `no persistent volumes detected` (informational for a stateless Blueprint, not a problem), and one line under **Changes**: `Local (local/online) · create · safe: New placement`.
+
+
+
+
+
+ This preview is the actual authorization step: nothing on the fleet has changed yet, and Sencho is showing you exactly what it's about to do before it does it. Click **Confirm Apply**.
+
+
+
+## Verify it worked
+
+Check from two places, since a single stale UI element could tell you the wrong thing.
+
+**The Deployments tab.** Back on **Fleet → Deployments**, the `whoami-edge` tile now shows a green dot, the **stateless** chip, and `1/1 active · any=[edge]`. Open the tile: the deployment table lists **Local**, status **Active**, with a **Withdraw** action now available. The stack also appears in the left sidebar's stack list like any other running stack, materialized on disk the same way a per-stack deploy would be.
+
+
+
+
+
+
+
+
+
+**The audit log.** Open **Audit**, switch to **Table**, and search `blueprints/1` (or whatever ID your Blueprint got, visible in the browser's address bar or in the raw preview response). You'll see a `POST /api/blueprints/1/apply` row for each time you clicked **Confirm Apply**, each logged with your account, a timestamp, and a 200 status. If you also hit and fixed a port conflict (see below), a `PUT /api/blueprints/1` row from saving the edit shows up between the two applies.
+
+
+
+
+
+## If something goes wrong
+
+The most common first-time failure is a host port that's already taken. `8080` is a common default that other containers on the same node may already be bound to. If that happens, the deployment row moves to **Failed**, and the notes column carries the real Docker error, ending in something like `Bind for 0.0.0.0:8080 failed: port is already allocated`.
+
+
+
+
+
+To recover, click **Edit**, change the host-side port in the `ports` mapping to something free (`8091:80`, for instance), and **Save changes**. Saving bumps the revision and clears approval back to pending, so click **Apply now** again: the preview this time shows an `update` action instead of `create`. Confirm it, and the deployment moves through **Deploying** to **Active** on the new port.
+
+## Related
+
+
+
+ The full mental model: markers, drift modes, stateful safety rails, and the reconciler's approval mechanics.
+
+
+ Once a Blueprint is running, use Federation's pin and cordon controls to relocate it to a different node.
+
+
diff --git a/docs/tutorials/enroll-a-remote-node.mdx b/docs/tutorials/enroll-a-remote-node.mdx
new file mode 100644
index 00000000..b05fad57
--- /dev/null
+++ b/docs/tutorials/enroll-a-remote-node.mdx
@@ -0,0 +1,96 @@
+---
+title: Enroll a Remote Node
+sidebarTitle: Enroll a remote node
+description: Bring a second server under management in the same Sencho console, without opening any inbound ports on it.
+---
+
+Say you run Sencho on one server already, and you separately have a home media server running a Jellyfin stack via Docker Compose, on a connection with no port forwarding. This walks through bringing that second server into your Sencho console as a remote node, using **Pilot Agent** mode: the remote host only needs outbound HTTPS, so nothing has to be opened on your router or firewall.
+
+By the end, both servers appear side by side in the same console, and Sencho can see the Jellyfin containers already running on the second one.
+
+This tutorial does not cover **Distributed API Proxy** mode (for a remote host you already expose on a stable URL), [Sencho Mesh](/features/sencho-mesh) cross-node networking, or adopting a discovered Compose file into a managed stack. See the [Multi-Node Management](/features/multi-node) feature page for those.
+
+## Prerequisites
+
+- An **admin** account on the Sencho instance that will act as the control instance. Adding, editing, and testing nodes requires admin.
+- Docker and the Docker Compose plugin installed on the second server, with SSH or console access as the user that owns the Docker socket.
+- The second server must be able to reach your control instance over outbound HTTPS (or HTTP, if both are on the same private network or VPN).
+
+
+ If your control instance is not reachable from the public internet or the remote server's network (for example, it only listens on `localhost` with no port forwarding and no `SENCHO_PUBLIC_URL` set), the agent will never dial home and the node will sit at `tunnel (waiting)` indefinitely. Confirm the remote host can reach your control instance's URL before starting.
+
+
+
+
+ On your control instance, click your avatar in the top-right and choose **Settings**. In the sidebar, under **Infrastructure**, pick **Nodes**, then click **Add node**.
+
+ Set **Name** to something that identifies the host (`media-server` here). Leave **Type** as **Remote** and **Mode** as **Pilot Agent**, both defaults. In **Compose Directory**, enter the absolute path on the remote host where its Compose stacks already live, or where you want them to live, `/home/ubuntu/docker` in this example. The agent mounts that same path inside its own container, so existing bind-mount paths in your Compose files keep working unchanged.
+
+
+
+
+
+ Click **Add node**.
+
+
+ A dialog opens with a generated Compose file and a start command.
+
+
+
+
+
+ Save Step 1's contents as `compose.yaml` in a directory on the remote host (it does not need to be the Compose Directory itself), then run Step 2's command in that same directory as the user that owns Docker:
+
+ ```bash
+ docker compose up -d
+ ```
+
+ This pulls the `saelix/sencho:latest` image, mounts the host's Docker socket, and starts a container named `sencho-agent` that dials out to your control instance. It does not touch or restart anything already running on the host, including your existing Jellyfin container.
+
+ The enrollment token embedded in the file is valid for **15 minutes and can only be used once**. If it expires before you run the command, see [If something goes wrong](#if-something-goes-wrong) below.
+
+
+ Back on the Nodes table, the new row starts at **Status: Unknown** with Endpoint `tunnel (waiting)`. Once the agent dials home, usually within a few seconds of `docker compose up -d` finishing, the row flips to **Status: Online** and the Endpoint reads `tunnel (seen Xs ago)`.
+
+
+
+
+
+
+
+## Verify it worked
+
+Check from two places, since either alone could be misleading (a stuck browser tab, or a table row that has not refreshed).
+
+**The node switcher.** Click the switcher at the top of the sidebar. Your new node appears in the **Connected** list with an `AGENT` kicker and a `SEEN Xm AGO` timestamp.
+
+
+
+
+
+**The Fleet view.** Switch back to Local if you navigated away, then open the **Fleet** tab. Both nodes show as separate cards with independent CPU, RAM, and disk stats pulled live from each host. The media-server card's running-container count reflects Jellyfin (and the agent itself), not just an empty shell.
+
+
+
+
+
+Click the node switcher and select your new node to confirm you can operate against it directly. Every top-level view scoped to that host, Home, Resources, Networking, Security, works the same way it does for Local. Fleet-wide views (Fleet itself, Schedules, Audit, Logs, Auto-Update) stay hidden until you switch back to Local, because they only make sense from the hub.
+
+## If something goes wrong
+
+The most common first-time snag is the 15-minute enrollment token expiring before you run `docker compose up -d`, usually because of a copy-paste detour or a slow SSH session. The node row stays on `tunnel (waiting)` indefinitely; it does not time out or show an error on its own. If more than 15 minutes have passed, open **Edit node** on that row, click **Regenerate enrollment token**, and repeat the save-and-run-compose steps with the fresh file. The previous tunnel attempt is closed automatically, so there is nothing to clean up on the remote host first.
+
+
+
+
+
+## Related
+
+
+
+ Distributed API Proxy mode, Sencho Mesh, license inheritance across nodes, and full troubleshooting.
+
+
+ Credential lifecycle, resource limits, and pilot-specific troubleshooting for the tunnel itself.
+
+
diff --git a/docs/tutorials/resolve-stack-drift.mdx b/docs/tutorials/resolve-stack-drift.mdx
new file mode 100644
index 00000000..763dc430
--- /dev/null
+++ b/docs/tutorials/resolve-stack-drift.mdx
@@ -0,0 +1,116 @@
+---
+title: Catch and Fix a Container That's Drifted From Its Compose File
+sidebarTitle: Catch and fix drift
+description: Read a real Drift finding after a manual out-of-band container change, then redeploy through Sencho to bring the running state back in line with the compose file.
+---
+
+Say an on-call engineer hotfixes a container directly on the host during an incident: change the image tag, run `docker compose up -d`, done, the site's back up. Nobody thinks to also make the same edit through Sencho, so the compose file it deploys from tomorrow never learns about tonight's fix. This walks through catching exactly that: read a real Drift finding after an out-of-band container change, confirm what's different, and redeploy through Sencho to bring the running state back in line with the compose file.
+
+This tutorial covers the runtime drift detection described on the Drift feature page: the status badge, the findings list, the drift ledger, and resolving a finding by redeploying. It does not cover Blueprint's separate policy-based drift mode (observe/suggest/enforce), which reconciles drift on Blueprint-deployed stacks automatically instead of leaving it for you to redeploy; see [Blueprint Model](/features/blueprint-model). It also does not cover network-attachment drift findings, which reuse this same mechanism but compare against the Networking tab instead of an image; see [Compose Networking](/features/compose-networking).
+
+## Prerequisites
+
+- Any role that can view a stack, including the read-only **viewer** and **auditor** roles, can open the Drift tab and click **re-check**. Actually resolving drift means redeploying, which needs the `stack:deploy` permission for this stack (the same permission Start, Stop, Restart, and Update already require).
+- The stack must have been deployed through Sencho at least once. Drift's "since last deploy" signal has nothing to compare against until then, and shows **No deploy baseline** instead of a useful state.
+- No plan requirement and nothing to turn on. Drift detection runs automatically as soon as a stack has a compose file and Docker is reachable on the node.
+- Shell or SSH access to the host, if you want to reproduce this tutorial's own out-of-band change yourself. Any real manual Docker operation that changes a running container without going back through Sencho works just as well.
+
+This tutorial uses a small single-service stack called `landing-page`:
+
+```yaml
+services:
+ app:
+ image: nginx:1.27-alpine
+ restart: always
+ ports:
+ - "8096:80"
+```
+
+Deploy this stack (or adapt an existing one) before continuing.
+
+
+
+ Click the stack in the sidebar to open it, then switch to the **Drift** tab in the right-hand panel (scroll the tab row if it doesn't fit). Right after a deploy through Sencho, the status reads **in sync** ("Runtime matches the compose file.") and the card below it reads **matches last deploy** ("The compose source is unchanged since the last deploy."). This is the state the rest of this tutorial gets you back to.
+
+
+
+
+
+
+
+ Simulate the incident hotfix: on the host, point the compose file at a different image tag, bring it up directly with Compose, then revert the file without redeploying through Sencho.
+
+ ```bash
+ cd /path/to/landing-page
+ sed -i 's/nginx:1.27-alpine/nginx:1.29-alpine/' compose.yaml
+ docker compose up -d
+ sed -i 's/nginx:1.29-alpine/nginx:1.27-alpine/' compose.yaml
+ ```
+
+ The running container is now on `nginx:1.29-alpine`, but the compose file on disk is back to declaring `nginx:1.27-alpine`, exactly as if someone had made a quick fix and never told Sencho about it.
+
+
+
+ Reopen the Drift tab. Just opening it re-runs the comparison, so the status flips to **drifted · 1 finding** ("Runtime differs from the compose file."), while **matches last deploy** stays exactly as it was: the file itself never changed, only what's running under it. Under **Findings**, the `app` service shows an **image** finding with the expected and actual images side by side.
+
+
+
+
+
+ This is the moment the two signals' independence actually matters: a stack can be drifted at runtime while its file is unchanged, or the reverse. Here it's drifted with an unchanged file, because the fix happened directly against Docker, not through a compose edit.
+
+
+
+ Click **re-check**. A **Drift history** section appears with the same finding marked **OPEN** and a detected timestamp, and the header now reads "checked just now." The status badge above it, though, still says **drifted**.
+
+
+
+
+
+ Re-check is a read plus a ledger write: it records the finding into the stack's history and logs a **Drift detected** entry to the Activity tab, but it never touches the running container. Nothing about the actual deployment has changed yet.
+
+
+
+ Click **Update**. The **Update Readiness** dialog opens with a **Drift** warning already listed: "1 open drift finding: the running state has diverged from the compose file, so the rollback target may not match what is running." Here that's expected, since you already know why; on a stack you didn't just break yourself, that warning is exactly the kind of thing worth reading before you proceed. Click **Update now**.
+
+
+
+
+
+ Sencho re-pulls and recreates the `app` container from the compose file, which puts it back on `nginx:1.27-alpine`.
+
+
+
+## Verify it worked
+
+Check from two independent surfaces.
+
+Back on the **Drift** tab, the status returns to **in sync**, and the drift history entry that was **OPEN** a moment ago now reads **RESOLVED**, with both a detected and a resolved timestamp:
+
+
+
+
+
+On the **Activity** tab, which lists most-recent-first, the update entries sit above a new **Drift resolved** entry, which sits above the original **Drift detected** entry from earlier in this tutorial: read bottom to top, that's detected, then updated, then resolved, in the order the events actually happened.
+
+
+
+
+
+## If something goes wrong
+
+**Clicking re-check doesn't fix anything, and the status still says drifted.** That's not a bug, it's the read-only design: re-check only reconciles the ledger (recording what it currently sees, as in the screenshot above) so the finding shows up in history and on the Activity timeline. It never changes what Docker is running. If you want the drifted container gone, redeploy the stack instead (**Update**, or **Save & Deploy** from the compose editor) as in the last step above; that's the only action that actually reconciles the runtime with the compose file.
+
+## Related
+
+
+
+ Full mechanics: every finding type, image comparison rules, port ranges, the drift ledger, and troubleshooting.
+
+
+ Drift detected and resolved events sit in the same timeline as deploys and restarts.
+
+
+ Blueprints manage drift differently, with a separate observe/suggest/enforce policy mode.
+
+
diff --git a/docs/tutorials/schedule-an-operation.mdx b/docs/tutorials/schedule-an-operation.mdx
new file mode 100644
index 00000000..efb74e50
--- /dev/null
+++ b/docs/tutorials/schedule-an-operation.mdx
@@ -0,0 +1,116 @@
+---
+title: Schedule an Operation
+sidebarTitle: Schedule an operation
+description: Create a recurring stack restart in Sencho, verify it with a manual run, and read its execution history.
+---
+
+Say you run a small web app that slowly accumulates stale connections as the week goes on, and the fix is a restart every night while nobody is using it. You could log in at 3:00 AM, or you could let Sencho do it for you. This walks through creating a scheduled task that restarts the `my-website` stack (an nginx web tier with a postgres database) every day at 3:00 AM, running it once by hand to confirm it works, and checking the execution history.
+
+By the end, the task is running on its own schedule, and you have a green run in the history that proves it works.
+
+This tutorial covers one recurring **Restart Stack** task on the hub. It does not cover the other scheduled actions (auto-update, vulnerability scans, prunes, fleet snapshots), advanced cron expressions, per-service restart scoping, one-shot tasks, or scheduling actions against a remote node. See the [Scheduled Operations](/features/scheduled-operations) feature page for the full action list and the [cron expression reference](/features/scheduled-operations#cron-expression-reference).
+
+## Prerequisites
+
+- An **admin** account on the Sencho hub. Creating and managing schedules requires admin.
+- A running stack on the hub. This tutorial schedules a restart of `my-website`, deployed as an nginx service with a postgres service behind it.
+- The hub (Local) selected as the active node. Schedules is a hub-level view: it is not available while a remote node is the active selection. If you manage remote nodes, switch back to **Local** before continuing.
+
+
+ A scheduled restart restarts the stack's containers in place. It does not recreate them, so the compose configuration and any bind-mounted data are left untouched.
+
+
+
+
+ In the top navigation, click **More**, then under **Operations** pick **Schedules**.
+
+
+
+
+
+ The page opens on the **Timeline** view: a 24-hour strip with five lanes, one per operation category (Lifecycle, Updates, Security, Upkeep, Backups). With no tasks yet, it shows the empty-state message `Nothing scheduled in the next 24 hours`.
+
+
+
+
+
+
+ Click **New Schedule** in the top right of the page. The **New scheduled task** modal opens.
+
+ Open the **Action** picker. The actions are grouped by category, each with a risk badge and one-line helper text so you can see the blast radius before you commit: **Lifecycle** holds the stack and container actions, **Updates** the auto-update variants, **Security** the vulnerability scan, **Upkeep** the resource prune, and **Backups** the fleet snapshot.
+
+
+
+
+
+
+ Fill in the form:
+
+ - **Name**: `Nightly website restart`
+ - **Action**: **Restart Stack** (the default, in the Lifecycle group). The **Interruptive** badge and the helper text "Restarts containers in place" describe exactly what a run will do: stop and start the containers on the same configuration.
+ - **Node**: **Local**.
+ - **Stack**: `my-website`. The stack defines two services, so a **Services (leave empty for all)** grid appears with `web` and `db` checkboxes. Leave both unchecked to restart the entire stack.
+ - **Schedule**: leave **Simple** mode selected, with **Daily**, **Hour** `03`, and **Minute** `00`. The preview below the controls reads `At 03:00 AM · 0 3 * * *`, and the note under it says the schedule runs in the node's local timezone. This is where you pick the off-peak window: 3:00 AM, when nobody is hitting the site.
+ - **Enabled**: leave ON.
+ - **Delete after successful run**: leave unchecked. This task is meant to fire every night, not remove itself after one success.
+
+
+
+
+
+ Click **Create**. The modal closes and a toast confirms the task was created.
+
+
+ The timeline now shows a blue pill on the **Lifecycle** lane at the next firing, and the **Next** readout shows the task name with a countdown.
+
+
+
+
+
+ Note the time: the schedule fires at 3:00 AM **in the node's timezone**, and the timeline displays firing times **in your browser's timezone**, so the pill time may differ from the form's preview. Here the node is on UTC, the browser on Eastern Daylight Time, and 03:00 UTC shows as 23:00.
+
+ Toggle to **All tasks** for the tabular view. The new row shows the name, the **Restart Stack** action badge, the `my-website` target, the human-readable schedule over its raw cron `0 3 * * *`, **Status** `Never run`, the next firing in your local time, and the **Enabled** switch set to ON.
+
+
+
+
+
+
+ Click the **Run now** button (play icon) on the row. The task executes immediately instead of waiting for the next firing. After a few seconds, click **Refresh**; the row's **Status** flips to **Success** (green badge).
+
+
+
+
+
+
+
+## Verify it worked
+
+Check from two places, since either alone could be misleading (a table that has not refreshed, or a run that updated the badge but left an error behind).
+
+**The Status badge.** In **All tasks**, the row shows the green **Success** badge, and the task is still **Enabled**, so the next firing is still scheduled.
+
+**The Execution history.** Click the **Execution history** button (clock icon) on the row. The sheet opens with the breadcrumb `Schedules › Nightly website restart › Runs`, a `1 run` count, and the run's row: **Source** `Manual` (it was triggered by Run now, not by the cron), **Status** `Success`, a short **Duration**, and the details `Restarted 2 container(s) in stack "my-website"`: both services, exactly as configured.
+
+
+
+
+
+The footer confirms the schedule is alive: the next run time is shown there too, matching the row's **Next Run** in the table. You can now rely on the nightly restart without being at the console at 3:00 AM.
+
+## If something goes wrong
+
+**You don't see Schedules in the More menu.** Schedules is a hub-level view. If a remote node is the active selection, the More menu shows only **Console**, and the hub-only views (Schedules, Audit, Logs, Update) are missing. Click the node switcher next to the Sencho logo, pick **Local**, and reopen **More**: Schedules is back under **Operations**.
+
+A run that fails shows a red **Failed** badge in the row and an error-level notification; the failure notification carries the task name and the error so you can diagnose without opening the run history. The task stays enabled and fires again at its next cron tick, or you can click **Run now** to retry immediately. See [Scheduled Operations · Troubleshooting](/features/scheduled-operations#troubleshooting) for the full failure list.
+
+## Related
+
+
+
+ Every supported action, the cron expression reference, and failure notifications.
+
+
+ Schedule image updates the same way, then review what changed on the companion board.
+
+
diff --git a/docs/tutorials/set-up-a-webhook.mdx b/docs/tutorials/set-up-a-webhook.mdx
new file mode 100644
index 00000000..bed2f4fa
--- /dev/null
+++ b/docs/tutorials/set-up-a-webhook.mdx
@@ -0,0 +1,111 @@
+---
+title: Deploy a Stack Automatically From Your CI Pipeline
+sidebarTitle: Deploy from CI on push
+description: Create a signed webhook that lets an external pipeline trigger a real stack redeploy, and verify it end to end with a signed request standing in for your CI job.
+---
+
+Say a small nginx-backed service, `storefront`, gets rebuilt every time its image changes. Without a webhook, redeploying it after a new build means someone opening Sencho and clicking **Update** by hand. This walks through wiring a webhook so an external pipeline can trigger that redeploy itself: you'll create the webhook, stage a new image tag the way a pipeline would, fire a signed HTTP request that stands in for the pipeline's own call, and confirm the container actually recreated on the new image.
+
+This tutorial covers creating and triggering one webhook for the **Deploy (down + up)** action. It doesn't cover the other five actions (Restart, Stop, Start, Pull & Update, Git source sync), writing CI YAML for a specific provider, or key rotation across many webhooks; see the [Webhooks](/features/webhooks) feature page for the complete picture.
+
+## Prerequisites
+
+- The `admin` role specifically. Creating, editing, and deleting webhooks needs the `system:webhooks` permission, which only `admin` holds; `node-admin`, `deployer`, `viewer`, and `auditor` can view a read-only list but not manage one.
+- A running stack to target. If you don't have one, select **Create Stack**, name it `storefront`, and replace its Compose file with:
+
+ ```yaml
+ services:
+ web:
+ image: nginx:1.27-alpine
+ restart: always
+ ports:
+ - "8095:80"
+ ```
+
+ Select **Save & Deploy**. This tutorial's steps and screenshots use this exact stack and tag, so if you use a different one, adjust the specific image tag named in step 3 to match.
+- The **Local** node active in the node switcher. The Webhooks page only appears while Local is active, and a webhook's execution is pinned to whichever node was active when you created it.
+- A way to send a signed HTTP POST request. This tutorial uses `curl` and `openssl` from a terminal to stand in for your CI platform's HTTP step.
+- Available on every tier; no Admiral requirement.
+
+
+
+ Open **Settings → Automation → Webhooks** and select **Create webhook**. Fill in **Name** (`Deploy storefront`), pick **Stack** (`storefront`), and leave **Action** on its default, **Deploy (down + up)**. **Node** is read-only and shows **Local**: execution stays pinned to whichever node was active when you created the webhook, regardless of which node is active later.
+
+
+
+
+
+ Select **Create**.
+
+
+ A green callout appears once, showing the full signing secret in a monospace block. Copy it now: every later view of this webhook, in the list or the API, shows only the masked form (`********` plus the last four characters). If you dismiss the callout without copying it, the only fix is to delete the webhook and create a new one, which rotates the secret and the trigger URL's id.
+
+
+
+
+
+ Note the numeric id in the **Trigger URL** (`/api/webhooks//trigger`); you'll need both the id and the secret in the next steps.
+
+
+ A trigger against a stack that already matches its compose file is a no-op: Sencho still reports success, but nothing visibly changes, the same way a plain `docker compose up -d` only recreates a container whose configuration actually changed. To see a real redeploy, stage a change first, the way your pipeline would after a new build.
+
+ Open the `storefront` stack, edit `compose.yaml`, and bump the pinned tag from `nginx:1.27-alpine` to `nginx:1.28-alpine`. Instead of **Save & Deploy**, open the dropdown next to it and select **Save Only**. This writes the change to disk without deploying it, the same gap that exists in a real pipeline between committing a new image reference and the moment something actually applies it.
+
+
+
+
+
+
+ Compute an HMAC-SHA256 signature over the exact request body using the secret from step 2, and send it in the `X-Webhook-Signature` header with a `sha256=` prefix:
+
+ ```bash
+ SECRET="your-webhook-secret"
+ BODY='{}'
+ SIGNATURE=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | cut -d' ' -f2)
+
+ curl -X POST https://your-sencho.example.com/api/webhooks//trigger \
+ -H "Content-Type: application/json" \
+ -H "X-Webhook-Signature: sha256=$SIGNATURE" \
+ -d "$BODY"
+ ```
+
+ A valid request returns `202 Accepted` with `{"message":"Webhook accepted","action":"deploy"}` immediately. That confirms the signature checked out and the deploy is now running asynchronously, not that it has finished.
+
+
+
+## Verify it worked
+
+Check from two independent surfaces so you're not trusting a single UI element.
+
+**The stack itself.** Reopen `storefront`. The container recreated on the new image: a fresh uptime, and its logs now start with `nginx/1.28.3` instead of `1.27.5`.
+
+
+
+
+
+**The webhook's own history.** Back on **Settings → Automation → Webhooks**, expand **Recent executions** on the card. A green check confirms `deploy` succeeded, with the timestamp and duration of the run you just triggered.
+
+
+
+
+
+## If something goes wrong
+
+**The trigger returns `404 Webhook not found or signature invalid`, but the id and secret are both right.** This is almost always a signing mismatch, and the most common cause is a shell adding bytes you didn't sign. `echo "$BODY"` appends a trailing newline that `echo -n "$BODY"` does not, so if any step in your pipeline signs with one and sends with the other, the signature won't match the bytes Sencho actually received, and it returns the same generic 404 it returns for every unauthenticated case (unknown id, disabled webhook, missing header). Confirm you're signing the exact same string you send, byte for byte, with no incidental newline.
+
+## Related
+
+
+
+ The full action list, concurrency behavior, execution history retention, and security model.
+
+
+ The Git source sync action pulls the latest commit and deploys, an alternative to bumping a pinned tag by hand.
+
+
+ A hands-off way to keep a stack current on a schedule, without needing an external pipeline to call a webhook at all.
+
+
+ Link a stack to a Git repository and run the same pull-review-apply cycle by hand from the panel, no webhook required.
+
+
diff --git a/docs/tutorials/set-up-deploy-enforcement.mdx b/docs/tutorials/set-up-deploy-enforcement.mdx
new file mode 100644
index 00000000..7ffb0b3e
--- /dev/null
+++ b/docs/tutorials/set-up-deploy-enforcement.mdx
@@ -0,0 +1,100 @@
+---
+title: Stop a Vulnerable Deploy Before It Starts
+sidebarTitle: Block a vulnerable deploy
+description: Add a scan policy that rejects a deploy carrying Critical vulnerabilities, read the block dialog, and override it as an admin when the risk is acceptable.
+---
+
+Say a stack's base image quietly falls behind between one deploy and the next and picks up a Critical CVE. Nothing about a normal `docker compose up` stops that image from going live. This walks through adding a scan policy that gates deploys against a small `prod-web` stack running `nginx:1.14`, an image old enough to reliably carry dozens of Critical and High findings: Trivy scans every image the stack references before any container starts, and Sencho rejects the deploy instead of starting one with a known-bad image. You'll then decide, as an admin, whether to accept the risk for this one deploy and confirm that decision lands in the audit log.
+
+This tutorial covers one block-on-deploy policy scoped to a single stack pattern, its block dialog, and the admin bypass. It doesn't cover installing the Trivy scanner itself, CVE suppressions, or replicating a policy across a fleet through Fleet Federation; see the [Deploy Enforcement](/features/deploy-enforcement) feature page for the complete picture.
+
+## Prerequisites
+
+- The `admin` role. Creating and editing a policy needs the `stack:edit` permission, which both the `admin` and `node-admin` roles hold, but only `admin` can click **Deploy anyway** on a blocked deploy: `node-admin` can configure policies but not bypass a block.
+- The Trivy scanner installed and running on the node you're deploying to (**Security** → **Scanner setup**). Deploy Enforcement fails open, letting the deploy through with a warning alert instead of a block, when Trivy isn't installed.
+- Available on every tier; no Admiral requirement.
+- This tutorial uses a small stack called `prod-web`:
+
+ ```yaml
+ services:
+ web:
+ image: nginx:1.14
+ restart: unless-stopped
+ ports:
+ - "8091:80"
+ ```
+
+ `nginx:1.14` is used here specifically because it's old enough to reliably carry Critical and High findings, which is what makes the block in this tutorial real instead of staged. Create this stack (or use one you already have with a similarly outdated image), but don't start it yet: the point of this tutorial is watching the policy catch the first deploy attempt.
+
+
+
+ Open **Security** → **Policies** and select **Add policy**. Set **Name** to `Production block on critical` and **Stack pattern (optional)** to `prod-*`, so the policy only ever evaluates stacks whose name starts with `prod-`. Leave **Known-exploited (KEV)** and **Fixable Critical/High** on (both default on), turn on **Severity threshold** (it defaults to **Critical**), and turn on **Block on deploy**. Leave **Enabled** on.
+
+
+
+
+
+ At least one block condition has to stay on; a policy with **Block on deploy** enabled but every condition off is rejected as a no-op.
+
+
+ Select **Create**. The policy now appears as a card reading **max: CRITICAL**, **KEV**, **Fixable**, and a destructive **block** badge, with **Scope: prod-\*** underneath.
+
+
+
+
+
+
+ Open the `prod-web` stack and select **Start**. Sencho enumerates the stack's images, runs a pre-flight Trivy scan against `nginx:1.14`, and evaluates the result against the policy before any container starts. This is the first scan of this image digest, so budget up to a couple of minutes rather than the sub-second result a cached rescan gets.
+
+ Instead of a running container, a **Deploy blocked by security policy** dialog appears, naming the policy and every condition it blocks on. The violation row shows `nginx:1.14` with its finding counts and a **Severity**, **KEV**, and **Fixable** badge, each matching a condition the image tripped.
+
+
+
+
+
+ This is a hard gate, not an advisory: the stack has no running containers and stays that way until the image clears the policy or an admin overrides it.
+
+
+ Select **Deploy anyway**. The button is only active because you're signed in as `admin`; a non-admin session sees a disabled **Admin required to bypass** button in its place and cannot proceed. Sencho retries the deploy with the block ignored, pulls the image, starts the container, and runs its usual health-gate observation window.
+
+
+
+
+
+
+
+## Verify it worked
+
+Check from two independent surfaces so you're not trusting a single UI element.
+
+**The stack itself.** `prod-web` shows **RUNNING**, with `prod-web-web-1` up and its port mapping live, as in the screenshot above.
+
+**The audit log.** Open **More** → **Audit**. Reading newest first, you'll see the successful bypass deploy, a `policy.bypass` entry naming the policy, the violation count, and the offending image (`policy.bypass stack="prod-web" policy="Production block on critical" violations=1 images=[nginx:1.14]`), and below that the original blocked attempt with a `409` status in red. All three carry the account that triggered them, so the override is attributable, not anonymous.
+
+
+
+
+
+## If something goes wrong
+
+**The gate lets a deploy through even though a policy should have caught it.** The most common first-time cause is a stack pattern that doesn't actually match the stack name. Edit the policy and set **Stack pattern** to something that doesn't match, like `staging-*`, while your stack is still named `prod-web`:
+
+
+
+
+
+Deploy or update `prod-web` again and it succeeds with no block at all: the policy never evaluates a stack whose name doesn't match its pattern, so a typo here fails silently rather than loudly. Fix the pattern back to `prod-*` (or clear it entirely to apply the policy to every stack on the node) and redeploy to confirm the gate fires again. See [Deploy Enforcement · Troubleshooting](/features/deploy-enforcement#troubleshooting) for the other ways a policy can fail to fire, including a missing Trivy install and a scan that found nothing matching your conditions.
+
+## Related
+
+
+
+ The full mechanics: precedence between policies, every code path the gate covers, and fleet policy replication.
+
+
+ Install and configure the Trivy scanner this tutorial's policy depends on.
+
+
+ Accept a specific finding with an expiry so it stops counting toward a block, without disabling the whole policy.
+
+
diff --git a/docs/tutorials/set-up-fleet-federation.mdx b/docs/tutorials/set-up-fleet-federation.mdx
new file mode 100644
index 00000000..cc9e1ed9
--- /dev/null
+++ b/docs/tutorials/set-up-fleet-federation.mdx
@@ -0,0 +1,119 @@
+---
+title: Move a Blueprint Deployment to a New Node
+sidebarTitle: Move a blueprint to a new node
+description: Relocate a Blueprint-managed stack from a node you're retiring to another node in the fleet, without letting the reconciler undo the move partway through.
+---
+
+Say a small stateless service, a status page, a metrics exporter, an internal API, runs as a [Blueprint](/features/blueprint-model)-managed deployment on one node in your fleet. That node is going away, for maintenance, decommissioning, or a hardware swap, and you need the workload running on a different node before you take the old one offline. This walks through doing that with **Federation**'s pin and cordon controls: pin the blueprint to the destination so the reconciler stops trusting its normal selector, cordon the source so it stops picking up anything new, then confirm the rollout that actually moves the workload.
+
+
+ Despite the name, Federation doesn't connect separate Sencho instances or fleets together, that's a different concept Sencho doesn't do at all. Federation is a tab inside a single fleet's **Fleet** view. It steers *where* that one fleet's Blueprint-managed deployments land; it has no notion of "other fleets."
+
+
+By the end, the workload runs only on the new node, the old node is marked unschedulable, and you can confirm both from more than one place in the UI.
+
+Both controls follow the same rule, and it's the part most likely to trip up a first-time user: **pinning and cordoning only declare intent.** Neither one deploys or withdraws a single container by itself. The reconciler stages what it *wants* to do next; you still open the blueprint and click **Confirm Apply** before anything on the fleet actually changes. That extra step is what makes this walkthrough three actions long instead of one.
+
+This tutorial does not cover creating a Blueprint from scratch (see [Create and Approve a Blueprint](/tutorials/create-and-approve-a-blueprint)), draining a node of everything it runs, or evicting a stateful deployment. See the [Blueprints](/features/blueprint-model) and [Fleet Federation](/features/fleet-federation) feature pages for those last two.
+
+## Prerequisites
+
+- **An admin account.** Cordoning a node and pinning a blueprint both check the same permission, scoped to the node you're acting on; clearing a pin checks that same permission without a node scope. Delegating that permission to a non-admin role for one specific node is an Admiral-tier capability, so on Community you complete every step here as the instance's admin.
+- **An existing Blueprint already deployed on at least one node.** Federation steers placement for Blueprints that exist; it does not create them. If you don't have one yet, walk through [Create and Approve a Blueprint](/tutorials/create-and-approve-a-blueprint) first.
+- **At least two nodes in the fleet**, both online. Pinning only matters when there's somewhere else to pin to. Multi-node fleets and Federation are both Community-tier, no paid plan required.
+
+
+
+
+
+
+
+ Open **Fleet → Federation** and find your blueprint's row in the **Pin policy** table. Open the **Pinned to** dropdown and select the node you're moving the workload to. A cordoned node would show inline as ` · cordoned` here, so you can see at a glance if you're about to pin onto something already out of rotation.
+
+
+
+
+
+ The selection saves immediately and a toast confirms it. The **Effective** column updates to `pin: `, overriding whatever the selector would otherwise resolve to. Nothing has deployed yet, this only changes what the reconciler wants to do next.
+
+
+
+
+
+
+ Go to **Fleet → Overview**. On the source node's card, click the kebab menu (`⋯`) and choose **Cordon node**.
+
+
+
+
+
+ A confirmation dialog opens with an optional reason field, up to 256 characters. Fill in something that will make sense to whoever looks at this node in a week.
+
+
+
+
+
+ Click **Cordon node**. The card gains a `Cordoned` pill immediately; this alone does not touch anything already running on the node, including the blueprint you're about to move off it.
+
+
+
+
+
+
+ Cordon only ever removes a node from consideration for *new* placements; it never touches what's already running, and it never overrides an explicit pin. If you pinned a blueprint to a node that happens to be cordoned, the pin still wins, the blueprint deploys there anyway once its rollout is confirmed. Cordon and pin answer different questions ("should this node pick up new work automatically" versus "where must this specific blueprint run"), and when they disagree, the explicit answer (pin) beats the general one (cordon).
+
+
+
+ Pinning and cordoning only declared intent, neither one deployed or withdrew anything by itself. Open **Fleet → Deployments**, select your blueprint, and click **Apply now**. Sencho opens a rollout preview.
+
+
+
+
+
+ You should see one **create** on the destination node and one **remove** on the source, both marked safe. If the destination were unreachable, that create would show as a blocker instead and **Confirm Apply** would stay disabled, see [If something goes wrong](#if-something-goes-wrong). Click **Confirm Apply**.
+
+
+
+
+
+
+
+## Verify it worked
+
+Check from more than one surface, since a single stale UI element could tell you the wrong thing.
+
+**The Federation tab.** The Pin policy row's **Effective** column reads the pinned node, and the Cordoned nodes card lists the source node with its reason and a timestamp.
+
+
+
+
+
+**The audit log.** Open **Audit** and search or scan for the actions you just took. Each mutating request gets its own row: the pin write, the cordon, and the apply.
+
+
+
+
+
+## If something goes wrong
+
+The most common first-time confusion is pinning or cordoning and then expecting the workload to move on its own. It won't. Both actions only change what the reconciler *wants*; the blueprint's catalog tile and detail sheet footer show **pending** or **reapproval required** until you open the blueprint and confirm a rollout.
+
+
+
+
+
+If **Confirm Apply** is greyed out instead, a blocker is present, most often an offline or unreachable destination node. Check the destination's status in **Fleet → Overview** before retrying, or change the pin to a node that's actually reachable.
+
+## Related
+
+
+
+ Cordon and pin mechanics, the confirm-before-mutate model, and full troubleshooting.
+
+
+ The declarative deployment model Federation steers, including selectors, drift policy, and the rollout-preview mechanic.
+
+
+ Author and deploy a Blueprint from scratch, the prerequisite this tutorial assumes.
+
+
diff --git a/docs/tutorials/set-up-fleet-secrets.mdx b/docs/tutorials/set-up-fleet-secrets.mdx
new file mode 100644
index 00000000..6a979c02
--- /dev/null
+++ b/docs/tutorials/set-up-fleet-secrets.mdx
@@ -0,0 +1,120 @@
+---
+title: Push a Shared Environment File to Every Node Running a Stack
+sidebarTitle: Push shared secrets to your fleet
+description: Author one encrypted env-var bundle on the control instance and push it to a stack running on every labeled node, with a diff you can read before anything is written.
+---
+
+Say the same `inventory-api` stack runs on two nodes in your fleet, each with its own `.env` file, and you need to rotate `DB_PASSWORD` on both without SSHing into either host or risking one node drifting out of sync with the other. This walks through building an encrypted secret bundle on your control instance, targeting both nodes by a shared label, and pushing it with a preview step that shows exactly what will change before you write anything.
+
+By the end, one bundle holds the source of truth for the stack's environment, and a single push keeps every labeled node's `.env` in sync with it.
+
+This tutorial covers creating a bundle, targeting nodes by label, and reading the push preview and results. It doesn't cover importing an existing `.env` from a running stack, editing a bundle to a new version, or the full audit and encryption model; see the [Fleet Secrets](/features/fleet-secrets) feature page for those.
+
+## Prerequisites
+
+- An **admin** account on the instance you're signed into. Fleet Secrets decrypts and writes credentials fleet-wide, so every route requires an administrator; there is no lesser role that can push a bundle.
+- You're working from the **control instance** (the hub), not a remote node. Fleet Secrets is a hub concern: its API routes never proxy to a remote node, so the **Secrets** tab and its data always reflect the instance you're currently signed into.
+- At least two nodes in your fleet (Local plus one enrolled node), each running a stack with the **same name** and declaring an env file via `env_file:` in its compose. This tutorial uses `inventory-api`:
+
+ ```yaml
+ services:
+ api:
+ image: nginx:alpine
+ restart: unless-stopped
+ ports:
+ - "8099:80"
+ env_file:
+ - .env
+ ```
+
+ Deploy this stack on both nodes before you start (an empty `.env` is fine; the push will populate it). A stack whose compose only has an inline `environment:` block won't show up as a push target, since Fleet Secrets writes to a file, not inline compose values.
+- Both nodes carry a **shared label**. This tutorial uses `inventory` on both. Add labels from **Settings → Nodes** if they aren't set yet.
+
+
+ Fleet Secrets is available on every Sencho installation; no Admiral requirement.
+
+
+
+
+ Open **Fleet**, select the **Secrets** tab, and click **New bundle** (or **Create your first bundle** if this is the first one on the instance).
+
+
+
+
+
+ Give the bundle a **Name** (`inventory-api-env` here) and an optional **Description**. Add a `KEY=value` row for each variable the stack needs; click **Add key** for more rows. This tutorial uses `LOG_LEVEL=info`, `REGION=us-east`, and `DB_PASSWORD` set to a placeholder value you'll rotate later. Add a **Change note** describing the initial save.
+
+
+
+
+
+ Click **Save**. The values are encrypted before the ciphertext is written to disk; the plaintext only lives in the editor while you're filling it in.
+
+
+ The bundle now appears as a row in the **Secret bundles** table, at version `v1` with a key count matching what you entered.
+
+
+
+
+
+
+ Click the **Send** icon on the bundle's row to open the push wizard. On the **Target** tab, toggle **any** or **all** (any is fine when you have one label) and pick the label both nodes carry, `inventory` here. Enter the **Stack name** the bundle should write to, exactly as it appears on each node. The **Env file** dropdown populates from `env_file:` entries a representative target's compose declares; leave it at `.env` unless your stack uses a different filename.
+
+
+
+
+
+ Click **Preview**. This step only reads; it doesn't write anything yet.
+
+
+ Each row is a node the label selector matched, with a summary of how many keys will be added, changed, or left unchanged. Expand a row to see the per-key breakdown. Since both nodes started with an empty `.env`, every key shows as added.
+
+
+
+
+
+ This is the safety net: the diff shown here is exactly what the next click writes. If a row looks wrong, close the wizard, fix the bundle, and reopen Send to re-run Preview.
+
+
+ Click **Push to N nodes**. Sencho writes to each matched node in sequence and reports a per-node outcome on the **Results** tab: a green check with the same added/changed/unchanged counts on success, or a red error with the exact failure string.
+
+
+
+
+
+
+
+## Verify it worked
+
+Check from two independent surfaces so you're not trusting a single UI element.
+
+**The Results tab itself**, shown above: both nodes report success with the counts matching the bundle's key count.
+
+**The Audit Log.** Open **More → Audit**. Reading newest first, the top entry reads `pushed secret: 1` and the one just below it `previewed secret push: 1`, both attributed to the account that ran the push.
+
+
+
+
+
+If you have shell access to either node, `cat`-ing the stack's `.env` file is a third way to confirm the write landed, but the two UI surfaces above are enough for day-to-day verification.
+
+## If something goes wrong
+
+**A node lands in the failed column.** The most common first-time cause is the **Stack name** not matching exactly what's deployed on every target. Retarget the same bundle at a name with a typo, `inventory-apy` instead of `inventory-api`, and Preview still succeeds (it reads an empty, nonexistent file as "nothing set yet" and shows every key as added), but the actual push fails on both nodes: the local node returns an `ENOENT` filesystem error, and the proxied node returns `No env file exists for this stack` over HTTP.
+
+
+
+
+
+Preview cannot catch this class of mistake because it treats an unreadable target as an empty file rather than a missing stack. Fix the stack name on the Target tab and re-run Preview; a failed push is safe to retry immediately, since the overlay write is idempotent. See [Fleet Secrets · Troubleshooting](/features/fleet-secrets#troubleshooting) for the other common causes, including a stack whose compose doesn't declare the chosen env file at all.
+
+## Related
+
+
+
+ Versioning, the import-from-stack flow, concurrency and lifecycle behavior, and the full audit trail.
+
+
+ Bring a second node into your fleet if you don't have one to label yet.
+
+
diff --git a/docs/tutorials/set-up-sso.mdx b/docs/tutorials/set-up-sso.mdx
new file mode 100644
index 00000000..665b4dd1
--- /dev/null
+++ b/docs/tutorials/set-up-sso.mdx
@@ -0,0 +1,111 @@
+---
+title: Set Up SSO with Custom OIDC
+sidebarTitle: Set up SSO
+description: Connect Sencho to your identity provider so your team signs in with credentials they already have, verified with a real end-to-end login.
+---
+
+Say your team already authenticates against a self-hosted identity provider, such as Keycloak, Authentik, or Authelia, and you'd rather they sign in to Sencho the same way than maintain a second password. This walks through registering Sencho as an OAuth client in that provider, wiring up Sencho's **Custom OIDC** provider to point at it, and confirming a real sign-in works end to end, using Keycloak as the worked example.
+
+By the end, a **Sign in with your identity provider** button appears on the Sencho login page, and signing in through it auto-provisions a new Sencho account with the role you configured.
+
+This tutorial covers **Custom OIDC** on Community, connected to a self-hosted provider. It does not cover the built-in **Google**, **GitHub**, or **Okta** presets (the same Sencho-side steps apply, with fewer fields to fill in), **LDAP / Active Directory** (Admiral only), **SSO only** mode, or mapping identity-provider groups to the Sencho Admin role. See the [SSO & LDAP Authentication](/features/sso) feature page for all of those, plus setup notes for other self-hosted providers.
+
+## Prerequisites
+
+- An **admin** account on the Sencho instance. Configuring SSO providers requires admin, on every tier.
+- An OIDC-compliant identity provider already running, with access to register a new OAuth client on it. This tutorial uses Keycloak; the Sencho-side steps are identical for Authentik, Authelia, or any other spec-compliant provider.
+- The identity provider must be reachable from Sencho over **HTTPS with a valid certificate**. A local dev instance behind plain HTTP, or a self-signed certificate Sencho doesn't trust, will not work.
+
+
+ Sencho's OIDC discovery step rejects a plain `http://` Issuer URL outright, even for a same-host or `localhost` identity provider. If you're self-hosting your identity provider for the first time, put it behind a reverse proxy with a real certificate (Caddy with automatic HTTPS, or Traefik with Let's Encrypt) before starting this tutorial.
+
+
+
+
+ In Keycloak, open **Clients → Create client**. Set **Client ID** to `sencho`, leave **Client type** as **OpenID Connect**, and click **Next**. On the **Capability config** step, turn **Client authentication** on (this makes it a confidential client with a secret, which the form in the next step expects) and click **Next**. On **Login settings**, set **Valid redirect URIs** to your Sencho URL plus the callback path Sencho's SSO page displays at the bottom of the provider list:
+
+ ```
+ https:///api/auth/sso/oidc/oidc_custom/callback
+ ```
+
+ Click **Save**. Open the new client's **Credentials** tab and copy the **Client secret**; you'll paste it into Sencho in the next step.
+
+
+ In Sencho, click your avatar, choose **Settings**, then under **Access** pick **SSO**. Click the **Custom OIDC** card to expand it.
+
+ Fill in:
+
+ - **Display Name**: the label shown on the login button, for example `Corporate SSO`.
+ - **Issuer URL**: your identity provider's realm URL, without the `/.well-known/openid-configuration` suffix. For Keycloak this is `https:///realms/`.
+ - **Client ID**: `sencho`, matching what you set in step 1.
+ - **Client Secret**: the secret you copied from the Credentials tab.
+ - **Default Role**: leave as **Viewer**. This is the role a new SSO user gets unless they match the optional Admin Claim mapping (not covered here; see the feature page for group-based role mapping).
+
+ Leave **Admin Claim**, **Scopes**, and the claim-mapping fields blank; they fall back to sensible OIDC defaults for a standard Keycloak realm.
+
+
+
+
+
+ Toggle the card's switch to **ON**, then click **Save**.
+
+
+ Click **Test Connection**. Sencho performs live OIDC discovery against your Issuer URL and reports success or failure next to the button within a few seconds, before you attempt a real sign-in.
+
+
+
+
+
+ A green check confirms Sencho reached the Issuer URL and parsed a valid OIDC discovery document. If it fails, see [If something goes wrong](#if-something-goes-wrong) below before continuing.
+
+
+ Log out of Sencho. The login page now shows your provider's button under **Or continue with**, using the Display Name you set.
+
+
+
+
+
+ Click it. Sencho redirects you to your identity provider's own login page.
+
+
+
+
+
+ Sign in with a directory account that has never logged into Sencho before. On success, the identity provider redirects back and you land on the Sencho dashboard, already signed in.
+
+
+
+## Verify it worked
+
+Check from two places, since either alone only shows one side of provisioning.
+
+**Your own profile menu.** Click your avatar. It shows the username from your identity provider, a **Viewer** role pill (the Default Role from step 2), and a **Community** tier pill, confirming you're signed in as a newly provisioned account, not your admin session.
+
+
+
+
+
+**The admin Users list.** Sign back in as an admin (local password, or an SSO account you've mapped to Admin) and open **Settings → Access → Users**. The new account appears in the table with today's date under **Created** and the same **Viewer** role, confirming the server-side record matches what the user saw.
+
+
+
+
+
+On every later sign-in, this same account is reused. Its email and role are re-synced from the identity provider each time, so promoting or removing someone from your directory takes effect on their next login.
+
+## If something goes wrong
+
+**Test Connection fails immediately with an HTTPS-related error.** The Issuer URL almost certainly starts with `http://`. Sencho's discovery step requires HTTPS with a certificate it trusts; put a reverse proxy with a valid certificate in front of your identity provider (see the Warning above) and update the Issuer URL, then test again. If the URL is already `https://`, check that it doesn't include the `/.well-known/openid-configuration` suffix and that it exactly matches the `issuer` value your provider's own discovery document reports.
+
+The [SSO troubleshooting section](/features/sso#troubleshooting) covers issuer mismatches, wrong-username-on-first-login, and the redirect URI error message from the identity provider side.
+
+## Related
+
+
+
+ Every provider (including LDAP), role mapping, SSO-only mode, and full troubleshooting.
+
+
+ Require a TOTP code on every SSO sign-in for a stricter posture than SSO alone.
+
+