# SQLite

> The @FrontMcp sqlite option: keep sessions, background tasks and pending questions in a SQLite file on one machine, with the packages it needs, where the file goes, what it stores, locking, and encryption.

Source: https://frontmcp.dev/reference/deployment/sqlite

`@FrontMcp({ sqlite })` keeps the state a server holds between requests (sessions of clients before MCP 2026-07-28, background tasks and questions waiting for an answer) in a SQLite file instead of the process's memory, so it survives a restart. It needs no server of its own, only a disk that lasts. It's for one machine: a server on a laptop or a VM, a local daemon, a desktop client's server. Several machines share state through [Redis](https://frontmcp.dev/reference/deployment/redis) instead.

```ts
@FrontMcp({ sqlite: { path?, encryption?: { secret }, walMode?, ttlCleanupIntervalMs?, busyTimeoutMs? } })
```

---

## Reference

### `sqlite`

```bash
npm install @frontmcp/storage-sqlite
```

```ts main.ts
import "reflect-metadata";
import { FrontMcp } from "@frontmcp/sdk";
import { HelpDesk } from "./help-desk.app";

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  sqlite: { path: "./data/desk.sqlite" },
})
export default class Server {}
```

[See more examples below.](#usage)

`@frontmcp/storage-sqlite` brings `better-sqlite3`, a native module, with it. FrontMCP's own session store loads `better-sqlite3` directly, and `local` and `remote` auth's `tokenStorage: { sqlite }` loads `@frontmcp/storage-sqlite`; installing the first gives you both. `better-sqlite3` downloads or compiles its binary when it's installed, so install it on the platform it runs on: `npm ci` inside the Docker image, not on your laptop.

#### Options

| Option | Type | Default | What it does |
| --- | --- | --- | --- |
| `path` | `string` | See [where the file goes](#where-the-file-goes) | The database file. Its folder is created if it's missing. `FRONTMCP_SQLITE_PATH` overrides it. |
| `encryption` | `{ secret: string }` | none | Encrypt every stored value with AES-256-GCM, with a key derived from `secret`. Keys stay readable. |
| `walMode` | `boolean` | `true` | SQLite's write-ahead log: the file gets `-wal` and `-shm` companions. |
| `ttlCleanupIntervalMs` | `number` | `60000` | How often expired entries are deleted. |
| `busyTimeoutMs` | `number` | `5000` | How long a write waits for a lock another process holds before it fails (SQLite's `busy_timeout`, set before anything else touches the file). Since 1.9. |

With another process holding a write lock for 3 seconds, an `initialize` waited for it and its session was stored; with `busyTimeoutMs: 1000` it gave up after a second, and the log said `Failed to persist session to SQLite { …, error: 'database is locked' }`. The option is also in `transport.persistence.sqlite`, `tasks.sqlite` and `auth.tokenStorage.sqlite`. Changed in 1.9: before, `@FrontMcp({ sqlite })` dropped `busyTimeoutMs`, and the wait was always 5 seconds.

### Where the file goes

| When | Without `path` |
| --- | --- |
| Outside production, started with `node` or `frontmcp dev` | `<project>/dist/sessions.sqlite` |
| In production | `~/.<info.name>/sessions.sqlite`, like `~/.help-desk/sessions.sqlite` |

`FRONTMCP_SQLITE_PATH` beats both, and `path` too: with `FRONTMCP_SQLITE_PATH=./other/override.sqlite` the file was created there and `./data/desk.sqlite` wasn't. The server logs the path it chose: ``No `sqlite.path` set — using default { path: '…' }``. In a container, `~` is the home folder of the user the process runs as, inside the container: mount a volume there, or set `path` to one, or the file is lost with the container.

### What's stored

With `sqlite` set, the server logs `sqlite session store will be initialized for transport persistence`, `Created SQLite task store`, and with `elicitation` on, `Created elicitation store { type: 'sqlite' }`. The file has two tables:

| Table | Holds |
| --- | --- |
| `kv` (`key`, `value`, `expires_at`) | Sessions, as `mcp:transport:<session id>`, and pending questions, under `mcp:elicit:`. Each value is JSON, or `iv:tag:ciphertext` in base64url with `encryption`. |
| `mcp_tasks` | Background tasks, one row each. |

A session expires an hour after it's written, as [in Redis](https://frontmcp.dev/reference/deployment/redis#how-long-sessions-last); `transport.persistence.defaultTtlMs` changes that. Sessions of clients on MCP 2026-07-28 don't exist, so their calls write nothing unless a tool starts a task or asks a question.

`sqlite` isn't used by `auth.tokenStorage` or `throttle.storage`, which have their own options ([Local auth](https://frontmcp.dev/reference/auth/local#storage), [Guard options](https://frontmcp.dev/reference/sdk/guard#storage-and-keyprefix)).

#### Caveats

- **One machine.** Two processes can share a file on the same disk, and one serves the other's sessions. Two that opened it at the same moment both started, in three tries; a write waits up to `busyTimeoutMs` when the other process holds the lock, and only after that fails with `database is locked`: the session isn't stored, the log says `Failed to persist session to SQLite { …, error: 'database is locked' }` (it said `Redis` in 1.8.7), and the call itself is served. Changed in 1.8.7: before, the second process failed straight away with `Failed to create session store - session persistence disabled { error: 'database is locked' }` and kept sessions in memory.
- **A lost `encryption.secret` loses the data**, and a changed one breaks it: a client whose session was stored with the old secret gets `500 Internal Server Error`, and the log says `SqliteDecryptionError: SqliteKvStore: failed to decrypt a stored value. The encryption secret differs from the one the database was written with (or the value is corrupted). Restore the original secret, or delete the database file to start empty.`, with the code `SQLITE_DECRYPTION_FAILED`. New sessions work. There's no way to re-encrypt.
- SQLite doesn't run on Cloudflare Workers, and the Cloudflare build refuses a server that names `sqlite`; on Vercel and Lambda each instance has its own disk, so a file doesn't help.
- The Playground has no SQLite, so nothing here runs on this page. It was run with FrontMCP 1.9.2, `@frontmcp/storage-sqlite` 1.9.2 and `better-sqlite3` 12.11, on macOS, and the tables are what `better-sqlite3` read back from the file: stored sessions across a restart, a second process on the same file serving a session, a write held up by another process's lock, with and without `busyTimeoutMs`, `FRONTMCP_SQLITE_PATH`, a changed `encryption.secret`, and the default path in development. Two processes opening the file at the same moment and the production path were last run with 1.8.7, and the container steps with 1.8.4.

---

## Usage

### Keeping sessions across restarts

With the configuration [at the top of the page](#sqlite), start the server with `MCP_SESSION_SECRET` set, and let a client on MCP 2025-06-18 `initialize`. Its session is a row in the file:

```bash
node -e "const D = require('better-sqlite3'); console.log(new D('./data/desk.sqlite', { readonly: true }).prepare('select key, expires_at from kv').all())"
```

```text
[ { key: 'mcp:transport:<session id>', expires_at: 1790000000000 } ]
```

Stop the server and start it again with the same secret: the client's next `tools/call` with the same `Mcp-Session-Id` succeeds, and the server logs `Recreating transport from stored session`. Without `sqlite`, it would get `404` `session not initialized`. Without the secret, outside production, the restarted server can't read the old id and answers `404` `invalid session id`, sqlite or not: see [Redis](https://frontmcp.dev/reference/deployment/redis#storing-sessions-in-redis).

### Encrypting what's stored

Read the secret from the environment, and keep it where you keep the server's other secrets:

```ts main.ts
@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  sqlite: {
    path: "./data/desk.sqlite",
    encryption: process.env.DESK_DB_SECRET ? { secret: process.env.DESK_DB_SECRET } : undefined,
  },
})
export default class Server {}
```

The row's `value` is then `<iv>:<tag>:<ciphertext>`, three base64url parts, instead of JSON. The keys, and so the session ids, stay readable: they're how rows are found.

### Keeping the file in a container

Put it on a volume, at a path you choose:

```ts
sqlite: { path: "/data/desk.sqlite" },
```

A new Docker volume takes the owner of the folder it's mounted on, and a folder that isn't in the image belongs to root, so a server that runs as `node` can't write there. Create it in the image, in [the Dockerfile](https://frontmcp.dev/reference/deployment/node#building-a-docker-image), before `USER node`:

```text title="ci/Dockerfile"
RUN mkdir -p /data && chown node:node /data
USER node
```

```bash
docker run --init -v desk-data:/data -e MCP_SESSION_SECRET="$(openssl rand -hex 32)" help-desk
```

A session then survives `docker restart`. The image needs `better-sqlite3` built for its own platform: install dependencies in the image, as that Dockerfile does, rather than copying `node_modules` from another machine.

---

## Troubleshooting

### `Error: Cannot find module 'better-sqlite3'`

The server has `sqlite` set, and the package isn't installed. It logs `Failed to create session store - session persistence disabled` and then exits. `npm install @frontmcp/storage-sqlite`, which installs `better-sqlite3` too.

### `failed to open database at "/data/desk.sqlite": unable to open database file`

The process can't create or write the file, most often a container user without write access to a mounted volume. The server exits, with `SqliteTaskStore: failed to open database at …`. Give the user the folder, as in [keeping the file in a container](#keeping-the-file-in-a-container).

### `Failed to persist session to SQLite { error: 'database is locked' }`

Another process held a write lock on the file for longer than a write waits, `busyTimeoutMs`, 5 seconds by default. The session isn't stored, though the request is served. Find the process that holds the file, raise `busyTimeoutMs`, give each process its own `path`, or move to [Redis](https://frontmcp.dev/reference/deployment/redis) for several. In 1.8.7 the line said `Failed to persist session to Redis`, though the store was SQLite. (Before 1.8.7 the second process failed to open the file at all, with `Failed to create session store - session persistence disabled { error: 'database is locked' }`.)

### `500 Internal Server Error`, and `SqliteDecryptionError` in the log

The stored value was encrypted with another `encryption.secret`. Put the old secret back, or delete the file and let clients start new sessions. (Before 1.8.7 the log said `Unsupported state or unable to authenticate data`.)

### `[--target cloudflare] config incompatible with Cloudflare Workers: … sqlite storage is not supported`

Workers have no file system for SQLite. Remove `sqlite` from the Worker's configuration. See [Cloudflare Workers](https://frontmcp.dev/reference/deployment/cloudflare-workers#storage).

### The file is somewhere unexpected

Without `path`, the file is in `dist/` in development and in `~/.<info.name>/` in production, unless `FRONTMCP_SQLITE_PATH` is set. The startup log says which. Set `path` to be sure.
