DEV Community

Great Sage
Great Sage

Posted on

Local-first sync on a $5 budget: self-hosting ElectricSQL against your own Postgres

If you've looked at local-first sync at all, you've probably hit the same wall I did: the good
tooling (Zero, Replicache, Electric's own cloud) either wants you to move your data into their
managed service, or wants a chunk of monthly spend before you've even shipped a prototype. I
wanted to try Electric's actual open-source sync engine against a Postgres I already run, on
infra I already pay for.

Turns out that's the easy part — Electric's OSS core is just a Docker image. Here's what it
actually does and how to stand it up.

What Electric actually is

Electric is a read-path sync engine for Postgres. It doesn't replace your database or your
writes — you keep writing to Postgres however you already do (your API, an ORM, whatever). What
Electric does is watch Postgres's logical replication stream and re-serve subsets of your data
to clients in real time, over plain HTTP.

The abstraction is a Shape: you ask for a subset of a table (GET /v1/shape?table=todos),
Electric syncs you the current rows plus a live stream of every insert/update/delete that matches,
keyed by a resumable offset. Clients don't poll — they hold a long-lived connection and get
pushed diffs. On the frontend, TanStack DB's useShape/useLiveQuery hooks turn that stream into
a normal reactive local collection, so your components just read local state and it happens to
stay in sync with Postgres. No loading spinners, no manual refetch-on-mutation code.

The catch, and it's a real one: Postgres needs wal_level = logical. On Railway's managed
Postgres that's one ALTER SYSTEM SET + a restart. If you're on a Postgres you don't control
(some managed providers lock this down), you can't use Electric against it at all — check that
before you invest time.

Running it

The whole self-hosted footprint is one container:

docker run -p 3000:3000 \
  -e DATABASE_URL="postgresql://user:pass@host:5432/db" \
  -e ELECTRIC_SECRET="something-long-and-random" \
  -e ELECTRIC_STORAGE_DIR="/var/lib/electric/persistent" \
  -v electric_data:/var/lib/electric/persistent \
  electricsql/electric:latest
Enter fullscreen mode Exit fullscreen mode

That's it — no separate control plane, no extra database. ELECTRIC_STORAGE_DIR needs a real
volume behind it (Electric caches shape logs there), so don't skip the mount or you'll be
re-syncing everything from scratch on every restart.

I maintain a one-click Railway template that wraps this (disclosure: I get a kickback if you
deploy through it): https://railway.com/deploy/electricsql-1?referralCode=Z1xivh&utm_medium=integration&utm_source=template&utm_campaign=devto
— point DATABASE_URL at your existing Railway Postgres (or any Postgres with logical
replication on), set a secret, done. If you'd rather not go through a referral link, the raw
image above runs identically anywhere Docker does — Railway's just where I happen to already run
my Postgres, so it was the path of least resistance for me.

Wiring up a client

On the frontend, TanStack DB is the least-friction path in:

import { useLiveQuery } from '@tanstack/react-db'
import { electricCollectionOptions } from '@tanstack/electric-db-collection'

const todos = electricCollectionOptions({
  shapeOptions: { url: 'https://your-electric-host/v1/shape', params: { table: 'todos' } },
})

function TodoList() {
  const { data } = useLiveQuery((q) => q.from({ todos }))
  return data.map((t) => <div key={t.id}>{t.text}</div>)
}
Enter fullscreen mode Exit fullscreen mode

Writes still go wherever they already go (your API, direct Postgres, whatever) — Electric only
owns the read path back down to the client.

What it's actually good for, and what it isn't

This is the honest part: Electric is not a general real-time-everything button. It shines when
you have a genuinely large or complex dataset and you want a subset of it live on the client
without hand-rolling websocket fan-out and cache invalidation — dashboards, collaborative tools,
anything with "this view should just always be current." If you only need to push a handful of
events to a handful of connected clients, plain websockets or Postgres LISTEN/NOTIFY is less
infrastructure for the same result.

Resource-wise it's light — the container idles well under 256MB for a small Shape set — but shape
storage grows with how much history you're retaining, so watch ELECTRIC_STORAGE_DIR if you're
syncing high-churn tables.

Top comments (1)

Collapse
 
mads_hansen_27b33ebfee4c9 profile image
Mads Hansen

The “read path only” distinction is especially helpful. The production checklist I’d add is mostly about logical-replication debt: alert on slot-retained WAL bytes and disk headroom, not just time lag; verify replica identity for updates and deletes; test schema changes against active Shapes; and rehearse restart, slot loss, and full re-snapshot behavior. A cheap container can become an expensive outage if a stalled slot quietly retains WAL until Postgres fills its disk.

I’d also keep Shape authorization separate from ELECTRIC_SECRET. Don’t let untrusted clients choose arbitrary tables or predicates. Issue server-generated, tenant-bound Shape URLs or tokens, authorize every subscription, and include the tenant plus policy version in cache keys. Logical replication is not a substitute for row-level access control at the Shape endpoint.

For user-visible correctness, expose both the resumable offset and a source freshness marker, then test high-churn backpressure and reconnect storms. “Reactive” is only useful if the UI can tell current, catching up, and resyncing apart.