---
title: "Fleet Secrets"
description: "Author one set of environment-variable bundles on the control instance, push them to labeled nodes' stacks, and keep an audit trail of every change."
---

The **Secrets** tab on the Fleet view is where you bundle environment variables once and push them, version by version, to every node in the fleet that runs a copy of the same stack. Each bundle is a named set of `KEY=value` pairs encrypted at rest, every save is a new immutable version with a change note, and every push records a per-node diff in the audit log.

The unit of work is the **bundle**. One bundle has one current `kv` payload; pushing it overlays that payload onto a chosen env file inside a chosen stack on every node a label selector matches. The wizard is three tabs (**Target**, **Preview**, **Results**) and reports each per-node outcome inline.

<Frame>
  <img src="/images/fleet-secrets/overview.png" alt="Fleet view with the Secrets tab selected. Heading 'Secret bundles' with a 'New bundle' button on the right; below it a table with columns Name, Description, Version, Keys, Updated, and three icon actions per row (edit, send, delete). One row visible: app-config · Shared environment for the app stack · v2 · 5 keys, last updated a few moments ago." />
</Frame>

<Note>
Fleet Secrets is available on every Sencho installation. Managing bundles requires an admin user role.
</Note>

## What Fleet Secrets covers (and what it doesn't)

Fleet Secrets owns env-var bundles that span more than one node. If your goal is in the table below, the dedicated surface is the right place.

| Goal | Where to do it |
|------|----------------|
| Edit `.env` for one stack on one node | The [stack editor](/features/stack-file-explorer) on that node |
| Ship a TLS cert, a key file, or a JSON credential blob to a stack | The stack editor (Fleet Secrets only carries `KEY=value` pairs in v1) |
| Rotate a value on a recurring schedule | Not yet. Pushes are operator-initiated in v1; track the rotation in [Scheduled Operations](/features/scheduled-operations) if you want a reminder |
| Restart the targets after a push | [Fleet Actions](/features/fleet-actions) |
| Replicate scan policies and CVE suppressions to remote control instances | [Fleet Sync](/features/fleet-sync) |
| Inspect who pushed which bundle when | [Audit Log](/features/audit-log) |

## The bundle is the unit

A **bundle** has a name, a description, a current version number, and a `kv` payload of `KEY=value` pairs. Each save creates a new version row that is immutable; the bundle's `current_version` field points at the newest one. The payload is encrypted with the instance's AES-256-GCM data key, the same key that protects MFA secrets and registry credentials, so the kv values never sit on disk in plaintext.

A **push** is a separate action. It reads the bundle's current version, walks every node the selector resolves to, computes a per-node diff against the chosen env file inside the chosen stack, and writes the merged result. Pushes are sequential per bundle, so only one push for a given bundle runs at a time. Each per-node write is independent, so one failure does not roll back the others.

## Prerequisites

| Requirement | Why it matters |
|---|---|
| Admin role on the control instance | Bundle CRUD and push require an administrator; authored-by rows are written into the audit log |
| At least one stack on at least one node | Pushes target an existing stack directory; the wizard does not create stacks |
| The target stack's compose declares the env file via `env_file:` | The env-file dropdown in the push wizard reads `env_file:` entries from a representative node's compose; a stack with only an inline `environment:` block will not show up |
| The control instance can reach the remote node's API URL | Each remote write is an HTTP call from the control instance to the remote's `/api/stacks/.../env`; an unreachable remote is reported as a per-node failure, not a transport error for the whole push |

## Create a bundle

1. Open **Fleet → Secrets** on the Fleet view.
2. Click **New bundle**.
3. Give it a name. Names are 2-64 characters, alphanumerics plus space, dot, dash, and underscore, and must start and end with an alphanumeric.
4. Optionally add a description; the description is a free-text field and is shown in the bundle list.
5. Add `KEY=value` rows. Keys follow the env-var convention: start with a letter or underscore, then any mix of letters, digits, and underscores; case-sensitive. The eye icon toggles per-row visibility, and the copy icon copies a single value to the clipboard.
6. Add a **Change note** if you want one (it appears next to the version row on the **Versions** tab).
7. Click **Save**. The bundle is now version `v1`.

<Frame>
  <img src="/images/fleet-secrets/create.png" alt="New secret bundle sheet. Breadcrumb 'Fleet › Secrets › New bundle', heading 'New secret bundle', Save button in the action bar. Identity section with Name 'app-config' and Description 'Shared environment for the app stack'. Key/value pairs section showing three rows: PUID=1000, PGID=1500, TZ=America/Los_Angeles, each row carrying eye, copy, and delete icons; an 'Add key' button beneath. Change note (optional) field reads 'Initial app config bundle'." />
</Frame>

Saving encrypts the payload in memory and writes the ciphertext to the `secret_versions` table. The plaintext values only live in the editor while you're filling them in; once saved they are read back by decrypt only when the bundle is opened or a push references that version.

## Edit and version

Open an existing bundle from the list and edit the keys, the description, or the change note. Saving creates a new version row and bumps the bundle's `current_version`; the previous version stays in history and is never rewritten. There is no row-level lock, so concurrent edits use last-write-wins semantics: both saves succeed, both appear in **Versions**, and the higher version number is current.

The **Versions** tab on the bundle sheet lists every version with its key count, change note, who saved it, and when.

<Frame>
  <img src="/images/fleet-secrets/versions.png" alt="Bundle sheet for app-config in edit mode with the Versions tab selected. Two version rows visible: v2 with 5 keys, change note 'Switch app to debug logging and wire Sentry', authored by admin at the timestamp on the right; v1 with 3 keys, change note 'Initial app config bundle', authored by admin at an earlier timestamp." />
</Frame>

The Versions tab is read-only history. There is no **Restore v1** action in v1; if you need to revert, open the current version, edit the rows back to the values you want, add a change note explaining the revert, and save. The Versions tab will then show the revert as a new version on top of the others.

## Push to nodes

The push wizard walks Target → Preview → Results in order. Once a stage is populated you can click back into earlier tabs to adjust; you cannot skip Preview before executing.

### Target tab

Pick the **label selector** that matches the nodes you want to push to. Toggle **any** (a node matches if it carries any of the selected labels) or **all** (a node must carry every selected label). Enter the **Stack name** that the bundle should be applied to; the stack must exist on each matched node by that exact name. Pick the **Env file** from the dropdown. The dropdown is populated from the `env_file:` directives on a representative target's compose, so per-node differences are possible.

<Frame>
  <img src="/images/fleet-secrets/target.png" alt="Push wizard Target tab. Breadcrumb 'Fleet › Secrets › app-config › Push', heading 'Push app-config' with 'v2 · 5 keys' subtitle. Preview button at the top. Target nodes section: 'Match any / all of these labels' with the any toggle selected; below it the labels combobox is open showing one option 'app-tier' with a checkmark. Target stack section: Stack name 'secrets-demo', Env file dropdown showing '.env', helper text below explaining that env-file options come from a representative target." />
</Frame>

Click **Preview** when you're done. Preview is a read-only walk: it asks each matched node what's currently in the chosen env file and computes the diff against the bundle. It does not write anything.

### Preview tab

Each row is a matched node with a short header reading `+added · ~changed · ·unchanged`, plus a `drift N` count when the target has keys the bundle doesn't. Expand a row to see the per-key diff: status labels are **ADDED**, **CHANGED**, **REMOVED**, and **UNCHANGED**. A `removed` row is informational; the push will not delete it.

<Frame>
  <img src="/images/fleet-secrets/preview.png" alt="Push wizard Preview tab with two node rows expanded. Local: header reads +2 ~2 ·1 drift 1, with per-key rows ADDED LOG_LEVEL, REMOVED OLD_LEGACY_FLAG, CHANGED PGID, UNCHANGED PUID, ADDED SENTRY_DSN, CHANGED TZ. Opsix: header reads +5 ~0 ·0, with five ADDED rows for LOG_LEVEL, PGID, PUID, SENTRY_DSN, TZ. 'Push to 2 nodes' button at the top." />
</Frame>

Preview is also the safety net. The diff you see is the diff the next push will apply, so if a row looks wrong, fix the bundle first and re-run Preview.

### Results tab

Once you click **Push to N nodes**, the wizard fans out to each matched node sequentially. Each per-node row reports `ok` with the same `+added · ~changed · ·unchanged` header it had in Preview, or `failed` with the exact backend error string. Hover a failed row for the full message.

<Frame>
  <img src="/images/fleet-secrets/results.png" alt="Push wizard Results tab. Two rows: Local with a green check icon and counts +2 ~2 ·1; Opsix with a red error icon and the message 'failed to write env (HTTP 500: {error: Failed to save env file...})'. 'Done' button at the top." />
</Frame>

Per-bundle serialization means a second push attempt while the first is running returns `A push for this secret is already running`; wait for the first to finish, then retry. Per-node serialization within a single push keeps the audit-log ordering predictable.

## Import from a stack

If a node already has a working env file, you don't need to retype it into a new bundle. Open the bundle in edit mode and click **Import from stack** next to the **Add key** button. Pick a **Node**, type the **Stack name** and **Env file** basename, and click **Import**.

The import reads the named env file from the chosen node and overlays its kv into the editor: keys that match existing rows have their values updated in place, keys that don't appear yet append at the bottom. The editor state stays dirty until you click **Save**, so you can review, tweak, or discard the import before it becomes a version.

Import is the natural seeder for a fresh bundle (read the working env off one node, then save it as v1) and a useful read-back for triage (point the bundle at a deployed stack to see what's currently set).

## Behaviour and lifecycle

| Aspect | Behaviour |
|---|---|
| Push execution | Sequential per bundle; sequential per node within a push |
| Concurrency lock | One in-flight push per bundle; second attempt returns `A push for this secret is already running` |
| Partial failure | Each per-node write is independent; one failure does not roll back others |
| Per-node timeout | 10 seconds per remote HTTP call |
| Encryption at rest | AES-256-GCM with the instance data key; only the kv payload is encrypted, bundle metadata (name, description, version numbers, change notes, who saved when) is plaintext SQLite |
| Versioning | Immutable; new save creates a new row in `secret_versions`, bundle's `current_version` is bumped |
| Concurrent edits | Last-write-wins (no row-level lock); both versions land in history, the later one is current |
| Drift handling | Keys present on the target but missing from the bundle are preserved and reported as informational `drift` in Preview; the push never deletes them |
| Live progress | None. The wizard is request/response; Results renders only after every per-node write completes |
| Audit | Every CRUD, import, preview, and execute action writes a row into the audit log; every executed push also writes one `secret_pushes` row per target node |

## Audit trail

Every mutating action surfaces in the [Audit Log](/features/audit-log) with the exact summary string below.

| Action | Audit summary |
|---|---|
| Create bundle | `Created secret` |
| Update bundle | `Updated secret: <id>` |
| Delete bundle | `Deleted secret: <id>` |
| Import env from a stack | `Imported env into secret: <id>` |
| Preview a push | `Previewed secret push: <id>` |
| Execute a push | `Pushed secret: <id>` |

In addition to the audit-log row, every executed push writes one row per target node into a `secret_pushes` table. Each row captures the bundle id, the version that was pushed, a UUID that groups all rows from the same push, the node id, the stack name, the env file basename, the status (`ok` or `failed`), the error message when the status is `failed`, and the per-node added / changed / unchanged counts. The audit-log summary points at the bundle and version; the `secret_pushes` rows are the per-target detail.

## Limitations and non-goals

v1 is intentionally narrow.

- **Payloads are `KEY=value` only.** Certificates, key files, and JSON credential blobs are out of scope. The same compose-level `env_file:` mechanism is what makes the push possible; non-env-file targets need a different surface.
- **Targeting is by label selector.** Pinning a bundle to a specific list of node IDs is not exposed in the wizard.
- **No scheduled rotation.** Every push is operator-initiated. Use [Scheduled Operations](/features/scheduled-operations) for a reminder if you need recurring rotation.
- **One bundle, one kv set.** Multi-region overlays, per-environment variants, or merged-from-multiple-bundles pushes are out of scope; if you need three different kv shapes, create three bundles.
- **Drift keys are never deleted.** A key the target has but the bundle doesn't is shown as informational `drift` and preserved. If you want it gone, edit that node's env file directly in the [stack editor](/features/stack-file-explorer), then re-push the bundle.
- **No live progress.** The wizard does not stream per-node updates while a push runs; Results renders only after the last node has been written.
- **No version restore action.** Versions are immutable history; to revert, edit the current version back to the old values and save a new version with a change note explaining the revert.

## Practical workflows

- **Rotate a database password across every replica.** Edit the bundle's `DB_PASSWORD` row, add a change note ("rotate 2026-Q2"), and push to nodes labeled `db-replica` against the `app` stack's `.env`. Once Results is clean, follow with **Fleet Actions → Stop fleet by label** + redeploy on the affected nodes so the running containers pick up the new value.
- **Seed a fresh node from a known-good one.** Create an empty bundle, click **Import from stack** in edit mode, point it at the node that already has the working env, save as v1, and push to the new node.
- **Bring a divergent fleet back in line.** Run **Preview** without pushing to see, node by node, exactly which keys have drifted. The diff is the source of truth for what the next push will write; share it with the team before committing.
- **Triage a failed push.** Open the **Audit Log** and find the `Pushed secret: <id>` row. The matching `secret_pushes` entries name every target, its status, and the error. A common cause is `env file '.env' not found` when the target's compose lacks an `env_file:` line; declare the file in compose, redeploy that stack, and re-run the push.

## Troubleshooting

<AccordionGroup>
  <Accordion title="A node landed in the 'failed' column">
    Hover the row for the full error. The two most common causes are (1) the target stack does not declare the chosen env file via `env_file:` in its compose, so the remote write target does not resolve, and (2) the stack directory does not exist on that node. Open the stack on the affected node, confirm both, fix the cause, and re-run the push. Pushes are idempotent under the overlay merge, so re-running is safe.
  </Accordion>
  <Accordion title="The wizard says 'A push for this secret is already running'">
    The per-bundle lock is held while the first push is in flight. Wait for it to finish; the lock is released automatically when the push exits, success or failure. Per-bundle serialization is what keeps the audit-log ordering predictable, so the lock is intentional.
  </Accordion>
  <Accordion title="The Env file dropdown is empty or missing the file I expected">
    The dropdown is populated by reading `env_file:` directives from a representative target's compose. If no target declares any env file, the dropdown falls back to the default `.env` only. If your stack uses an inline `environment:` block in compose, the file does not exist and the dropdown can't list it; add an `env_file:` entry in compose and redeploy the stack, then reopen the wizard.
  </Accordion>
  <Accordion title="Values on the target look wrong after a push">
    Open the bundle's **Versions** tab and confirm which version is current. Then run **Preview** without pushing: the diff you see is exactly what the next push will write. If the bundle is correct but the running container still shows the old values, the container needs to be recreated to pick up the new env, since `env_file:` is read at create-time, not on a restart. Stop and redeploy the stack on the affected node, or use [Fleet Actions → Stop fleet by label](/features/fleet-actions) for a batch redeploy.
  </Accordion>
  <Accordion title="Two operators saved the bundle at the same time">
    Last-write-wins. Both saves succeed and both are visible in the **Versions** tab; only the later one is the current version. Open the bundle, reconcile the kv with whichever operator should "win" the merge, and save once more with a change note explaining the reconciliation.
  </Accordion>
  <Accordion title="Preview shows 'drift' rows; should I worry?">
    No. A `drift N` count means the target has N keys the bundle doesn't, which is informational only. The push will leave those keys alone. If a drift key is something you want gone, delete it from that node's env file in the [stack editor](/features/stack-file-explorer), then re-push the bundle to bring the rest of the keys back in line.
  </Accordion>
  <Accordion title="A node in the label selector resolved to zero matches">
    The labels combobox lists labels that exist somewhere in the fleet, but a label needs to be assigned to at least one online node for the selector to match it. Open **Settings → Nodes** on the control instance, confirm the label is assigned to the node you expect, and re-run **Preview**.
  </Accordion>
</AccordionGroup>

## Where Fleet Secrets fits

Fleet Secrets is one tab inside the Fleet view. Each adjacent surface answers a different question.

| Related feature | What it covers | Why it is not Fleet Secrets |
|---|---|---|
| [Stack File Explorer](/features/stack-file-explorer) | The per-node editor for compose files, env files, and any other file in the stack directory | Stack File Explorer edits one file on one node; Fleet Secrets pushes one kv to many nodes |
| [Fleet Actions](/features/fleet-actions) | Imperative bulk operations across labeled nodes: stop, prune, relabel | Fleet Actions runs operations; Fleet Secrets pushes configuration |
| [Fleet Federation](/features/fleet-federation) | Operator-driven placement controls (cordon, pin) for [Blueprints](/features/blueprint-model) | Federation steers where new declarative deployments land; Fleet Secrets distributes existing-stack env updates |
| [Fleet Sync](/features/fleet-sync) | Push-only replication of security policies from a control instance to its replicas | Fleet Sync replicates scan policies and CVE suppressions; Fleet Secrets replicates environment-variable bundles |
| [Scheduled Operations](/features/scheduled-operations) | Recurring or one-shot scheduled stack operations | Scheduled Operations owns recurrence; Fleet Secrets pushes are operator-initiated in v1 |
| [Audit Log](/features/audit-log) | Every mutating action on the control instance, including all Fleet Secrets actions | Fleet Secrets writes into the audit log; the Audit Log is where you read it back |
| [Multi-Node Management](/features/multi-node) | Adding nodes to the fleet, the proxy model, and how tier propagates | Fleet Secrets rides on the same proxy; the bundles are authored on the control instance and pushed to remotes via the remote-node HTTP proxy |
