Deployment guide

ElectricSQL

Overview

ElectricSQL is a sync engine for Postgres. Rather than having clients poll for changes, it tails the database's own logical replication stream and hands each client a shape: a filtered slice of a table, described by a query. A client subscribes once, receives the current snapshot, then holds an ordinary long-lived HTTP request that delivers every later insert, update and delete as it is committed.

This guide covers ElectricSQL 1.7.8 on Ubuntu 24.04 on Azure, using the SecureAnyCloud Azure Marketplace image. Electric is a sync engine, not a database, and it is inert without a PostgreSQL configured for logical replication. The image therefore bundles a configured PostgreSQL on the same VM, so the appliance syncs the moment it boots, with nothing else to stand up.

What you get:

  • The ElectricSQL sync service 1.7.8, run unmodified from the official upstream container image, pinned by digest and version-checked at runtime
  • PostgreSQL 16 from the Ubuntu main package set, so the bundled database keeps receiving Ubuntu security updates, pre-configured with wal_level = logical, replication slots and a least-privilege electric role
  • A dedicated 32 GiB data volume at /srv/electric holding both the database cluster and Electric's shape storage
  • nginx terminating TLS on 443 with a per-VM certificate and a per-VM Basic credential
  • Per-VM secrets generated on first boot, written to a root-only file, with the service gated until they exist

Because the interface is plain HTTP with real caching semantics, shapes travel through ordinary proxies and CDNs, and anything that can make an HTTP request can consume one. That is what makes Electric a good engine for local-first and realtime applications: the client keeps a working copy of exactly the data it needs and stays current without custom websocket plumbing or a hand-written change feed.

The shape of the deployment

Port What it is Reachable from

443 nginx. TLS with a per-VM self-signed certificate, and HTTP Basic authentication with a per-VM credential. The only way in. your network (you choose the CIDR)

80 Redirect to 443. your network

3000 The Electric sync service. 127.0.0.1 only

5432 The bundled PostgreSQL 16. 127.0.0.1 and the host-local 172.17.0.1 docker bridge only

22 SSH. your network

The API is small enough to hold in your head:

Route What it does

GET /v1/shape?table=…&offset=-1 Subscribe. Returns the current snapshot plus an electric-handle and an electric-offset.

GET /v1/shape?table=…&handle=…&offset=…&live=true Long-poll. Returns every change committed since that offset, then the next offset.

DELETE /v1/shape?table=… Drop the server-side shape.

GET /v1/health Liveness. Returns {"status":"active"}.

Security by design

The sync API serves the contents of your database, so an unauthenticated Electric is an open database. This image treats it that way. It uses two independent secrets, both generated on your VM at first boot, before anything is listening:

An nginx HTTP Basic credential. nginx is the single public entry point and holds a per-VM username and password. Every route except /v1/health requires it.

Electric's own API secret. Electric checks a ?secret= parameter on /v1/shape itself. Upstream refuses to start unless you set either ELECTRIC_SECRET or ELECTRIC_INSECURE=true. This image always sets the secret and never sets ELECTRIC_INSECURE.

A shape request needs both secrets. Neither is baked into the image, so no two customers share either one.

Beyond the credentials:

The engine and the database are bound where the public interface cannot reach them. Electric is published to 127.0.0.1:3000. PostgreSQL listens on 127.0.0.1 plus the host-local Docker bridge address the container uses to reach it. Neither is a routable address on this VM, which is a kernel-level guarantee rather than a firewall rule you can accidentally widen.

The image ships with no database at all. The PostgreSQL cluster is built on first boot, on the dedicated data volume, with passwords generated for your VM. The upstream default credential is never created and then rotated. It is never created.

Electric cannot start before its own bootstrap. Its service unit is gated on a marker file that first boot writes only once every secret exists, and the image is captured without that marker. If first boot fails, Electric stays down rather than coming up unconfigured.

/v1/health is deliberately the one unauthenticated route. Upstream keeps it outside its own secret check so that load balancers and orchestrators can probe liveness. It returns a single status word and no database data, and it is left open here for the same reason.

A note on the license

ElectricSQL is Apache-2.0. The LICENSE file at the repository root and the one shipped alongside the sync service are both the full, unmodified Apache License 2.0 text, with no Commons Clause, no added restriction and no separate enterprise tree. Every one of the 71 packages in the service's dependency lock is Apache-2.0, MIT or BSD-2-Clause.

The vendor also runs a hosted service, Electric Cloud. The open-source sync engine shipped here is the complete product. It has no license key, entitlement check or feature gate.

SecureAnyCloud is not affiliated with, endorsed by, or sponsored by ElectricSQL. "PostgreSQL" is a trademark of the PostgreSQL Community Association.

Before you start

You need an Azure subscription, permission to create a virtual machine, and an SSH key pair. Nothing else is required, because the image is self-contained.

Decide up front which network is allowed to reach port 443. Anything that holds both secrets can read whatever your shapes expose, so treat the port like a database port rather than a web port.

Deploy the virtual machine

Deploy from the Azure Marketplace listing or from the CLI. Standard_B2s (2 vCPU, 4 GiB) is the recommended size and runs both the sync engine and PostgreSQL comfortably.

az group create --name electric-rg --location eastus
az vm image terms accept --urn sac1647283583153:electricsql:default:latest
az vm create \
  --resource-group electric-rg \
  --name electric-sync \
  --image sac1647283583153:electricsql:default:latest \
  --size Standard_B2s \
  --admin-username azureuser \
  --generate-ssh-keys \
  --public-ip-sku Standard

Network Security Group rules

Open SSH and 443. Replace the example value with your own CIDR. The example is deliberately not 0.0.0.0/0.

az network nsg rule create \
  --resource-group electric-rg --nsg-name electric-syncNSG \
  --name allow-electric-https --priority 1010 \
  --source-address-prefixes '<your-mgmt-cidr>' \
  --destination-port-ranges 443 --protocol Tcp --access Allow

Do not open 3000 or 5432. Neither is listening on a routable address, so a rule for them would expose a port that cannot answer.

Connect and read this VM's credentials

SSH in to the VM. First boot writes every secret it generated to a root-only file.

ssh azureuser@YOUR-VM-IP
sudo cat /root/electricsql-credentials.txt

The file lists both secrets, the database passwords and the URLs for this VM:

ELECTRIC_SECRET=<64 hex characters, generated on this VM> API_USER=electric API_PASSWORD=<40 hex characters, generated on this VM> PG_PASSWORD=<48 hex characters, generated on this VM> PG_SUPERUSER_PASSWORD=<48 hex characters, generated on this VM> ELECTRIC_URL=https://20.172.141.80/ ELECTRIC_HEALTH_URL=https://20.172.141.80/v1/health ELECTRIC_SHAPE_URL=https://20.172.141.80/v1/shape

API_USER and API_PASSWORD are the nginx Basic credential. ELECTRIC_SECRET is Electric's own API secret. A shape request carries both.

Confirm the service is running

Run the following to check the services, the health endpoint and the listening ports:

for s in docker.service postgresql@16-main.service electric.service nginx.service; do printf '%-32s %s\n' "$s" "$(systemctl is-active "$s")" done curl -sk -D - -o /dev/null https://127.0.0.1/v1/health | grep -i '^electric-server' curl -sk https://127.0.0.1/v1/health; echo ss -tlnH | awk '{print $4}' | sort -u | grep -E ':(80|443|3000|5432)$'

All four services report active. The electric-server header is Electric's own version string. This image asserts that it matches the digest it pinned, so the version you read is the version that is running.

The listening surface is the important part. Ports 443 and 80 are on 0.0.0.0. Port 3000 is on 127.0.0.1, and port 5432 is on 127.0.0.1 and the Docker bridge, and nowhere else.

The database Electric is reading

The bundled PostgreSQL 16 lives on the dedicated data volume and is already configured for logical replication. You do not have to change anything to start syncing.

sudo -u postgres psql -tAc 'select version()'
sudo -u postgres psql -tAc 'show wal_level'
sudo -u postgres psql -tAc 'show data_directory' df -h /srv/electric | tail -1

wal_level is logical. Electric cannot work without this setting, and a hand-built deployment most often forgets it. The cluster sits under /srv/electric, a dedicated 32 GiB volume, so your data does not compete with the operating system disk.

Connect as the electric role. It owns its own database and holds REPLICATION, but it is not a superuser:

PGP=$(sudo grep -E '^PG_PASSWORD=' /root/electricsql-credentials.txt | cut -d= -f2-) PGPASSWORD="$PGP" psql -h 127.0.0.1 -U electric -d electric -c '\conninfo'

Your first shape

A shape is a table, optionally filtered. Create a table to sync, then subscribe to it.

PGP=$(sudo grep -E '^PG_PASSWORD=' /root/electricsql-credentials.txt | cut -d= -f2-)
export PGPASSWORD="$PGP" psql -h 127.0.0.1 -U electric -d electric -v ON_ERROR_STOP=1 <<'SQL' DROP TABLE IF EXISTS orders; CREATE TABLE orders ( id int PRIMARY KEY, region text NOT NULL, status text NOT NULL, total numeric(10,2) NOT NULL ); INSERT INTO orders VALUES (1, 'emea', 'placed', 120.00); INSERT INTO orders VALUES (2, 'amer', 'placed', 80.50); SQL psql -h 127.0.0.1 -U electric -d electric -c 'TABLE orders'

Subscribe to the shape. offset=-1 means "I have nothing, send me everything":

IP=$(hostname -I | awk '{print $1}') CRED=/root/electricsql-credentials.txt
AU=$(sudo grep -E '^API_USER=' $CRED | cut -d= -f2-)
AP=$(sudo grep -E '^API_PASSWORD=' $CRED | cut -d= -f2-)
SECRET=$(sudo grep -E '^ELECTRIC_SECRET=' $CRED | cut -d= -f2-)
curl -sk -u "$AU:$AP" -D /tmp/shape.headers \
  "https://$IP/v1/shape?table=orders&offset=-1&secret=$SECRET" | tr ',' '\n' | grep -E 'region|status|total|operation|control' echo '--- the two headers that make this resumable ---' grep -iE '^electric-(handle|offset|schema):' /tmp/shape.headers

The body is a list of messages. Each row arrives as an insert operation with its key and value, and the run ends with a snapshot-end control message. The two headers matter more than they look:

  • electric-handle identifies the server-side shape. Hand it back and Electric knows which shape you mean.
  • electric-offset is your position in the log. Hand it back and Electric sends only what happened after it.

Together they are the whole resumption protocol. There is no session, no socket to keep alive, and nothing to reconnect.

Watching changes arrive

This is the part that makes Electric a sync engine rather than an HTTP view of a table. Subscribe, change the data, and long-poll from your offset. The change comes back through the sync API, tagged with the operation that produced it and the log position where it happened.

IP=$(hostname -I | awk '{print $1}') CRED=/root/electricsql-credentials.txt
AU=$(sudo grep -E '^API_USER=' $CRED | cut -d= -f2-)
AP=$(sudo grep -E '^API_PASSWORD=' $CRED | cut -d= -f2-)
SECRET=$(sudo grep -E '^ELECTRIC_SECRET=' $CRED | cut -d= -f2-)
PGP=$(sudo grep -E '^PG_PASSWORD=' $CRED | cut -d= -f2-)
export PGPASSWORD="$PGP" Q() { psql -h 127.0.0.1 -U electric -d electric -v ON_ERROR_STOP=1 -tAq "$@"; }
# 1. subscribe, and keep our place in the log curl -sk -u "$AU:$AP" -D /tmp/s.h -o /dev/null \
  "https://$IP/v1/shape?table=orders&offset=-1&secret=$SECRET"
H=$(grep -i '^electric-handle:' /tmp/s.h | cut -d: -f2- | tr -d '[:space:]')
O=$(grep -i '^electric-offset:' /tmp/s.h | cut -d: -f2- | tr -d '[:space:]')
echo "subscribed: handle=$H offset=$O"
# 2. a NEW row, committed after we subscribed Q -c "INSERT INTO orders VALUES (3, 'apac', 'placed', 42.00);" curl -sk -u "$AU:$AP" -D /tmp/l1.h -m 40 \
  "https://$IP/v1/shape?table=orders&handle=$H&offset=$O&live=true&secret=$SECRET" echo
O=$(grep -i '^electric-offset:' /tmp/l1.h | cut -d: -f2- | tr -d '[:space:]')
# 3. an UPDATE to a row we already had Q -c "UPDATE orders SET status = 'shipped' WHERE id = 1;" curl -sk -u "$AU:$AP" -m 40 \
  "https://$IP/v1/shape?table=orders&handle=$H&offset=$O&live=true&secret=$SECRET" echo

The first live response carries the apac row with "operation":"insert"; the second carries order 1 with "operation":"update" and its new shipped status. Each response also carries an lsn, the exact position in PostgreSQL's write-ahead log where the change was committed. Each closes with an up-to-date control message meaning "you have everything, ask again when you like".

The lsn shows that this is real logical replication rather than a re-query: Electric reads PostgreSQL's replication stream and does not poll the table.

You can see the other end of that stream from the database itself:

PGP=$(sudo grep -E '^PG_PASSWORD=' /root/electricsql-credentials.txt | cut -d= -f2-)
export PGPASSWORD="$PGP" psql -h 127.0.0.1 -U electric -d electric \
  -c 'SELECT slot_name, plugin, slot_type, active FROM pg_replication_slots' psql -h 127.0.0.1 -U electric -d electric \
  -c 'SELECT pubname FROM pg_publication' psql -h 127.0.0.1 -U electric -d electric \
  -c "SELECT schemaname || '.' || tablename AS published FROM pg_publication_tables"

The output shows an active logical slot using the pgoutput plugin, a publication, and your table inside it. Electric created all three for itself when you first asked for that shape.

Narrowing a shape

Syncing a whole table is rarely what you want. A shape takes a where clause and a column list, so each client receives only the rows and fields it is entitled to:

IP=$(hostname -I | awk '{print $1}') CRED=/root/electricsql-credentials.txt
AU=$(sudo grep -E '^API_USER=' $CRED | cut -d= -f2-)
AP=$(sudo grep -E '^API_PASSWORD=' $CRED | cut -d= -f2-)
SECRET=$(sudo grep -E '^ELECTRIC_SECRET=' $CRED | cut -d= -f2-)
echo '--- only EMEA orders, and only three columns ---'
# A brand-new shape has to be snapshotted, and if the table's schema changed # recently Electric will invalidate the first attempt and ask you to re-subscribe # (see the note below). Both are normal, so subscribe with a bounded retry. for i in $(seq 1 10); do BODY=$(curl -skG -u "$AU:$AP" "https://$IP/v1/shape" \
  --data-urlencode 'table=orders' \
  --data-urlencode "where=region = 'emea'" \
  --data-urlencode 'columns=id,region,status' \
  --data-urlencode 'offset=-1' \
  --data-urlencode "secret=$SECRET") case "$BODY" in *'"region":"emea"'*) break ;; esac sleep 2 done printf '%s' "$BODY" | tr ',' '\n' | grep -E '"(id|region|status|total)"'

Only the emea row comes back, and only the requested columns. total is absent, which is why the final grep looks for it and does not find it. The engine applies the filter, so a client that is not entitled to a row or a field never receives it, on the snapshot or on any later change.

Use this to scope sync per tenant or per user: define one shape per audience, rather than one firehose that everybody filters client-side.

Shapes and schema changes

Electric ties a shape to the table's identity in PostgreSQL, not just its name. If you DROP and recreate a table, or otherwise change its schema, every shape over it is deliberately invalidated, and the log records exactly that:

[warning] Failed to configure publication for relation {16422, {"public","orders"}}: %Electric.DbConfigurationError{type: :schema_changed, message: "Database table \"public.orders\" has been dropped or renamed"}

This is the engine refusing to serve stale rows against a table it no longer recognizes, which is the behavior you want. Clients re-subscribe from offset=-1 and carry on. The official TypeScript client does this for you when it sees the refetch signal, and the retry loop above is the same behavior written out by hand.

Proving the security model

Run this check once on your own VM. It is the difference between "the port is closed" and "I checked".

IP=$(hostname -I | awk '{print $1}') CRED=/root/electricsql-credentials.txt
AU=$(sudo grep -E '^API_USER=' $CRED | cut -d= -f2-)
AP=$(sudo grep -E '^API_PASSWORD=' $CRED | cut -d= -f2-)
SECRET=$(sudo grep -E '^ELECTRIC_SECRET=' $CRED | cut -d= -f2-) C() { curl -sk -o /dev/null -w "%{http_code}\n" -m 15 "$@"; } echo -n 'both secrets, correct : '; C -u "$AU:$AP" "https://$IP/v1/shape?table=orders&offset=-1&secret=$SECRET" echo -n 'no Basic credential : '; C "https://$IP/v1/shape?table=orders&offset=-1&secret=$SECRET" echo -n 'Basic credential, wrong password: '; C -u "$AU:nope" "https://$IP/v1/shape?table=orders&offset=-1&secret=$SECRET" echo -n 'Basic credential, no API secret : '; C -u "$AU:$AP" "https://$IP/v1/shape?table=orders&offset=-1" echo -n 'Basic credential, wrong secret : '; C -u "$AU:$AP" "https://$IP/v1/shape?table=orders&offset=-1&secret=nope" echo echo '--- the engine and the database are not on the routable address ---' echo -n "positive control, nginx on $IP:443 : "; C "https://$IP/v1/health" echo -n "Electric direct on $IP:3000 : "; curl -s -o /dev/null -w '%{http_code}\n' -m 6 "http://$IP:3000/v1/health" || echo 'connection refused' echo -n "PostgreSQL direct on $IP:5432 : " (exec 3<>/dev/tcp/$IP/5432) 2>/dev/null && echo 'CONNECTED' || echo 'connection refused'

The first line is 200, and every other credential combination is 401.

The second half of the output matters most: the same routable address, with the port as the only variable. nginx answers on 443. Electric on 3000 and PostgreSQL on 5432 refuse the connection on that address, because neither is bound to it. The positive control gives the two refusals meaning. Without a request that does succeed, a refusal only proves that the test ran.

Connecting a real client

Everything above uses curl, because the protocol is ordinary HTTP. In an application, use the official TypeScript client, which handles the handle and offset bookkeeping and materializes the shape for you.

npm install @electric-sql/client
import { ShapeStream, Shape } from '@electric-sql/client' const auth = 'Basic ' + btoa(`${API_USER}:${API_PASSWORD}`) const stream = new ShapeStream({ url: 'https://YOUR-VM-IP/v1/shape', params: { table: 'orders', where: "region = 'emea'", secret: ELECTRIC_SECRET, }, headers: { Authorization: auth }, }) const shape = new Shape(stream) shape.subscribe(({ rows }) => { console.log('current rows', rows) })

The VM ships a self-signed certificate, so a browser warns on first visit and a Node client needs your CA or your own installed certificate (see below). Put your own hostname and certificate on the VM for anything customer-facing.

Using your own certificate

Replace the two certificate files and reload nginx. Nothing else refers to them.

sudo cp your-fullchain.pem /etc/electric/certs/electric.crt
sudo cp your-private-key.pem /etc/electric/certs/electric.key
sudo chmod 0644 /etc/electric/certs/electric.crt
sudo chmod 0600 /etc/electric/certs/electric.key
sudo nginx -t && sudo systemctl reload nginx

Operations

Rotating the Basic credential

The htpasswd file holds a single user. Run the following to change its password:

NEW=$(openssl rand -hex 20)
AU=$(sudo grep -E '^API_USER=' /root/electricsql-credentials.txt | cut -d= -f2-) printf '%s:%s\n' "$AU" "$(openssl passwd -apr1 "$NEW")" | sudo tee /etc/nginx/electricsql.htpasswd >/dev/null
sudo chmod 0640 /etc/nginx/electricsql.htpasswd
sudo chown root:www-data /etc/nginx/electricsql.htpasswd
sudo sed -i "s|^API_PASSWORD=.*|API_PASSWORD=${NEW}|" /root/electricsql-credentials.txt
sudo systemctl reload nginx echo 'rotated; new password is in /root/electricsql-credentials.txt'

Logs

Electric logs to the container. nginx and PostgreSQL log to their usual locations.

sudo journalctl -u electric.service -n 30 --no-pager sudo docker logs --tail 20 electric

Restarting

The sync stream survives a restart. Shapes are stored on the data volume and the replication slot persists in PostgreSQL, so clients resume from their offset rather than re-downloading.

sudo systemctl restart electric.service for i in $(seq 1 40); do case "$(curl -sk -m 5 https://127.0.0.1/v1/health)" in *active*) echo 'back up'; break ;; esac sleep 3 done curl -sk https://127.0.0.1/v1/health; echo

Shape storage

Each shape keeps a materialized log on the data volume. Electric manages it: a shape whose table is dropped or altered is cleaned up automatically, and idle shapes are retired. Under normal use, you only need to watch the volume:

sudo du -sh /srv/electric/storage df -h /srv/electric | tail -1

The DELETE /v1/shape route exists but returns 405 on this image, and that is deliberate. Upstream gates shape deletion behind ELECTRIC_ENABLE_INTEGRATION_TESTING, a flag that also relaxes other behavior for its own test suite. Turning it on to gain one convenience endpoint would widen the appliance's surface for no operational benefit, so it stays off:

IP=$(hostname -I | awk '{print $1}') CRED=/root/electricsql-credentials.txt
AU=$(sudo grep -E '^API_USER=' $CRED | cut -d= -f2-)
AP=$(sudo grep -E '^API_PASSWORD=' $CRED | cut -d= -f2-)
SECRET=$(sudo grep -E '^ELECTRIC_SECRET=' $CRED | cut -d= -f2-)
echo -n 'DELETE /v1/shape -> HTTP ' curl -sk -u "$AU:$AP" -X DELETE -o /dev/null -w '%{http_code}\n' \
  "https://$IP/v1/shape?table=orders&secret=$SECRET"
echo 'expected: 405 — shape deletion is an upstream integration-testing feature and is not enabled'

To reclaim shape storage wholesale, stop the service, clear the directory and start it again. Clients re-subscribe from offset=-1. No data is lost, because the shapes are derived from PostgreSQL:

sudo systemctl stop electric.service
sudo find /srv/electric/storage -mindepth 1 -delete sudo systemctl start electric.service for i in $(seq 1 40); do case "$(curl -sk -m 5 https://127.0.0.1/v1/health)" in *active*) echo 'back up'; break ;; esac sleep 3 done
sudo du -sh /srv/electric/storage

Tuning PostgreSQL

The SecureAnyCloud settings live in one file, separate from the stock configuration, so an upgrade does not overwrite them.

sudo cat /etc/postgresql/16/main/conf.d/10-sac-electric.conf

Edit the file, then run sudo systemctl restart postgresql@16-main.service. Keep wal_level = logical, because Electric stops working without it.

Troubleshooting

/v1/health returns {"status":"waiting"} or {"status":"starting"}. Electric is connecting to PostgreSQL. Give it a few seconds. If it persists, check that PostgreSQL is up and that Electric can reach it:

sudo systemctl is-active postgresql@16-main.service
sudo journalctl -u electric.service -n 20 --no-pager

Every shape request returns 401. You are missing one of the two secrets. Both are required: the Basic credential at nginx and ?secret= for Electric. Re-read them:

sudo grep -E '^(API_USER|API_PASSWORD|ELECTRIC_SECRET)=' /root/electricsql-credentials.txt

A shape request returns 400 mentioning the table. Electric adds a table to its publication the first time you ask for it, and to do that the electric role must own the table. Create your tables as electric (as this guide does) rather than as postgres.

Nothing arrives on the live poll. Confirm that the replication slot is active. If it is not, PostgreSQL is not streaming:

PGP=$(sudo grep -E '^PG_PASSWORD=' /root/electricsql-credentials.txt | cut -d= -f2-) PGPASSWORD="$PGP" psql -h 127.0.0.1 -U electric -d electric \
  -c 'SELECT slot_name, active, wal_status FROM pg_replication_slots'

Checking the data volume. Both the database and the shape storage live on the dedicated disk:

df -h /srv/electric
sudo du -sh /srv/electric/postgresql /srv/electric/storage

Support

SecureAnyCloud publishes and maintains this image. If something here does not behave as described, contact SecureAnyCloud support. 24/7 support is included with the image.

Deploy on Azure

Launch ElectricSQL 1.7.8 on Ubuntu 24.04 on Azure with 24/7 support from SecureAnyCloud.

View this image

Need Help?

Our support team is available 24/7.

sales@secureanycloud.com

Deploy on Azure