Skip to content

Storage

Where a workspace's data lives, how that is decided, and how to move it from local SQLite to Postgres without losing what you already have.

Verified against runtime 1.131.1.

The one rule

Nothing chooses its own database. Every component asks one service for a kind of store, and that service resolves the configuration once, at startup, and reports what it chose.

Before 1.130.0 six components resolved storage independently and four ignored the configuration entirely — three had a SQLite path hardcoded. A workspace declaring Postgres for everything ran its run history, its audit trail and its governed memory into a file on one machine. swarmkit validate passed, runs succeeded, and the only symptom was that swarmkit serve showed nothing.

The six stores

Store Holds Follows
runtime jobs, conversations, usage, serve access the config you write
audit the append-only audit trail storage.audit, else storage.runtime
artifacts saved run outputs, fetched by ref storage.artifacts, else storage.runtime
memory governed memory + its change log storage.runtime
fleet enrollment tokens, fleet memberships storage.runtime (design 19 Q4)
checkpoints LangGraph run state only storage.checkpoints

audit and fleet keep their own SQLite file when the backend is SQLite — separate retention, separate size — but they follow the same backend.

checkpoints is the exception, deliberately. It is a LangGraph component with its own driver (pip install "swarmkit-runtime[postgres]"), so promoting it to Postgres merely because the application store is Postgres would fail a workspace that never asked for it. Only an explicit storage.checkpoints block moves it.

If you ask for postgres here and the extra is not installed, it degrades to the local SQLite checkpointer with a warning rather than refusing to start (1.131.1+). That is the one place degrading is right: see It fails rather than degrades.

Configuring it

One block moves the whole workspace:

# workspace.yaml
storage:
  runtime:
    backend: postgres
    url: ${SWARMKIT_STORE_URL}

A per-store block inherits storage.runtime.url when it declares none, so the URL is written once:

storage:
  runtime:
    backend: postgres
    url: ${SWARMKIT_STORE_URL}
  audit:
    retention_days: 90        # different retention, same database

${VAR} and ${VAR:-default} are expanded here. (Before 1.130.0 they were not — url: ${SWARMKIT_STORE_URL}, the form every deployment doc uses, reached SQLAlchemy as those literal characters.)

SWARMKIT_STORE_URL vs SWARMKIT_STORE_BACKEND

Both are environment variables, both override workspace.yaml, and they are not a pair.

Variable What it does Needed?
SWARMKIT_STORE_URL The connection URL. A URL names its own backend, so setting this alone selects Postgres. This is the one you want.
DATABASE_URL Same, used only when SWARMKIT_STORE_URL is unset. Fallback.
SWARMKIT_STORE_BACKEND Forces sqlite or postgres regardless of the file. Rarely. Only to force SQLite while a URL is set.
# Sufficient. Do not also set SWARMKIT_STORE_BACKEND.
export SWARMKIT_STORE_URL="postgresql://swarm:secret@db:5432/swarmkit"

This exact combination was a bug until 1.130.0

A .env declaring only SWARMKIT_STORE_URL was silently ignored: the resolver required SWARMKIT_STORE_BACKEND to be set before it would look at the URL at all, so a correctly configured Postgres stayed empty while everything wrote to SQLite. If you set only the URL and saw no data, that was this.

The environment is a global signal — it moves every store that follows storage.runtime. It does not move checkpoints, for the reason above.

Seeing what it chose

swarmkit storage status <workspace>
storage for /srv/swarm:

  store        backend   location  (source)
  runtime      postgres  postgresql://swarm:***@db:5432/swarmkit  (env)
  audit        postgres  postgresql://swarm:***@db:5432/swarmkit  (env)
  checkpoints  sqlite    workspace-local  (default)
  artifacts    postgres  postgresql://swarm:***@db:5432/swarmkit  (env)
  memory       postgres  postgresql://swarm:***@db:5432/swarmkit  (env)
  fleet        postgres  postgresql://swarm:***@db:5432/swarmkit  (env)

The source column is the point: it names the setting that won, so "I set that and it did nothing" has an answer. The same report is printed at swarmkit serve startup, served at GET /storage, and shown on the web UI's System page — the answer to "why is this screen empty" has to be reachable from the screen that is empty.

Passwords are masked everywhere. This output goes to terminal scrollback, log files and CI capture.

A store whose configuration cannot be honoured — the usual case is backend: postgres with url: ${SWARMKIT_STORE_URL} in a shell where that variable is not set — is a row, not a crash (1.237.0; before that swarmkit system died with a traceback on exactly the workspace it was meant to diagnose). Stores that inherit one block share one line, and the command exits 2:

  store        backend   location  (source)
  runtime      postgres  UNRESOLVED  (storage.runtime — see below)
  audit        postgres  UNRESOLVED  (storage.runtime — see below)
  checkpoints  sqlite    workspace-local  (default)

  ! runtime, audit, artifacts, memory, fleet: storage backend 'postgres' (from storage.runtime)
    has no URL. Set one of: storage.runtime.url, SWARMKIT_STORE_URL. (If the value is '${VAR}',
    that variable is not in swarmkit's environment — a `source .env` sets a shell variable that child processes never see unless it is exported: `set -a; source .env; set +a`.) Refusing to fall back to sqlite …

swarmkit system prints the same rows and then carries on to the environment section, which is where the answer usually is. If you upgraded from before 1.130.0 and this is the first time you have seen it: that version silently ignored the setting and wrote to SQLite, so check .swarmkit/*.sqlite for rows before assuming the Postgres database is the history — swarmkit storage migrate copies them over.

It fails rather than degrades

A backend that cannot be honoured raises at startup:

storage backend 'postgres' for runtime (from storage.runtime) has no URL. Set one of:
storage.runtime.url, SWARMKIT_STORE_URL. (If the value is '${VAR}', that variable is unset.)
Refusing to fall back to sqlite: the run would write to a different database than the one
configured.

Falling back would write the run somewhere other than where you configured, and split serve from anything else reading the same store with neither process warning. A failed start is the cheaper failure.

Except for checkpoints

storage.checkpoints.backend: postgres without swarmkit-runtime[postgres] installed degrades to the local SQLite checkpointer and warns:

storage.checkpoints.backend is 'postgres' (from storage.checkpoints) but the Postgres
checkpointer is not installed — using the local SQLite checkpointer instead. Runs stay
resumable on THIS host only. Install it with:  pip install 'swarmkit-runtime[postgres]'

swarmkit storage status shows it as sqlite … (storage.checkpoints → sqlite (postgres extra not installed)), so the report never claims you configured what you got.

Two reasons this one is different. The rule above protects records — an audit trail or a governed-memory write landing in the wrong database loses data silently. Checkpoints are disposable run state; the cost here is resumability from another host, which surfaces at resume, on the run it affects. And this is a missing optional dependency, not a wrong config: taking down serve and every trigger over one is disproportionate to a store whose contents can be thrown away.

Upgrading from before 1.130.0

storage.checkpoints.backend: postgres was silently ignored until 1.130.0, so a workspace could carry it for months without the extra installed. In 1.130.0 and 1.131.0 that combination refused to start — swarmkit serve exited with StorageConfigError on a config that had always been there. 1.131.1 degrades instead. If you are on 1.130.0 or 1.131.0, either install the extra or set storage.checkpoints.backend: sqlite.

Moving from local SQLite to Postgres

The whole runbook. Steps 4 and 5 are the ones people skip.

1. Create the database

createdb swarmkit
# or: docker run -d --name swarmkit-pg -e POSTGRES_PASSWORD=secret \
#       -e POSTGRES_USER=swarm -e POSTGRES_DB=swarmkit -p 5432:5432 postgres:16

Nothing else — the tables are created on first connection.

2. Point the workspace at it

# workspace.yaml
storage:
  runtime:
    backend: postgres
    url: ${SWARMKIT_STORE_URL}
# .env, or your process manager's environment
SWARMKIT_STORE_URL=postgresql://swarm:secret@localhost:5432/swarmkit

Keep the URL in the environment and the reference in version control. Do not put a password in workspace.yaml.

3. Confirm the resolution before moving anything

swarmkit storage status .

Every store you expect should read postgres, and the source column should name the setting you just wrote. If one still says sqlite (default), fix that first — migrating into a database the runtime is not going to use is worse than not migrating.

4. Copy the existing rows

swarmkit storage migrate . --dry-run    # what would move
swarmkit storage migrate .              # move it
Migrating 468 row(s) from /srv/swarm/.swarmkit:
  store.sqlite   jobs                                18 -> postgresql://swarm:***@db:5432/swarmkit
  audit.sqlite   audit_events                       412 -> postgresql://swarm:***@db:5432/swarmkit
  ...
Done: 468 row(s) copied, 0 already present.
  • Additive and idempotent. Rows already present are skipped on primary key, so a re-run after a partial failure resumes rather than duplicates.
  • Nothing is deleted. The SQLite files stay exactly as they are.
  • Run it with the runtime stopped, so nothing is writing to the old files mid-copy.

Without this step, "switch to Postgres" means "abandon everything recorded so far". Governed memory is the one that actually hurts: it is accumulated knowledge, not just history.

5. Verify, then archive the old files

swarmkit storage status .        # no warnings
psql -d swarmkit -c "SELECT count(*) FROM audit_events;"

status warns while a populated local SQLite still exists under a remote configuration:

! audit: configured for postgres, but /srv/swarm/.swarmkit/audit.sqlite still holds ~412 rows
  written before this. Move them with:  swarmkit storage migrate /srv/swarm

Once the counts match, move the files aside:

mkdir -p .swarmkit/pre-postgres
mv .swarmkit/store.sqlite .swarmkit/audit.sqlite .swarmkit/fleet.sqlite .swarmkit/pre-postgres/

Leaving them in place is how a split brain starts.

6. Restart everything that touches the store

swarmkit serve, any swarmkit run invocation, and any process manager unit. They must all see the same environment. A process started without SWARMKIT_STORE_URL reads and writes a different database than the one you migrated into: runs land where nobody is looking, and nothing warns. If your application sequences runs, it keeps its own state in its own store — check that one too.

Optionally: Postgres checkpoints too

Run state does not need to move — SQLite checkpoints are local and disposable, and losing them costs you resumability, not data. If you want them shared anyway:

pip install "swarmkit-runtime[postgres]"
storage:
  checkpoints:
    backend: postgres        # inherits storage.runtime.url

Rolling back

Remove the storage: block (or set backend: sqlite), restore the archived files, restart. The SQLite files were never modified, so this is a move-back, not a restore.

See also