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:
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¶
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¶
# .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¶
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¶
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¶
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:
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¶
- Workspace environment configuration —
workspace.env.yaml,${...}references, and marking properties as secret. - Driving SwarmKit from your application — the HTTP contract, and where your sequencer's own state belongs.
- CLI reference —
swarmkit storage,swarmkit system.