Postgres
Point concile at a Postgres database instead of SQLite, with no code changes.
SQLite is our awesome, zero-config default for concile serve. It runs completely from a single
file, so you don't have to worry about installing extra software. But hey, if you prefer using
Postgres, you can easily make the switch! You'll still get the exact same reactive engine and app
code without rewriting a single line, but your data gets tucked away safely in a robust database
that your infrastructure is likely already backing up, replicating, and monitoring for you.
In this guide, we'll walk you through how to turn it on, what happens behind the scenes when it boots up, the inner workings of the engine, our single-writer approach, group commits, and share a few thoughts on performance.
Turning it on
To get started, just point your serve or dev command at a Postgres database using the
--database-url flag. You can also use the CONCILE_DATABASE_URL environment variable. If you
happen to set both, the flag takes priority. If you leave both blank, the system will happily stick
with SQLite.
concile serve --dir concile --database-url postgres://user:pass@host:5432/dbCONCILE_DATABASE_URL=postgres://user:pass@host:5432/db concile serve --dir concileAny connection string that starts with postgres:// or postgresql:// will automatically select
the Postgres backend. Anything else will fall back to SQLite. It is wonderful that this choice
happens at boot time, meaning it is not hardcoded into your application. This works perfectly for
concile dev as well as any compiled concile build binary.
Using Docker Compose with a postgres:16 service
If you are using Docker, you can simply add a postgres service to your docker-compose.yml file.
Then, just swap out the SQLite volume for your CONCILE_DATABASE_URL:
services:
concile:
build:
context: .
target: runner
ports:
- "3000:3000"
volumes:
- ./concile:/app/concile:ro
environment:
CONCILE_ADMIN_KEY: ${CONCILE_ADMIN_KEY}
CONCILE_DATABASE_URL: postgres://concile:concile@postgres:5432/concile
command: serve --dir /app/concile
depends_on:
- postgres
postgres:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_USER: concile
POSTGRES_PASSWORD: concile
POSTGRES_DB: concile
volumes:
- postgres-data:/var/lib/postgresql/data
volumes:
postgres-data:Now, when you run docker compose up, your app will save everything to the postgres-data named
volume instead of the old concile-data one. Everything else from our
Self-hosting guide, like the admin key, the dashboard, and restart
persistence, works exactly the same!
What happens at boot
Every time you boot the engine, it automatically runs setupSchema(). This handy process safely
executes a series of "create if not exists" statements for our internal tables and sequences. Don't
worry, it only touches a fixed set of internal tables (which we explain
below) and never messes with your own app's tables.
This process is perfectly safe to run on every boot. If the database already has these tables set up, the engine will quickly glide past those steps without making any changes.
Once the initial setup is complete, the engine claims a single-writer advisory lock. The very first
time it connects to a database, it also sets up the commit-timestamp sequence so it can pick right
up where it left off (or start fresh at 1). The best part? There is no manual setup required from
you. Just pointing serve at an empty Postgres database is all it takes!
The single-writer invariant
To keep your data safe, only one concile engine can connect to a specific Postgres database at any
given time. When it boots up, the engine secures a pg_advisory_lock, which is tied directly to its
pinned connection. If another serve or dev process tries to connect to the same database, it
will immediately fail and let you know, preventing any accidental data corruption:
$ concile serve --dir concile --database-url postgres://... # already running elsewhere
Error: another Concile engine is already connected to this database (advisory lock held)This setup gives you fantastic single-node durability. It allows you to use Postgres as a powerful,
externally-managed database for one writer. However, it is not designed for clustering or running
multiple engines simultaneously for high availability. If you are interested in multi-node scaling,
be sure to check out the Scaling guide for details on concile serve --fleet.
Known limitations
Single pinned connection, no automatic reconnect. The engine relies on exactly one Postgres connection for its entire lifespan. This connection handles the essential single-writer lock and transaction pinning. If the connection drops due to a network glitch or a database restart, the engine won't reconnect on its own. You will just need to quickly restart the concile process to get things back on track.
An unclean process kill can briefly hold the lock. If the process shuts down gracefully
(SIGTERM), it lets go of the lock instantly. But if it crashes or is forcefully killed
(SIGKILL), the lock might linger for a few seconds until Postgres realizes the session is gone.
If you restart immediately, you might briefly see an error saying "another engine is already
connected."
Keep in mind that these aren't limitations on clustering or high availability. They are simply natural outcomes of our single-node, single-writer design.
Going deeper
The @concile/docstore-postgres package provides PostgresDocStore. Think of it as the Postgres
equivalent to our SQLite adapter. It follows the exact same contract but uses a different database
under the hood. To keep things clean, the core engine never directly imports a Postgres driver.
Instead, PostgresDocStore relies on a simple, driver-independent interface called PgClient for
things like queries, transactions, and locks. We use the same design pattern for SQLite, ensuring
that driver-specific details never leak into the rest of the application.
We actually ship two different implementations of this interface, and the engine automatically picks
the best one for your setup. If you are using Bun, which includes our compiled single binary, it
uses BunSqlClient. This is built on native Bun.SQL and is about 10 to 17% faster per query than
the standard Node driver. If you are on Node or any other runtime, it falls back to NodePgClient,
powered by the popular pg driver. Both options are fully tested and
behave exactly the same, so the rest of the system never needs to worry about which one is running.
These clients also handle some helpful data normalization behind the scenes:
- Postgres timestamp columns (
int8) are automatically converted to JavaScriptbigintvalues instead of strings. - Binary data (
bytea), like index keys and document IDs, seamlessly converts back and forth toUint8Array. - The writer lock and active transactions are securely pinned to a single connection for the
lifetime of the client. This means
BEGINandCOMMITalways happen on the same session, keeping our single-writer rule intact. Reads, on the other hand, are free to use a small pool of extra connections for fast, streaming index scans.
You will be happy to hear that our Postgres adapter doesn't use a traditional schema. The tables,
fields, and indexes you define in your app are never actually converted into Postgres table
structures. Instead, we store everything as data within a few fixed internal tables. These tables
stay exactly the same even as your schema.ts evolves:
documents (table_id, internal_id, ts, prev_ts, value, shard_id) -- one row per document revision
indexes (index_id, key, ts, table_id, internal_id, deleted, shard_id) -- MVCC index entries
persistence_globals (key, value) -- engine metadata KV
client_mutations (identity, client_id, seq, verdict, commit_ts, value_json, error_code, created_at)
client_floors (identity, client_id, pruned_through_seq, updated_at) -- offline outbox receiptsWhen you define a table in your app, it just becomes a table_id. Fields are just keys in the JSON
blob, and indexes are just rows in the indexes table. This means you can add tables, create new
fields, or change indexes without ever needing an ALTER TABLE command or a complex migration file.
It keeps the Postgres adapter simple and focused, saving you from migration headaches.
Reading from this log-based storage is incredibly efficient. We use set-based queries rather than
checking rows one by one. For example, a scan uses a neat SQL trick to instantly find the latest
revision of every document in a single query. Index scans resolve the right document and filter out
deleted rows before limiting the results, ensuring you always get accurate pages. We even check for
conflicts by fetching the latest revisions of multiple documents in one quick database trip.
When you paginate or limit your reads using methods like .paginate(), .take(n), or .first(),
the engine streams the rows using a Postgres cursor instead of loading everything into memory at
once. As soon as the page is full or the limit is reached, it stops fetching. In our tests with
100,000 rows, this approach cut the wait time by 92% and fetched roughly a thousand times fewer
rows. It is a huge win for server performance, not just network bandwidth.
This streaming feature is enabled by default, so you don't need to configure a thing. If you ever
want to turn it off, just set CONCILE_PG_STREAM=0 (or false), and the system will fall back to
standard buffered reads. Don't worry, both methods return the exact same data!
Normally, every commit through the single writer handles its own BEGIN, insert, and COMMIT. On
Postgres, this means a separate disk sync (fsync) for each one. Group commit changes this by
batching multiple concurrent commits from a short time window into one shared transaction. This way,
we only pay the cost of one disk sync across all of them, while still keeping the exact same
atomicity and ordering guarantees for every individual commit.
We have set group commit to be on by default for single-node Postgres and off for SQLite. If you
ever need to change it, you can use the CONCILE_GROUP_COMMIT environment variable. Setting it to
1, true, or yes turns it on, while anything else turns it off.
# Turn it off even for Postgres
CONCILE_GROUP_COMMIT=0 concile serve --database-url postgres://... --dir concile
# Turn it on even for SQLite (though this usually isn't helpful, as explained below)
CONCILE_GROUP_COMMIT=1 concile serve --dir concileWe chose these defaults because the benefits really depend on the storage engine you are using. Here
are the numbers from a real containerized postgres:16 setup with true on-disk syncing enabled:
| clients | group commit OFF | group commit ON | throughput gain | p50 OFF to ON |
|---|---|---|---|---|
| 1 | 1,149 ops/s | 1,164 ops/s | +1% (neutral) | 0.84 to 0.83 ms |
| 8 | 1,213 ops/s | 1,686 ops/s | +39% | 6.55 to 4.50 ms |
| 64 | 1,206 ops/s | 1,907 ops/s | +58% | 52.95 to 33.25 ms |
With just one client, the latency is practically identical. Our system is smart enough to skip batching when there is nothing to batch with. But as concurrency grows, group commit delivers a huge win in both throughput and latency.
On the flip side, we saw about an 8% drop in throughput when running this on SQLite. Since SQLite runs in memory and is CPU-bound with no per-commit disk sync, group commit just adds overhead without any real benefit. That is exactly why we tailor the default setting to your chosen database.
If you are developing locally or running a simple single-node deployment, SQLite is typically your best bet. It requires zero configuration, you do not need to run a separate service, and it is noticeably faster since everything runs in memory without network or disk-sync overhead.
Postgres is a fantastic choice when you need enterprise features like backups, monitoring, and replication managed by your existing infrastructure. It is also required if you plan to scale up to a multi-node fleet later on (see Scaling). Just keep in mind that at this single-node tier, Postgres still uses a single-writer setup. Switching to Postgres will not magically make the transactor multi-writer. If you need to scale your writes, you will want to look at sharding or adding fleet nodes rather than just throwing more threads at a single Postgres connection.
In both databases, write throughput remains fairly constant even as concurrency increases. This is a core part of our optimistic concurrency control design, not a quirk of Postgres. Here is a look at the write throughput on an embedded Postgres setup with an insert workload:
| concurrent clients | SQLite ops/s | SQLite p50 / p99 | Postgres ops/s | Postgres p50 / p99 |
|---|---|---|---|---|
| 1 | 44,516 | 0.019 / 0.042 ms | 4,516 | 0.211 / 0.585 ms |
| 8 | 46,553 | 0.019 / 0.041 ms | 4,617 | 1.643 / 2.678 ms |
| 64 | 46,157 | 0.019 / 1.527 ms | 4,472 | 14.018 / 20.209 ms |
Notice how jumping from 1 to 64 concurrent clients barely changes the operations per second for either database. What does increase is the tail latency, as extra clients have to wait their turn behind the single writer. Postgres is understandably slower here because it has to wait for disk syncs, whereas the engine itself does very little work. This is exactly why group commit makes such a big difference on Postgres but not on SQLite. If your app is pushing more write volume than one Postgres writer can handle, check out our Scaling guide for sharding and fleet options.
We test PostgresDocStore using the exact same conformance suite as our SQLite implementation. Both
SqliteDocStore and PostgresDocStore (powered by an in-process PGlite Postgres for quick,
isolated runs) pass the identical set of DocStore checks. This means the two backends are truly
interchangeable in their behavior, not just similar in theory.
To be extra sure, our automated tests spin up a real postgres:16 Docker container and run the
actual concile serve production binary through its paces. It tests a full cycle: committing a
mutation, broadcasting it via WebSockets, reading it back, and making sure a second serve process
gets safely blocked by the single-writer lock. Finally, it stops the first process and verifies that
a third one can boot up and still see the data.
We hold every deployment path to this standard. We test the real shipped binary, not just an isolated unit test.
Related
- Self-hosting with Docker: The baseline
concile serveanddocker compose upsetup that this backend drops right into. Your admin key, dashboard, and restart persistence all work exactly the same. - Scaling: Learn about
concile serve --fleet, which lets multiple nodes share this Postgres backend for scaled out writes and live failover. - Configuration reference: A complete list of flags and environment
variables, including
CONCILE_DATABASE_URLandCONCILE_GROUP_COMMIT.