Cheatsheet
The commands you'll actually use to run pgpipe — install, log in, set up the source, verify, troubleshoot, tear down. Copy-paste friendly. For the full feature description, see the product page.
Install
Single static binary, ~25 MB, no runtime dependencies. The package installs a hardened systemd unit that runs as a dedicated pgpipe system user.
1. Install the package
Debian / Ubuntu (.deb)
# arm64: swap amd64 → arm64
wget https://www.pghorizon.com/downloads/pgpipe/v5.2.0/pgpipe_5.2.0_amd64.deb
sudo apt install ./pgpipe_5.2.0_amd64.deb RHEL / Rocky / Fedora (.rpm)
# aarch64: swap x86_64 → aarch64
wget https://www.pghorizon.com/downloads/pgpipe/v5.2.0/pgpipe-5.2.0-1.x86_64.rpm
sudo dnf install ./pgpipe-5.2.0-1.x86_64.rpm
The package's postinst script creates the pgpipe system user, sets ownership on the config + state directories, and enables the systemd unit. You do not need to run systemctl enable by hand.
2. What got installed
| Path | Purpose |
|---|---|
| /usr/bin/pgpipe | The static binary. |
| /lib/systemd/system/pgpipe.service | systemd unit. User=pgpipe, ProtectSystem=strict, restart on failure. |
| /etc/pgpipe/ | Config directory (mode 0750). The setup wizard saves pgpipe.yaml here on first run. |
| /var/lib/pgpipe/ | Runtime state (mode 0750) — BoltDB checkpoints, JWT secret, TLS cert/key, admin password. |
user/group pgpipe | Dedicated system user — no shell, no home outside /var/lib/pgpipe. |
3. Start it & open the setup wizard
# Start now (the package enabled the unit at install time)
sudo systemctl start pgpipe
sudo systemctl status pgpipe
# Follow structured-JSON logs from the systemd journal
sudo journalctl -u pgpipe -f
# Open http://<host>:8080 — the setup wizard walks you through
# source + destination credentials and writes /etc/pgpipe/pgpipe.yaml. Need the first-run admin password? → Dashboard login section below.
More options? .deb / .rpm packages + Docker quickstart →
Docker quickstart
Spin up two Postgres databases plus pgpipe in one go — fastest way to see replication.
mkdir pgpipe-demo && cd pgpipe-demo
BASE="https://www.pghorizon.com/downloads/pgpipe/v5.2.0/docker"
for f in Dockerfile docker-compose.yml init-source.sql init-dest.sql; do
curl -fsSL $BASE/$f -O
done
curl -fsSL $BASE/pgpipe.example.yaml -o pgpipe.yaml
docker compose up -d --build
docker compose logs -f pgpipe Dashboard login
On first run pgpipe generates a random admin password, prints it once, and persists it to a 0600 file.
# Just the password (always works, even after first run)
docker compose exec pgpipe cat /var/lib/pgpipe/pgpipe-admin.password
# Or grep the full first-run banner from the logs
docker compose logs pgpipe | grep -A 6 "FIRST RUN — DASHBOARD"
# Then open the dashboard and log in as admin
open http://localhost:8080
Setting your own password? Edit pgpipe.yaml → server.auth.password. For production, prefer the PGPIPE_DASHBOARD_PASSWORD env var over a value baked into YAML.
Minimum config (pgpipe.yaml)
The smallest config that runs against your own databases.
pipeline:
name: "prod-replica"
source:
host: "source.example.com"
database: "app"
user: "pgpipe_repl"
password: "${PGPIPE_SOURCE_PASSWORD}"
ssl_mode: "require"
replication:
slot_name: "pgpipe_prod"
publication_name: "pgpipe_prod"
tables:
- { schema: "public", name: "orders" }
- { schema: "public", name: "customers" }
destination:
host: "replica.example.com"
database: "app_replica"
user: "pgpipe_writer"
password: "${PGPIPE_DEST_PASSWORD}"
ssl_mode: "require"
state:
backend: "boltdb"
boltdb:
path: "/var/lib/pgpipe/state.db" Full reference: annotated pgpipe.example.yaml →
Source prerequisites
Run these on the source PostgreSQL before pointing pgpipe at it.
-- 1. postgresql.conf (requires restart for wal_level)
wal_level = logical
max_replication_slots = 10
max_wal_senders = 10
-- 2. pg_hba.conf — allow replication from pgpipe's IP
host all pgpipe_repl 10.0.0.0/8 scram-sha-256
-- 3. Replication role with least privilege
CREATE ROLE pgpipe_repl WITH LOGIN REPLICATION PASSWORD 'redacted';
GRANT USAGE ON SCHEMA public TO pgpipe_repl;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO pgpipe_repl;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT ON TABLES TO pgpipe_repl; Walking through this end-to-end: PostgreSQL Logical Replication, Step by Step →
Start / stop / restart
# Foreground (development)
pgpipe start -c pgpipe.yaml
# Validate config without starting
pgpipe validate -c pgpipe.yaml
# Setup-only (creates publication, slot, schema — useful for CI dry-runs)
pgpipe setup -c pgpipe.yaml
# Under systemd (.deb / .rpm install)
sudo systemctl enable --now pgpipe
sudo systemctl status pgpipe
sudo journalctl -u pgpipe -f
# Under Docker Compose
docker compose restart pgpipe
docker compose logs -f pgpipe Verify replication
-- On the source: slot health + lag
SELECT slot_name, active, wal_status,
pg_size_pretty(pg_wal_lsn_diff(
pg_current_wal_lsn(), confirmed_flush_lsn)) AS lag_bytes
FROM pg_replication_slots;
-- On the source: tables in the publication
SELECT schemaname, tablename
FROM pg_publication_tables
WHERE pubname = 'pgpipe_pub';
-- Compare row counts source vs destination
SELECT count(*) FROM public.orders;
pgpipe also exposes everything as Prometheus metrics at http://<host>:8080/metrics.
Since v2.0.0 the endpoint requires a bearer token by default — pgpipe prints the auto-generated token once on first run.
Set your own via metrics.auth_token or the PGPIPE_METRICS_TOKEN env var, or restore open scraping with metrics.public: true.
# Scrape with the token (v2.0.0+ default)
curl -H "Authorization: Bearer $PGPIPE_METRICS_TOKEN" http://localhost:8080/metrics
# prometheus.yml — scrape job with the bearer token
scrape_configs:
- job_name: "pgpipe"
authorization:
credentials: "<your-token>"
static_configs:
- targets: ["pgpipe-host:8080"] Add / remove tables
Use the dashboard's table picker, or hit the REST API while pgpipe is running.
# Add a table to an active pipeline
curl -u admin:$PASSWORD -X POST http://localhost:8080/api/tables \
-H 'Content-Type: application/json' \
-d '{"schema":"public","name":"new_table"}'
# Remove a table
curl -u admin:$PASSWORD -X DELETE \
'http://localhost:8080/api/tables/public/new_table'
# List configured tables
curl -u admin:$PASSWORD http://localhost:8080/api/tables | jq Diagnose common issues
| Symptom | Likely cause | Fix |
|---|---|---|
wal_status = 'lost' | Slot WAL retention exceeded max_slot_wal_keep_size | Slot is gone — full re-snapshot needed. Raise the limit and resume. |
UPDATE/DELETE not replicating | Table has no PRIMARY KEY and no REPLICA IDENTITY | ALTER TABLE … REPLICA IDENTITY FULL (or add a PK). |
| DDL event blocked or quarantined | The source change is unsupported, ambiguous, or waiting behind a table-level barrier | Review the event in the dashboard, then resolve it manually or through the approved quarantine workflow. |
| Lag growing monotonically | Destination can't keep up (slow disk / locks / network) | Switch write.ordering: parallel, increase batch_size, or scale destination. |
| Source disk filling fast | WAL retained for a slot that isn't catching up | Bring pgpipe back online, or drop the slot if you're decommissioning. |
| Events stuck in DLQ | Constraint violation, type mismatch, or destination row missing | Inspect on the dashboard, fix the destination row, click Retry. |
opening state store: ... read-only file system right after the setup wizard | v1.1.4 wizard wrote a relative state-DB path; ProtectSystem=strict made cwd / read-only | Upgrade to v1.1.4+. On v1.1.4 see the callout below. |
"read-only file system" on first start (fixed in v1.1.4)
If a wizard-saved config on an older systemd install fails on first start with opening boltdb ... read-only file system, it's the relative state-DB path bug. v1.1.4 fixes it; the one-line workaround for older installs is in the release notes.
Upgrade
pgpipe is not (yet) published to a Debian repository, so sudo apt update && sudo apt install --only-upgrade pgpipe won't find an upgrade — there's no source list to pull from. Instead, download the new .deb and install it on top. The package preserves your config and state, keeps service enablement unchanged, and restarts pgpipe only when it was already running.
- 1
Stop pgpipe and snapshot the state DB
Record whether the service is meant to be enabled and running, then stop it before copying BoltDB so the snapshot is coherent. The state backup contains authority evidence and must be protected like a credential.
sudo systemctl is-enabled pgpipe sudo systemctl is-active pgpipe sudo systemctl stop pgpipe sudo cp --preserve=mode,ownership,timestamps \ /var/lib/pgpipe/pgpipe-state.db \ /var/lib/pgpipe/pgpipe-state.db.bak - 2
Download the new .deb and install over the old one
Substitute
X.Y.Zwith the version you're upgrading to (latest isv5.2.0). Apt accepts a local.debthe same way it accepts a remote one, while the package lifecycle replaces the binary without overwriting runtime-created config or state.# arm64: swap amd64 → arm64 wget https://www.pghorizon.com/downloads/pgpipe/vX.Y.Z/pgpipe_X.Y.Z_amd64.deb sudo apt install ./pgpipe_X.Y.Z_amd64.debRHEL / Rocky / Fedora equivalent:
sudo dnf upgrade ./pgpipe-X.Y.Z-1.x86_64.rpm. - 3
Validate the service state
Because this checklist stopped pgpipe for a coherent backup, it remains stopped after installation. Validate the config, then restore only the enablement and running state you recorded in step 1. If you skipped the backup and left pgpipe running, the corrected package lifecycle restarts it automatically. On the first Debian transition from an older affected package, check both values because its old
prermcan erase the prior service state.# Optional but recommended: validate the existing config against the # new binary, then inspect the preserved service state. sudo -u pgpipe -- /usr/bin/pgpipe validate -c /etc/pgpipe/pgpipe.yaml sudo systemctl is-enabled pgpipe sudo systemctl is-active pgpipe # First affected Debian transition only, and only when these # states were intended before the upgrade: sudo systemctl enable pgpipe sudo systemctl start pgpipe - 4
Verify
# Binary reports the new version pgpipe --version # Unit is active and not in restart loop sudo systemctl status pgpipe # Watch the first ~30s of logs — confirms the slot reattached # at the saved LSN and streaming is back sudo journalctl -u pgpipe -f
What gets preserved across the upgrade
| Item | Preserved? | Where |
|---|---|---|
| Configuration | Yes | /etc/pgpipe/pgpipe.yaml — not in the package payload, never overwritten. |
| Replication checkpoints (BoltDB) | Yes | /var/lib/pgpipe/pgpipe-state.db — pipeline resumes from the saved LSN. |
| Dashboard sessions (JWT secret) | Yes | /var/lib/pgpipe/jwt.secret — logged-in browsers stay logged in. |
| Admin password + TLS cert/key | Yes | /var/lib/pgpipe/ — unchanged across upgrades. |
| Source-side slot / publication / triggers | Yes | Live inside PostgreSQL, not on the pgpipe host — untouched by apt. |
| Service state (running vs stopped) | Yes | Enablement and running state are preserved. Check both manually after the first Debian transition from an older affected package. |
Plan for a small lag spike
WAL accumulates on the source slot while pgpipe is stopped for the backup and package swap, then pgpipe drains it after restart. For write-heavy workloads, schedule the upgrade during a low-traffic period and check pgpipe_replication_lag_bytes after restart to confirm it trends back to zero.
Rollback (if the new version misbehaves)
Treat rollback as a coordinated recovery. Stop pgpipe first, preserve the current state directory, and restore the previous package together with the pre-upgrade state snapshot when that release's notes do not explicitly guarantee forward-state compatibility. Do not run an older binary against state already advanced by a newer release merely as a quick test.
sudo systemctl stop pgpipe
sudo apt install ./pgpipe_OLD-X.Y.Z_amd64.deb
# Restore the matching pre-upgrade state only as part of the
# reviewed rollback procedure for that release.
sudo cp /var/lib/pgpipe/pgpipe-state.db.bak /var/lib/pgpipe/pgpipe-state.db
sudo systemctl start pgpipe
pgpipe --version
Every release directory at /downloads/pgpipe/vX.Y.Z/ is kept indefinitely, so an older .deb is always reachable by URL.
Remove pgpipe safely
v5.2 separates Decommission pipeline, Remove software, and Erase local data. They affect different systems and never silently perform one another. If this host ran a pipeline, decommission it first so an abandoned slot cannot retain WAL.
- 1
Preview the database decommission
sudo systemctl stop pgpipe sudo -u pgpipe -- /usr/bin/pgpipe teardown \ -c /etc/pgpipe/pgpipe.yamlThe preview is read-only. It reports the exact source identity, slot, publication, owned objects, preserved objects, and a plan digest without printing credentials.
- 2
Confirm the exact reviewed plan
If the preview says it is ready, use the exact digest and slot name it printed. Omit
--confirm-slotonly when the preview does not request it.sudo -u pgpipe -- /usr/bin/pgpipe teardown \ -c /etc/pgpipe/pgpipe.yaml \ --execute \ --yes \ --operator "admin@example.com" \ --reason "pipeline retired; CHG-1234" \ --confirm-slot pgpipe_orders_slot \ --plan-digest <64-lowercase-hex-characters>Execution rechecks the plan, refuses an active or changed slot, drops the exact inactive slot first, and removes only proven pgpipe-owned source objects. It never terminates PostgreSQL sessions, uses broad
CASCADEdeletion, or changes destination data and safety records. - 3
Choose what happens locally
Keep local data: Remove software
Remove only the package when configuration and state should remain available for recovery or a future reinstall.
# Debian / Ubuntu sudo apt remove pgpipe# RHEL / Rocky / Fedora sudo dnf remove pgpipeBoth commands retain
/etc/pgpipe,/var/lib/pgpipe, external TLS paths, and the stablepgpipeaccount.Discard local data: Erase local data
This is permanent. Back up anything that must survive and complete the database decommission first. Debian has a guarded package purge; RPM requires a digest-bound local preview while the binary is still installed.
# Debian / Ubuntu sudo apt purge pgpipe# RHEL / Rocky / Fedora: preview first sudo systemctl stop pgpipe sudo /usr/bin/pgpipe local-purge # Review the plan, then use its exact digest sudo /usr/bin/pgpipe local-purge \ --execute --yes \ --confirm-database-teardown \ --operator "admin@example.com" \ --reason "pipeline retired; CHG-1234" \ --plan-digest <64-lowercase-hex-characters> sudo dnf remove pgpipeGuarded erasure refuses unknown paths, links, mounts, unsafe metadata, and live service processes. It preserves
/etc/pgpipe-tls, configured external paths, and the stable service account. Do not replace it with broadrm -rfor account deletion. - 4
Treat a refusal as a safe stop
Do not bypass a guard. Correct only the named condition, rerun the read-only preview, and use the new digest. Shared, uncertain, conflicting, or legacy source ownership requires PostgreSQL administrator review; there is no safe automatic execution command for it.
# Service running, slot active, or database plan changed sudo systemctl stop pgpipe sudo -u pgpipe -- /usr/bin/pgpipe teardown \ -c /etc/pgpipe/pgpipe.yaml # RPM local-data plan changed sudo /usr/bin/pgpipe local-purge
Preserved by design
Source application data and uncertain source objects remain. Every destination business table, row, checkpoint, fencing record, and lineage record remains. External TLS paths and the stable pgpipe account remain. Package scripts and local-purge never connect to PostgreSQL.
Docker Compose teardown
For the bundled quickstart, stop the stack and delete its disposable database volumes:
# Stop containers and delete the data volumes
docker compose down -v
# Remove the working directory (Dockerfile, compose, configs)
cd .. && rm -rf pgpipe-demo If the container used an external source, complete steps 1 and 2 with the v5.2 binary and its real configuration before deleting the container. Do not abandon an external replication slot.
Need a hand?
If something here doesn't behave as documented, or you'd rather have us run pgpipe for you, get in touch.