ElectricSQL 1.7.8 on Ubuntu 24.04 on Azure User Guide
Overview
This guide covers the deployment and use of ElectricSQL 1.7.8 on Ubuntu 24.04 on Azure using cloudimg Azure Marketplace images.
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 subsequent insert, update and delete as it is committed.
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 it the engine underneath local-first and realtime applications: the client keeps a working copy of exactly the data it needs, and stays current without bespoke websocket plumbing or a hand-written change feed.
Electric is a sync engine, not a database. It is inert without a PostgreSQL configured for logical replication — so this image bundles one, on the same VM, already configured. The appliance syncs the moment it boots, with nothing else to stand up.
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 itself 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. An unauthenticated Electric is therefore an open database, and this image treats it that way. Two independent secrets, both generated on your VM, on its first boot, before anything was 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/healthrequires it. -
Electric's own API secret. Electric checks a
?secret=parameter on/v1/shapeitself. Upstream refuses to start unless you set eitherELECTRIC_SECRETorELECTRIC_INSECURE=true— this image always sets the secret and never setsELECTRIC_INSECURE.
A shape request needs both. 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:3000and PostgreSQL listens on127.0.0.1plus 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 physically 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/healthis deliberately the one unauthenticated route. Upstream keeps it outside its own secret check so load balancers and orchestrators can probe liveness; it returns a single status word and no database data. It is left open here for the same reason.
A note on the licence
ElectricSQL is Apache-2.0. The LICENSE file at the repository root, and the one shipped alongside the sync service itself, are both the full, unmodified Apache License 2.0 text — no Commons Clause, no added restriction, 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 — there is no licence key, entitlement check or feature gate anywhere in it.
cloudimg is not affiliated with, endorsed by, or sponsored by ElectricSQL. "PostgreSQL" is a trademark of the PostgreSQL Community Association.
What is included:
-
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-privilegeelectricrole -
A dedicated 32 GiB data volume at
/srv/electricholding both the database cluster and Electric's shape storage -
nginx terminating TLS on
443with 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
Before you start
You will need an Azure subscription, permission to create a virtual machine, and an SSH key pair. Nothing else — 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 it 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 create \
--resource-group electric-rg \
--name electric-sync \
--image cloudimg: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 placeholder 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 advertise a port that cannot answer.
Connect and read this VM's credentials
First boot writes every secret it generated to a root-only file.
ssh azureuser@YOUR-VM-IP
sudo cat /root/electricsql-credentials.txt
You will see 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
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 it matches the digest it pinned, so the version you read is the version that is actually running. The listening surface is the important part: 443 and 80 are on 0.0.0.0, while 3000 is on 127.0.0.1 and 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 — that is the setting Electric cannot work without, and the one a hand-rolled deployment most often forgets. The cluster sits under /srv/electric, a dedicated 32 GiB volume, so your data is not competing with the operating system disk.
Connect to it as the electric role, which owns its own database and holds REPLICATION but 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 something 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'
Now subscribe. 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-handleidentifies the server-side shape. Hand it back and Electric knows which shape you mean.electric-offsetis 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 it 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 it happened at.
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 also carries an lsn — the exact position in PostgreSQL's write-ahead log where the change was committed — and closes with an up-to-date control message meaning "you have everything, ask again when you like".
That lsn is the proof that this is real logical replication rather than a re-query: Electric is reading PostgreSQL's replication stream, not polling 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"
An active logical slot using the pgoutput plugin, a publication, and your table inside it. Electric created all three for itself the moment 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 columns asked for — total is absent, which is why that last grep looks for it and does not find it. The filter is applied at the engine, so a client that is not entitled to a row or a field never receives it, on the snapshot or on any later change.
This is how you scope sync per tenant or per user: one shape definition per audience, rather than one firehose 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"}
That is the engine refusing to serve stale rows against a table it no longer recognises, which is what 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. The retry loop above is the same behaviour written out by hand.
Proving the security model
Worth doing once on your own VM, because 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; every other credential combination is 401. Then the part that matters: 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 is what makes the two refusals mean something — without a request that does succeed, a refusal only proves the test ran.

Connecting a real client
Everything above is curl, which is the point: the protocol is ordinary HTTP. In an application you would normally use the official TypeScript client, which handles the handle/offset bookkeeping and materialises 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)
})
Because the VM ships a self-signed certificate, a browser will warn on first visit and a Node client will need your CA (or your own certificate installed — see below). Put your own hostname and certificate on the box for anything customer-facing.
Using your own certificate
Replace the two 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. To change the 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 where they always do.
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 materialised log on the data volume. Electric manages that itself — a shape whose table is dropped or altered is cleaned up automatically, and idle shapes are retired — so under normal use there is nothing to do but keep an eye on 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 behaviour 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 simply 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 cloudimg settings live in one file, separate from the stock configuration, so an upgrade never fights you for it.
sudo cat /etc/postgresql/16/main/conf.d/10-cloudimg-electric.conf
Edit it and sudo systemctl restart postgresql@16-main.service. Keep wal_level = logical: 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 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
This image is published and maintained by cloudimg. If something here does not behave as described, contact cloudimg support — support is included with the image, 24/7.