This is the multi-page printable view of this section. .
Documentation
-
1: Platform Support
-
2: Quick Start
-
3: Namespace Isolation
-
4: Backup
-
5: Restore
-
6: Replica (read-only standby)
-
7: HA Cluster
- 7.1: Patroni HA
- 7.2: Patroni Dynamic Configuration
- 7.3: Patroni REST API
- 7.4: Extensions in HA Clusters
- 7.5: Patroni Cluster Backup
- 7.6: Patroni Cluster Restore
- 7.7: HA Exec / psql
- 7.8: Example: HA Cluster with a Self-CA MinIO
- 7.9: Generating a Certificate — pg cert
- 7.10: S3 Storage High Availability
-
8: Failover: Replica Promotion
-
9: Self-Cert
-
10: Auto-start on Boot
-
11: Extensions
- 11.1: Extension Catalog
- 11.2: Popular Extensions
-
12: Addons
- 12.1: PgBouncer
- 12.2: Etcd
- 12.3: PgDog
- 12.4: Garage
- 12.5: PostgREST
- 12.6: SeaweedFS
- 12.7: RustFS
- 12.8: HAProxy
- 12.9: Silo
- 12.10: MinIO
- 12.11: Redis
- 12.12: Predixy
- 12.13: pgAdmin 4
- 12.14: Nginx
- 12.15: Grafana
- 12.16: Prometheus
- 12.17: Alertmanager
- 12.18: Gitea
- 12.19: VictoriaMetrics
- 12.20: BeeGFS
- 12.21: VMCluster
- 12.22: JuiceFS
- 12.23: VictoriaLogs
- 12.24: vlagent
- 12.25: VLCluster
- 12.26: ZeroFS
- 12.27: DuckDB
- 12.28: Node_exporter
- 12.29: InfluxDB
- 12.30: Pg_exporter
- 12.31: influx2tsdb
- 12.32: ClickHouse
- 12.33: Kafka
- 12.34: RabbitMQ
- 12.35: RocketMQ
-
13: Clone
-
14: Data Import/Export
-
15: Health Inspection
-
16: Configuration Tuning
-
17: KV Cache
-
18: Toolbox
-
18.1: ZFS Storage
-
18.2: PGMQ Queues
-
18.3: SQLite + Litestream
-
18.4: TimescaleDB
-
18.1: ZFS Storage
-
19: SQL Commands
-
20: Destroy
-
21: Logs
-
22: Administration
Complete guide to managing PostgreSQL databases with pgcli.
PostgreSQL 18 — production ready.
- Quick Start — Install and basic usage
- Backup — Snapshots and pgBackRest management
- Restore — Point-in-time recovery (PITR)
- Replication — Read-only replicas and failover
- Extensions — Install and manage PostgreSQL extensions
- Addons — PgBouncer connection pooler and other addons
- KV Cache — Lightweight Redis alternative for key-value caching
- Auto-start on Boot — Start instances and services automatically after reboot
- Toolbox — Standalone utilities, including ZFS storage management
1 - Platform Support
pgcli drives Podman containers on both Linux and macOS. The difference is how containers get networking:
- Linux runs Podman natively, so containers share the host network stack
(
--network host) and reach each other over127.0.0.1. This is the zero-overhead path and the only one that supports cross-host topologies. - macOS runs Podman inside a
podman machineVM. Host networking there binds to the VM’s loopback, which the Mac cannot see, so pgcli instead joins a shared bridge network (pgcli-net) and publishes each port — the Mac reaches it on127.0.0.1:<port>via gvproxy, and containers reach each other by container name over the bridge. This works for a single host (dev/test); it is not the cross-host HA path.
The table below is the current support matrix. “macOS (single-host)” means fully usable for one machine; features that must advertise a routable address across machines, or that depend on Linux-only container internals, stay Linux only.
Support matrix
| Component / feature | Linux | macOS |
|---|---|---|
Instance lifecycle — pg create / start / stop / restart / destroy / status |
✅ | ✅ single-host |
pg psql / pg exec |
✅ | ✅ |
SQL command reference (pg exec, admin queries) |
✅ | ✅ |
Logs — pg logs |
✅ | ✅ |
Namespace isolation (pg.yaml namespace) |
✅ | ✅ |
Data import / export — pg import / pg export |
✅ | ✅ |
Clone — pg clone (streamed pg_dump | pg_restore) |
✅ | ✅ |
Extensions — pg extension install / list |
✅ | ✅ |
Backup / restore — pg backup / pg restore (pgBackRest) |
✅ | ✅ single-host |
Physical replica — pg replica |
✅ | ✅ single-host |
Failover — pg failover (replica promotion) |
✅ | ✅ single-host |
Auto-start on boot — pg autostart |
✅ systemd user unit | ✅ launchd (after login) |
| Addon — PgBouncer | ✅ incl. cross-host pools | ✅ single-host dev/test |
| Addon — PgDog | ✅ | ✅ single-host dev/test |
| Addon — etcd | ✅ incl. cross-host clusters | ❌ Linux only |
| Addon — HAProxy | ✅ | ❌ Linux only |
| Addon — MinIO | ✅ host network | ✅ bridge, published ports |
| Addon — silo | ✅ host network | ✅ bridge, published ports |
| Addon — rustfs | ✅ host network | ⚠️ code-complete bridge path, untested — treated as Linux only |
| Addon — Redis | ✅ host network | ⚠️ code-complete bridge path, untested — treated as Linux only |
| Addon — Predixy | ✅ host network (single- and cross-host clusters) | ❌ Linux only |
| Addon — Garage | ✅ host network | ⚠️ code-complete bridge path, untested — treated as Linux only |
| Addon — SeaweedFS | ✅ host network | ⚠️ code-complete bridge path, untested — treated as Linux only |
| Addon — pgAdmin 4 | ✅ host network | ⚠️ code-complete bridge path, untested — treated as Linux only |
| Addon — Grafana | ✅ host network | ⚠️ code-complete bridge path, untested — treated as Linux only |
| Addon — Prometheus | ✅ host network | ⚠️ code-complete bridge path, untested — treated as Linux only |
| Addon — Alertmanager | ✅ host network | ⚠️ code-complete bridge path, untested — treated as Linux only |
| Addon — Gitea | ✅ host network | ⚠️ code-complete bridge path, untested — treated as Linux only |
| Addon — nginx | ✅ host network | ✅ bridge, published ports (lightly tested) |
Client — pg mc (MinIO client) |
✅ host network | ✅ bridge (see below) |
HA — Patroni (pg ha) |
✅ incl. cross-host | ❌ Linux only |
Legend: ✅ supported · ✅ note supported with the stated caveat · ⚠️ present but unverified on that platform · ❌ not supported.
Instance features
Everything built on the managed PostgreSQL instance works on both platforms —
lifecycle commands, pg psql / pg exec, logs, namespaces, import/export,
clone, extensions, backup/restore (pgBackRest), physical replicas, and failover.
The macOS bridge path (published ports + container-name DNS) was proven by the
instance and replica/backup paths first; the proxy addons reuse it.
On macOS these are single-host: cross-host replicas and pools that span
machines are a Linux --network host + LAN-addresses feature.
Auto-start on boot
Both platforms are supported, via different services:
- Linux — a systemd user unit.
- macOS — a launchd LaunchAgent. LaunchAgents run at user login, not at system boot, because rootless podman cannot start before someone logs in. Expect containers to come up after you log in, not before. See Auto-start on Boot.
Addons
Addon networking differs per component:
- PgBouncer and PgDog —
pure proxies, supported on macOS for single-host dev/test. On macOS they join
pgcli-netand publish their ports. Client connections are still127.0.0.1:<port>. See each page’s Platform support section for the backend-address caveat (a remote/backend host must be reachable from the Mac, not127.0.0.1). - etcd — Linux only. Members run on host networking and advertise their client/peer URLs into the raft membership list as permanent cluster state; retrofitting the macOS bridge + container-name model would rewrite that state. Kept Linux-only until that is designed in.
- Patroni (
pg ha) — Linux only. Members rely on podman host networking; both root and rootless podman are supported. Thepodman machineVM’s uid mapping does not align with host networking. The commands fail fast on macOS with a clear message. See Patroni HA. - HAProxy — Linux only. It fronts Patroni members over host networking, which the podman machine VM does not provide to containers; the manager fails fast on macOS.
- MinIO — both platforms. Object storage,
standalone or as a distributed erasure-coded cluster across hosts. On Linux it
serves over host networking; on macOS it joins
pgcli-netwith its API and console ports published, so the Mac reaches both on127.0.0.1:<port>(the same path the proxy addons use). The public image is dual-arch (amd64 + arm64). - silo — both platforms. Pigsty’s MinIO fork, so
it shares MinIO’s networking story exactly: host networking on Linux, the
pgcli-netbridge with published ports on macOS. The public image is dual-arch (amd64 + arm64). - rustfs — Linux only, in practice. pgcli runs a
custom wrapper image (
ghcr.io/mars-base/pgcli/pgcli-rustfs) whose entrypoint chowns the bind-mounted data/cert directories to rustfs’s fixed uid 10001 from inside the container, before dropping to the unprivileged user. The wrapper image is built dual-arch, and the Go manager has the same macOS bridge path (published ports) as MinIO/silo, so macOS is expected to work — but this addon’s e2e coverage has only run on Linux, so treat it as Linux only until it is exercised on a Mac. - Redis — Linux, tested; macOS expected. A plain
upstream
docker.io/library/redisimage with no wrapper and no fixed container uid, so it needs none of rustfs’s ownership machinery. On Linux it serves over host networking (the port lands on every interface or just loopback, perlisten); on macOS it joinspgcli-netwith the port published andpg redis-clidialshost.containers.internal— the same bridge path the object stores use, code-complete but not yet exercised on a Mac, so the e2e coverage is Linux-only for now. - Predixy — Linux only. Like HAProxy, it needs
host networking to dial its backend cluster nodes (single- and cross-host,
both verified). The wrapper image is amd64-only — upstream ships an amd64
binary — so an arm64 host needs
--platform linux/amd64emulation, and the macOS podman machine path does not exist. - pgAdmin 4 — Linux, tested; macOS expected. A
plain upstream
docker.io/dpage/pgadmin4image with no wrapper — the image’s own entrypoint chowns/var/lib/pgadminto uid 5050 and drops privileges, so pgcli passes--user 0and never chowns on the host (unlike rustfs). On Linux it serves over host networking onpgadmin_start_port(loopback by default); on macOS it joinspgcli-netwith the port published and itslistenwidened to0.0.0.0— the same bridge path redis/the object stores use, code-complete but not yet exercised on a Mac, so the e2e coverage is Linux-only for now. - Grafana — Linux, tested; macOS expected. A
plain upstream
docker.io/grafana/grafanaimage with no wrapper. The image bakesUSER 472and its entrypoint does no ownership work at all, so pgcli chowns the host data dir to472:0itself before creating the container — viapodman unshare chownunder rootless podman, or a direct recursivechownwhen pgcli runs as root on a rootful host. On Linux it serves over host networking ongrafana_start_port(on every interface by default); on macOS it joinspgcli-netwith the port published and itslistenwidened to0.0.0.0— the same bridge path pgAdmin/redis use, code-complete but not yet exercised on a Mac, so the e2e coverage is Linux-only for now. - Prometheus — Linux, tested; macOS expected.
A plain upstream
docker.io/prom/prometheusimage with no wrapper, the same ownership shape as Grafana (bakedUSER nobody, uid/gid65534, no entrypoint fixup) — pgcli pre-chowns the host TSDB dir viapodman unshare chown 65534:65534under rootless podman, or a direct recursivechownwhen pgcli runs as root on a rootful host. Unlike Grafana it is configured by a bind-mountedprometheus.yml(rendered, or copied from--conf-file) plus CLI flags after the image name, not env vars. On Linux it serves over host networking onprometheus_start_port(on every interface by default) — with no authentication behind that default, so unlike Grafana’s password-guarded0.0.0.0, this exposure is guarded only by the network boundary. On macOS it joinspgcli-netwith the port published — the same bridge path Grafana uses, code-complete but not yet exercised on a Mac, so the e2e coverage is Linux-only for now. - Alertmanager — Linux; macOS code-complete,
untested. Prometheus’s downstream alert router and its near-exact twin: a
plain upstream
docker.io/prom/alertmanagerimage, the same ownership shape (bakedUSER nobody, uid/gid65534, no entrypoint fixup — pgcli pre-chowns the host state dir), configured by a bind-mountedalertmanager.yml(rendered, or copied from--conf-file) plus CLI flags after the image name. Two Alertmanager-specific facts:--cluster.listen-address=is always passed empty so a single instance never opens the HA gossip port (TCP+UDP9094), and unlike Prometheus the unauthenticatedPOST /-/reloadroute cannot be disabled in the image — pgcli reloads via SIGHUP so it never depends on that route, but the route stays open regardless. That sits on top of a sharper exposure than Prometheus’s:Listendefaults to0.0.0.0with no authentication, and reaching the port lets anyone create silences — silently muting every notification the on-call expects. On macOS it joinspgcli-netwith the port published — the same bridge path, code-complete but not yet exercised on a Mac. - Gitea — Linux; macOS code-complete, untested. The
self-hosted Git service runs from the plain upstream
docker.io/gitea/giteaimage (rootful build — the rootless variant’s data layout is incompatible and pgcli never selects it). Ownership is Grafana’s shape with a different number: the daemon drops to the bakedgituser, uid/gid1000(pinned viaUSER_UID/USER_GIDso a host pre-chown cannot drift), no--userflag; pgcli pre-chowns the host/datadir the same way. Config is env-only (GITEA__*folded into the persistedapp.inion every boot) — which is exactly whySECRET_KEY/INTERNAL_TOKENare never injected: a regenerated secret would silently re-key a surviving database on reinstall. The install is no-wizard (INSTALL_LOCK) and the admin is created afterwards through the in-container CLI. Two optional faces beyond the single-port norm:--dsnmoves the metadata to an external PostgreSQL (never created or dropped by pgcli), and git-over-ssh rides the image’s OpenSSHsshd, which runs on every instance (the s6 service is unconditional).--ssh-port Nmakes that sshd the live clone endpoint on host port N; off by default it is just parked on a pool port — but it must be parked somewhere, because on the host network an sshd left on 22 would fight the host sshd forever.Listendefaults to0.0.0.0(a Git host is cloned from other machines), guarded by the generated admin password and closed self-registration. On macOS it joinspgcli-netwith the web port — and, when SSH is enabled, the SSH port — published; code-complete but not yet exercised on a Mac. - BeeGFS — Linux only; macOS installation is
refused. The first multi-container addon: one config entry is one
converged single-host filesystem — three containers (
mgmtd+meta+storage) started in dependency order and managed as a unit. It is the only addon with no bridge path and no bridge fallback: BeeGFS speaks its own TCP/UDP protocol (not HTTP), so the daemons run on--network hostand bind their stock protocol ports directly — and because 8.4’s meta (8005) and storage (8003) have no listen-port override at all, exactly one filesystem can exist per host (guarded, not pooled). No macOS equivalent exists to bridge to: without host networking there is no correct BeeGFS topology, sopg addon install beegfsfails fast on a Mac rather than half-working. Ownership is the inverse of Gitea/Grafana: the images run as root and own their bind-mounted dirs, so pgcli never chowns anything. The client side is a kernel module (DKMS) that cannot be containerised — pgcli deliberately never installs or mounts it and instead prints the exact client config viapg addon beegfs mount-hint. - nginx — both platforms (macOS lightly
tested). The HTTP reverse proxy joins
pgcli-neton macOS with its ports published — the same bridge path the object stores use; it has been exercised there but not under load. The upstream image is multi-arch (amd64 + arm64). pg mc(MinIO client) — both platforms. It runs themcbinary from a throwaway container, with your~/.mc/config.jsonmounted as its own default config path and local file operands (forcp/mirror/diff) mounted at their real paths — on macOS that means under the home directory, the only tree the podman machine shares. On Linux it uses host networking, so a127.0.0.1alias reaches a local addon instance; on macOS the container sits on the bridge, where a127.0.0.1alias is the container’s own loopback — point aliases athost.containers.internal:<port>for a local Mac addon, or a routable address for a remote store.
Confirming your platform
pg start prints the detected platform in its banner, so you can confirm which
path you are on:
2 - Quick Start
Get pgcli up and running in minutes.
Installation
Install pgcli with a single command:
This script will:
- Download the latest pgcli binary for your platform
- Install it to
/usr/local/bin(or~/.local/binif no sudo) - Add pgcli to your PATH
Initialize Configuration
This creates ~/.pgcli/pg.yaml with sensible defaults including:
- A default instance named
default - Data directory at
~/pg/default - Auto-assigned ports starting from 35432
The --backup-dir flag is optional but recommended for production: it puts
backup data and logs on a separate path from instance data, so a data-disk
failure does not cascade into backup loss.
Start Instance
The output shows the connection URL, admin password, and backup status.
Connect to Your Database
Basic Operations
Multi-Instance
Multiple Config Files (Isolation)
For isolated testing environments or one config per project on the same host,
generate a separate config file per environment with a distinct --namespace
and disjoint port ranges.
| Parameter | Default | Meaning |
|---|---|---|
--namespace |
default |
Container name prefix: pgcli-pg-<namespace>-<instance> |
--base-dir |
~/.pgcli |
Base directory for all data paths |
--backup-dir |
(under --base-dir) |
Separate base directory for backup data/log |
--pg-start-port |
35432 |
First PG host port; instances get sequential ports |
--pg-ssh-port |
42201 |
First SSH host port; sequential from here |
Planning tips:
- Always pass an explicit
--namespaceto avoid container name clashes. - Port ranges must not overlap between configs on one host.
- The namespace is baked into container names at creation time. Changing it later requires
pg destroyand re-init.
Interactive psql Session
Inside the interactive psql shell you get:
- SQL queries with history and tab completion
- psql meta-commands (
\dt,\du,\l, etc.) \qto quit
Container Shell
The shell runs as root inside the container, giving full access to:
- PostgreSQL data directory (
/var/lib/postgresql/data) - Configuration files
- Log files (
/var/log/postgresql/) - All system tools and utilities
Next Steps
- Learn about backup and restore
- Set up replication for high availability
- Explore extensions management
- Configure auto-start on boot for production deployments
- Understand administration
3 - Namespace Isolation
Namespaces allow you to create completely isolated environments on a single host, perfect for separating production, development, and testing environments without container name conflicts or port collisions.
What is a Namespace?
A namespace is a prefix applied to all container names within a configuration file. This isolation mechanism ensures that:
- Container names don’t clash: Each namespace gets its own container prefix
- Port ranges are separate: Each config file allocates ports from its own range
- Backup containers are isolated: Each namespace has its own pgBackRest container
- Configuration files are independent: Each environment uses a separate config file
Use Case: Production and Development Environments
A common scenario is running production and development environments on the same server:
This creates two completely isolated environments:
| Environment | Config File | Container Prefix | PG Port Range | SSH Port Range |
|---|---|---|---|---|
| Production | ~/.pgcli-prod/pg.yaml |
pgcli-pg-prod-* |
35432+ | 42201+ |
| Development | ~/.pgcli-dev/pg.yaml |
pgcli-pg-dev-* |
38000+ | 43000+ |
Managing Multiple Environments
Use the -c flag to specify which configuration file to use:
How It Works
Container Naming
With namespace prod and instance app-db:
- Instance container:
pgcli-pg-prod-app-db - Backup container:
pgcli-backup-prod - Network:
pgcli-net-prod(if using separate networks)
Without namespace (or --namespace ""):
- Instance container:
pgcli-pg-default-app-db - Backup container:
pgcli-backup-default
Port Allocation
Each instance in a config file gets sequential ports:
- First instance:
pg_start_port(e.g., 35432) - Second instance:
pg_start_port + 1(e.g., 35433) - And so on…
Same for SSH ports used by pgBackRest.
Configuration Persistence
The namespace and port ranges are saved in the config file:
Best Practices
1. Always Use Explicit Namespaces
Never rely on the default namespace when running multiple configs on one host:
2. Use Disjoint Port Ranges
Ensure port ranges don’t overlap between configs:
Leave headroom for multiple instances within each environment.
3. Namespace is Baked into Container Names
The namespace is embedded in container names at creation time. Changing it later breaks the link:
4. Use Shell Aliases for Convenience
Create aliases to avoid typing -c repeatedly:
Advanced: Multiple Environments with Replicas
You can even set up isolated replication environments:
Each environment maintains its own replication slots, backup stanzas, and data directories.
Troubleshooting
Container Name Conflicts
Cause: Two configs using the same namespace.
Solution: Use different namespaces or destroy the conflicting instance first.
Port Already in Use
Cause: Port ranges overlap between configs.
Solution: Use disjoint port ranges with sufficient spacing.
Instance Not Found After Namespace Change
Cause: Changed namespace in config file after creating instances.
Solution: Either destroy and recreate instances, or revert the namespace change.
Summary
Namespaces provide complete isolation for multiple environments on a single host:
- Separate configs: Each environment gets its own
pg.yaml - Distinct namespaces: Prevents container name collisions
- Disjoint ports: Avoids port conflicts
- Independent operation: Each environment managed separately with
-c
Perfect for running production, development, testing, and staging environments on the same server without interference.
4 - Backup
Snapshots
Snapshot types:
full— Complete backup (default, self-contained)diff— Changes since last full backupincr— Changes since last backup
Shared Backup Container
All instances share a single pgbackrest container; each instance gets its own stanza in the repository.
One repository per config file. The container serves exactly one repository — the local directory under
data_dir, or the singlebackup.repo.s3endpoint when configured — and every stanza in the environment (regular instances and Patroni clusters alike) pushes to it. There is no per-instance or per-cluster repo choice: one pgcli environment therefore backs up to one store. To aim a second store (e.g. a secondpg addon install minio) at a subset of clusters, run a second config (pg config init --namespace …) with its own backup container — worked out end to end in Example: HA Cluster with a Self-CA MinIO.
Backup infrastructure (network, image, directories, config, container) is prepared automatically on pg start; run pg backup setup manually to reinitialize, e.g. after changing the base directory.
S3 Object Storage Repository
Patroni cluster backups and WAL archiving can push to any S3-compatible store
(MinIO, silo, rustfs, garage, AWS S3, …). Configure it under backup.repo.s3
in pg.yaml:
Then re-run setup — it performs the archiving orchestration:
- HTTPS is mandatory. pgBackRest refuses plaintext S3 (upstream won’t
implement it), so the endpoint must serve TLS. pgcli’s own MinIO does via
pg addon install minio --tls— pgcli generates a self-signed CA whoseca.crtpath you put inca_fileabove. (silo and rustfs take--tlsthe same way; garage has no native TLS, so front it with the nginx addon — see Garage.) Publicly-caught endpoints (real AWS S3) just omitca_file; if you truly cannot supply a CA,verify_tls: falseis the escape hatch (no certificate verification). - Fetching the CA from a remote store — no scp. When the MinIO lives on
another machine, get its trust anchor with one TLS handshake instead of
copying files:
pg backup fetch-ca <store-host>:9002pulls it out of the endpoint’s served chain, saves it under<base-dir>/backup/repo-ca/and prints a SHA-256 fingerprint — trust-on-first-use, so cross-check the fingerprint against the store host before feeding the file topg backup setup --s3-ca-file <path>. It covers exactly the two TLS shapes pgcli serves: a MinIO with--tls(the generated leaf+CA chain — the signing root is pulled from the chain) and a MinIO with apg cert-minted BYO self-signed leaf (the leaf is the anchor, and is saved as-is). Publicly-trusted endpoints need noca_fileat all, so this command is pointless there. - Cross-host HA needs no key/CA hand-copy. For a Patroni cluster spread
over several hosts,
ca_fileand the backup SSH public key only have to be set up on one host:pg backup setuppublishes the CA to the cluster’s etcd registry and a joiner withca_fileempty pulls it; each host’s backup public key is likewise published onpg ha createand merged into every member’s authorized_keys bysetup. Only public material (the CA certificate, SSH public keys) enters the registry — never private keys or the S3 secret_key. See the HA doc, “Backing up a cross-host leader”. - Archiving is automated. When setup finds a Patroni member whose archiving
config is stale, it pauses the cluster, recreates members replicas-first
(the recreate restarts PostgreSQL, which postmaster-level
archive_modeneeds), then resumes. Each member’s patroni.yml gets the samearchive_command— one stanza per cluster, members listed aspg*-hostso pgBackRest finds the primary itself — and WAL streams to S3 from then on. - Blast radius. The S3 repo applies only to Patroni cluster stanzas;
pg-managed regular instances keep backing up to the local repo, unchanged.
One-shot flag equivalent (secret_key is best edited into pg.yaml so it never
lands in shell history):
5 - Restore
Point-in-Time Recovery (PITR)
Restore to any point in time after the first backup.
Time formats:
2026-08-26 15:30:00+08:00— with timezone offset2026-08-26 15:30:00+08— timezone hour only2026-08-26 15:30:00Z— UTC2026-08-26 15:30:00— assumed UTC
Recovery workflow: Stop → Restore → Start → WAL replay to target time
Note: After --promote, create a new full snapshot before further PITR.
Patroni cluster
A Patroni HA cluster restores with pg ha restore, the cluster-side twin of
the single-instance command above. It takes the same --time (all formats), and
supports --dry-run, --tail-logs, and --force.
How it works: the cluster is first paused (freeze failover, so no remote
replica is promoted while this host stops its local leader), then its DCS identity
is removed, and one local member re-bootstraps its (emptied) data directory
from the pgBackRest repo at the target time via Patroni’s custom-bootstrap method.
That member comes up on a new timeline and promotes itself to the writable
leader; the other members — this host’s and cross-host ones alike — rejoin it
through the DCS. This needs a stanza with WAL archiving, provisioned by
pg backup setup with an S3 repo (see the HA backup addon).
Differences from single-instance restore:
- It always promotes. There is no read-only “pause, inspect, retry another
time” two-step — the cluster comes up writable at the target. Use
--dry-runto confirm the target before executing. - Leader-locality pre-check. Before touching anything,
pg ha restorereads the current leader from the DCS and checks whether it is one of this host’s local members. On a real run it refuses if the leader is not local (a remote member, or a cross-host one absent from this host’s view);--dry-runonly warns. The reason: the destructive path clears the DCS identity and re-bootstraps a local member, so a leader this host cannot stop would race the bootstrap to re-claim the DCS on the old timeline. Move leadership local first (pg ha switchover/failover) or run on the leader’s host. An empty/unreadable leader does not block the run (it covers the first bootstrap). - No
--promoteflag — promotion is automatic.
Recovery workflow: Pre-flight (leader must be a local member) → pause the cluster → stop local members → remove DCS → wipe + bootstrap one member from the repo → it promotes to leader on a new timeline → remaining members rejoin as replicas.
After the restore: take a fresh full snapshot to re-baseline the new
timeline — pg ha snapshot create app --type full. This usually succeeds
directly: a same-stanza pgBackRest restore keeps the system-id across the
timeline switch, so the first post-restore snapshot does not hit
[051] system-id ... do not match stanza and no stanza-upgrade is needed.
If a replica does not rejoin on its own, rebuild it with
pg ha ctl app -- reinit app-<ns> <member> --force.
6 - Replica (read-only standby)
Create a read-only physical replica of an existing instance. The replica continuously streams WAL from its primary via PostgreSQL physical replication and serves read-only queries — useful for read/write splitting, reporting, or as a warm standby.
What happens
- Pre-flight check — the primary must be running (verified before any config is written; a stopped primary fails with no side effects)
- Register — a new instance entry is added with the primary’s database name and password (see Notes), PITR disabled, and
replica_ofset to the primary - Replication setup — on the primary:
pg_hba.confgainshost replicationentries for loopback and RFC1918 ranges (idempotent)- a physical replication slot
pgcli_r_<name>is created, reserving WAL so the replica can never fall behind WAL recycling
- Base backup —
pg_basebackup -Rcopies the primary’s data directory into the replica’s data dir, writingprimary_conninfo(with password) andstandby.signalso the replica boots in standby mode - Start — the replica container starts and streams WAL continuously
Verify
Destroy
Destroying a replica is a 2-step process:
Step 1 must run before step 2: PostgreSQL refuses to drop a slot that is still being streamed (replication slot is active), so the replica must be destroyed first to close its streaming connection.
Note: If the primary is a local pgcli-managed instance (same host), Step 2 can be skipped —
destroyautomatically cleans up the slot. Step 2 is required when the primary is not accessible from the replica host.
Cross-network replicas
The same-host flow above assumes primary and replica share one server (one podman daemon, one network). For a replica on another host, pgcli runs one command on each side — no SSH, the only cross-machine information is what you pass as parameters:
Getting the primary DSN
If the primary is a pgcli-managed instance, get its connection info with pg status:
Then replace 127.0.0.1 with the primary host’s IP as seen from the replica host (e.g. 10.0.0.20) — the user, password and database are used as-is. Note the primary host must accept TCP connections on that port from the replica host (firewall/security group).
What each side does:
- Primary side (
--replica-host <ip|hostname>): only prepares the primary — nothing is created locally. It appends ahost replication all <addr>entry topg_hba.confand creates the physical slotpgcli_r_<name>, then prints the exact--primary-dsncommand to run on the replica host. IPs get a/32(/128for IPv6) mask; hostnames are written as-is. An IP already inside a managed RFC1918 range (10.0.0.0/8,172.16.0.0/12,192.168.0.0/16) is skipped as redundant. Idempotent — re-running adds no duplicate lines. - Replica side (
--primary-dsn): first verifies the slot exists on the primary (validates connectivity and ordering — running before the primary side fails with an actionable message and no side effects), then registers the instance, runspg_basebackupfrom the DSN over the network (host networking), and starts the standby.
The replica side runs only on the replica host: user, database and password of the replica instance come from the DSN (physical replication copies pg_authid, so the local password must equal the primary’s). The primary name is given with --primary-name and recorded as replica_of; it does not have to exist in the replica host’s config, and -i is not used for the remote primary — if given, it keeps its strict meaning and must reference a real local instance.
Destroy is symmetric, one command per host — in this order:
Step 1 must run before step 2: PostgreSQL refuses to drop a slot that is still being streamed (replication slot is active), so the replica must be destroyed first to close its streaming connection.
Automatic slot cleanup: When a cross-host replica has PrimaryDSN set, destroy automatically attempts to drop the replication slot on the remote primary via DSN. If the primary is reachable, no manual step 2 is needed. replica drop is idempotent — re-running when the slot is already gone succeeds as a no-op.
Non-pgcli primary
The primary does not have to be managed by pgcli — the replica side works against any PostgreSQL server, as long as the primary side has been prepared manually (the slot check only verifies the slot exists, not who created it):
- Allow replication from the replica host in
pg_hba.conf, then reload (SELECT pg_reload_conf()): - Create the physical slot with the exact name
pgcli_r_<replica-name>(the replica side checks this name):Requireswal_level = replica(orlogical) and a user withREPLICATIONprivilege — the DSN user.
Then the replica-side command is unchanged:
On destroy, there is no pgcli on the primary side — after pg destroy -i ro1, drop the slot manually:
If a base backup fails (e.g. network hiccup), destroy the replica and re-run the replica-side command — the slot and hba entry on the primary side remain valid.
Notes
- Read-only — the replica rejects all writes (
cannot execute INSERT in a read-only transaction). To make it writable you would promote it (pg_ctl promote), which is not exposed as a pgcli command yet - Same data, same password — physical replication is a byte-for-byte copy of the primary, including
pg_authid. The replica’s admin password and database name are therefore identical to the primary’s; only container name, port and data directory differ. With--dsn-style connections use the replica’s port - PITR disabled on replicas — a standby archives nothing and is not registered with the pgBackRest backup container; backups run on the primary
- Primary must be running — both for initial creation (
pg_basebackup) and for continuous streaming; if the primary restarts, the replica reconnects automatically (slot showsactive) - Lag display —
replica listlag (now() - pg_last_xact_replay_timestamp()) grows while the primary is idle; it drops back to zero on the next replicated transaction. This is expected idle behavior, not drift - Idempotent start — repeated
pg start -i ro1skips the base backup when the data directory is already initialized
7 - HA Cluster
Patroni is the de-facto standard for PostgreSQL
high availability: it owns each postmaster’s lifecycle, streams replication
between members, and performs automatic failover when the leader is lost.
pgcli exposes Patroni as its own top-level command — pg ha — a distinct mode
rather than a plain pg instance or an addon installed with pg addon install.
Note on placement. Patroni pages used to live under Addons. They are listed here as a standalone HA Cluster section because
pg hais not installed via the addon system — the addon index only mentions it for discovery. The etcd DCS and the HAProxy load balancer remain addon pages (etcd, HAProxy); this section links to them where relevant.
Pages
| Page | What it covers |
|---|---|
| Patroni HA | The pg ha command set: create, status, switchover/failover, pause, cross-host members, passwords, namespace & DCS layout |
| Dynamic Configuration | pg ha edit-config / pg ha ctl — the DCS-backed runtime config, and why it never recreates a container |
| REST API | The per-member Patroni REST API: health checks, leader redirects, what HAProxy probes |
| Extensions in HA | Installing/removing PostgreSQL extensions across a cluster (rolling shared_preload_libraries changes) |
| Cluster Backup | pg backup setup + pg ha snapshot — stanza, WAL archiving to S3, cross-host trust |
| Cluster Restore | pg ha restore — cluster PITR via the custom-bootstrap mechanism, the leader-locality pre-check, the post-restore re-baseline |
| Exec / psql | pg ha exec / pg ha psql — run SQL against the leader or any member directly, no dsn, no container exec |
| Example: HA + self-CA MinIO | A verified end-to-end walkthrough: a cluster backed up to a MinIO serving a bring-your-own cert, in a fully isolated second environment |
| Generating a Certificate | pg cert — self-signed cert with DNS and IP SANs for any TLS you need without a CA (dev/test servers, an internal endpoint, or a MinIO serving a bring-your-own cert): its flags, and how the single self-signed leaf acts as its own trust anchor |
| S3 Storage High Availability | Making the MinIO/silo repo itself fault-tolerant: the SNSD and MNSD modes pgcli exposes and why only those two, plus ZFS under the data directory — single-host raidz, per-node pools in a distributed cluster, heterogeneous nodes |
The typical path
Related
- Addons: etcd (the DCS), HAProxy (a stable client endpoint with optional read/write split), MinIO (the S3 repo the backups live in)
- Restore → Patroni cluster for PITR basics shared with single-instance restores
- Failover: Replica Promotion for the non-Patroni, single-replica promotion path (
pg replica) - Namespace Isolation — scopes, stanzas, and container names are namespace-prefixed
7.1 - Patroni HA
Patroni is the de-facto standard for
PostgreSQL high availability: it manages each postmaster’s lifecycle, streams
replication between members, and performs automatic failover when the
leader is lost. pgcli exposes Patroni as a distinct mode — pg ha — rather than
folding it into the plain pg instance path.
This is a separate mode, not an addon subcommand. Patroni (not pgcli) owns the PostgreSQL process.
pg hamembers do not appear incfg.Instances, share no instance lifecycle code, and are not created bypg create. The one thing it borrows from the addon system is the etcd DCS.
Linux only, like the etcd addon: Patroni members rely on podman host networking. Both root and rootless podman are supported. On macOS the commands fail fast with a clear message.
Ownership boundary
The single most important thing to internalize is who owns what:
| Responsibility | Owner |
|---|---|
Patroni container (run/start/stop/rm), image, patroni.yml, port allocation, passwords, DCS wiring |
pgcli |
postmaster lifecycle, initdb, PostgreSQL config rendering, replication slots, failover |
Patroni |
| switchover / failover / pause / edit-config | pgcli wrapping patronictl in short-lived containers |
pgcli never edits a running PostgreSQL’s config directly, and never runs
initdb — Patroni does both. This is also why the plain PG image’s
docker-entrypoint-initdb.d convention (the admin role / default database)
does not apply here: Patroni bootstraps its own cluster, so the role system
is postgres (superuser), replicator, and rewind_user instead.
Container lifecycle ≠ safe PG restart
Because Patroni is PID 1 inside its container:
pg ha createis re-install semantics (stop + recreate the container). Recreating the leader takes that node fully offline and triggers a failover. Changing a member’s config by re-runningcreateis fine; for dynamic settings preferpg ha edit-config, which never touches the container.pg ha start/pg ha stopare raw container start/stop. Stopping the leader’s container is exactly as disruptive as the node crashing — Patroni will fail over to a replica. For planned maintenance,pg ha pausefirst (it disables auto-failover), then stop the container.- A paused cluster has no automatic failover until resumed.
How It Works
pg ha runs one Patroni container per member. All members of a scope point at
the same DCS (a etcd cluster) which holds the cluster’s dynamic
configuration and leader lock:
- The first
pg ha createfor a scope bootstraps: Patroni runsinitdb, wins the leader race, and becomes the leader. - Every later member automatically
pg_basebackups from the current leader and begins streaming — no flag distinguishes “add” from “join”. pg hacontrol commands (switchover,pause, …) runpatronictlin an ephemeral container against the DCS, so they work even when no member container is running locally.
The DCS is the source of truth: Patroni re-renders each member’s
postgresql.conf/pg_hba.conf from it every loop, so hand-edits on disk are
lost — use pg ha edit-config instead.
Scope and the DCS layout
The <scope> in pg ha create <scope> is the Patroni cluster name — the
identity of one HA cluster. Every command that takes a scope (status,
switchover, failover, pause, ctl, …) names the same cluster; the first
create for a scope bootstraps it, later creates with the same scope add
members. (--member is the per-host node name inside the cluster — one
member per machine.)
In pg.yaml the cluster is keyed by that scope under addons.patroni.<scope>.
What actually lands in etcd
Patroni stores everything under a fixed etcd namespace plus the scope:
- namespace:
/service/— Patroni’s top-level key, not changed by pgcli. - scope: pgcli writes
PatroniScope(scope)intopatroni.yml, which is your scope plus the pgcli namespace suffix. Patroni has no namespace concept of its own (its etcd prefix is the raw scope), so pgcli bakes the suffix into the scope to keep two pgcli namespaces sharing one etcd from cross-talking.
So the full etcd prefix for a cluster is:
The keys below the prefix are Patroni’s own DCS layout. To inspect them, point
pg etcdctl at a running etcd member of the same host/config:
Note the scope shown there carries the namespace suffix, even if you configured the cluster with the bare name.
Install
Prerequisite: a DCS. Either reuse local etcd addon members or point at an external etcd.
pg ha status app renders patronictl list — exactly one Leader row and the
rest Replica … streaming:
With no argument, pg ha status rolls up every cluster: container state, member
count, and each member’s pg=/rest= ports.
Choosing a DCS
Patroni only needs a reachable etcd cluster, so there are two layouts:
-
Co-located — run the etcd members as addons on the same hosts as the Patroni members and pass
--etcd m1,m2,m3. Simplest: one machine per role, no extra hosts. Good for a starter 3-node HA cluster. -
Dedicated DCS hosts — run the etcd cluster on its own (virtual) machines and point every Patroni member at it with
--etcd-endpoints. Each line below runs on a different machine (pgcli is per-host):This is the more available topology: etcd’s quorum survives losing a PG host, and re-imaging a database machine never takes the DCS down with it. The PG hosts only ever talk to the DCS;
--etcd-endpointslists all member client URLs, so Patroni falls through to the next endpoint if one etcd host is down (writes still need the etcd quorum itself — 2 of 3). Keep the endpoint list identical on every PG host.A single endpoint (
--etcd-endpoints 10.0.0.20:2379) does work — that one etcd member serves the whole cluster, and the other two are hidden behind it. But it re-introduces a single point of failure: if E1 goes down, Patroni cannot reach the DCS even though the etcd quorum is healthy, and a leader that fails to renew its lock demotes itself. List every endpoint you actually have.
A dedicated odd-sized etcd cluster (3 or 5) is the production recommendation; co-locating is fine for dev and small footprints.
Commands
| Command | What it does |
|---|---|
pg ha create <scope> --member <m> … |
Register + (re)install one member — recreate = node offline |
pg ha list |
Compact table of all HA clusters (scope, members, DCS, status) |
pg ha status [scope] |
All clusters, or one cluster’s patronictl list |
pg ha switchover <scope> |
Planned leader change (patronictl confirms; --yes to script) |
pg ha failover <scope> |
Promote a replica now |
pg ha pause / resume <scope> |
Disable / re-enable automatic failover |
pg ha edit-config <scope> -- … |
View or patch the dynamic config in the DCS (never recreates a container) |
pg ha start / stop <scope> --member m | --all |
Raw container start/stop (see lifecycle caveat) |
pg ha remove <scope> --member m | --scope-all [--clean-data] [--force] |
Remove member(s); --scope-all also clears the DCS |
pg ha passwords <scope> [--file F] |
Export the stored password set (the --passwords-file format, for other hosts) |
pg ha remote <scope> [--member m] [--ssh-port P] |
Register a member living on another host (typically the cross-host leader) for backup SSH |
pg ha remote remove <scope> --member m |
Undo such a registration (the remote container is untouched) |
pg ha extension install/remove/list/apply |
Install, remove, or list PostgreSQL extensions (see Extensions in HA) |
pg ha exec <scope> "<sql>" [--member m] [--database db] |
Run one-shot SQL against the leader (or a named member) — no dsn, no container exec |
pg ha psql <scope> [--member m] [--database db] [-- <psql-args>…] |
Interactive psql against the cluster, same resolved target |
pg ha ctl <scope> -- <patronictl args…> |
Passthrough to any patronictl command |
Flags after -- reach patronictl verbatim (cobra strips the --), so
pg ha ctl app -- show-config, pg ha ctl app -- topology, and
pg ha edit-config app -- -s synchronous_mode=true --force all work. edit-config --show is a convenience alias for ctl … -- show-config.
Cross-host members
Each host runs its own pgcli managing only that host’s members; the cluster
reassembles through the shared DCS, so two hosts’ pg.yaml files each hold a
partial view of one scope.
Cross-host checklist (all three must line up on every host):
- Ports — auto-assignment is per-host, but
connect_addressis stored in the DCS cluster-wide. Pass--host-port/--restapi-portwith the same value on every host, or replicas can’t reach each other. - Passwords — Patroni’s replication / rewind / REST-API auth is cluster-wide.
Export the first host’s generated set with
pg ha passwords app --file app-passwd.ymland pass--passwords-file app-passwd.ymlto every otherpg ha create. --advertise-host— required for cross-host members; it flips the listen address to0.0.0.0and puts a reachable IP inconnect_address. Left empty, a member is loopback-only. This applies to the first (bootstrap) member too: it becomes the leader, and its loopbackconnect_addressis what every later host would try topg_basebackupfrom — cross-host joins fail forever once the cluster was bootstrapped with the default. If you may ever add a member on another host, pass your LAN IPv4 from the very firstcreate.- Firewall — allow the two ports (PG + REST API) pairwise between members.
pgcli does not validate the peers’ config; pg ha status shows each member’s
connect_address so you can self-check reachability.
Backing up a cross-host leader
Each host’s pg.yaml records only its own members, so this host cannot see a
leader running on another host — yet pgBackRest’s stanza-create and full
backups must connect to the leader.
This now happens automatically: after pg ha create installs a member, it
publishes that member’s pgcli-private ports (SSH / REST API) into an etcd
registry at /pgcli/ha/<scope>/<member> (the topology itself — member name +
host:pgport — Patroni already stores in the DCS). The backup config generators
then run patronictl list for the cluster-wide topology and look up each remote
member’s SSH port in the registry, so pgbackrest.conf / ssh_config include
members on every host, with pg1-host and the SSH HostName pointing at the
remote IP (local members still go via 127.0.0.1). pg ha start/stop/remove,
autostart, and pg ha extension skip members that aren’t local — they belong to
their own host’s pgcli.
Just refresh the backup config:
Cross-host backup trust is now automatic. A cluster-wide stanza lists every
member as a pg*-host, so a full backup / check from any host SSH-probes the
members on all hosts to find the primary — each of those member sshd processes
must therefore accept the initiating host’s backup key, and its
--advertise-host already flipped the listener to 0.0.0.0 (see above) so
cross-host SSH reaches it. pg backup setup
handles this: each host’s create publishes its backup public key into the
same /pgcli/ha/<scope>/<member> registry, setup merges every member’s key
into a per-cluster authorized_keys file, and each member container bind-mounts
that file as an extra AuthorizedKeysFile. Because sshd re-reads it on every
login and the merge rewrites the file in place, a host that joins later is
trusted by the running members without a restart (the file only needs the
one-time recreate to add the mount, which setup does inside the pause window
alongside the archive-config recreate). A member removed with pg ha remove
drops its registry key, so the next merge stops trusting it.
The S3 repository CA is distributed the same way: the host that configured
ca_file publishes the certificate to /pgcli/ha/<scope>/.repo/ca, and a
joiner that leaves ca_file empty pulls it and points its own ca_file at the
pulled copy. When the store itself lives on yet another machine, the first host
gets the CA without any file copy too — pg backup fetch-ca <store>:<port>
pulls it out of the endpoint’s TLS chain (see the MinIO doc). Only public
material — SSH public keys and the self-signed CA —
ever enters the registry; private keys, passwords, and the S3 secret_key never
do (the DCS link is unauthenticated).
Fallback: a remote member created on its host before that host upgraded
pgcli has no registry entry, so the generators fall back to this scope’s base
SSH port for it (every host starts at patroni_ssh_start_port, so the first
member is usually right). When it isn’t, register just that member manually to
override (creates no container):
S3 backups and WAL archiving
Patroni clusters ship with archiving off — full backups alone can never
reach a point-in-time. Enabling the S3 repository
(backup docs, English;
中文) flips it on. Each cluster is one
stanza — pgcli_<scope>, matching the namespaced DCS scope, so two pgcli
namespaces sharing one S3 bucket never collide — whose backup-side config lists
every member as a pg*-host, so pgBackRest finds the primary itself and keeps
working across failovers. pg backup setup renders the identical
archive_command into each member’s local patroni.yml (pgcli never writes
archive GUCs into DCS — that is edit-config’s territory, and Patroni applies
a local value whenever DCS does not manage the key), recreates stale member
containers replicas-first inside a pause window (the recreate is also the
postmaster restart archive_mode needs), and runs stanza-create + check
for the cluster. From then on WAL streams to S3 continuously, from whichever
member holds the leader lock.
Expect a short per-member offline window and a planned leader demotion at
the end — the same recreate semantics as pg ha create.
Passwords
The first pg ha create for a scope generates four credentials — superuser,
replication, rewind, and the restapi basic-auth pair (restapi_user /
restapi_password) — and stores them in pg.yaml under the cluster. You never
pass passwords on the command line: the first member generates them, and
pg ha passwords exports the stored set:
(Without --file the YAML goes to stdout — prefer --file so the secrets stay
out of shell history and scrollback.)
The four roles and their default usernames (fixed by pgcli — you only ever set
the passwords, which default to a random 16-char string per role). Override the
length with --password-length on pg ha create / pg create (8–64; it only
applies to the generate path, not to a --passwords-file or an already-stored set):
| pg.yaml key | Role | Default username | Default password |
|---|---|---|---|
superuser |
PostgreSQL superuser | postgres |
random 16-char, generated |
replication |
replication / streaming | replicator |
random 16-char, generated |
rewind |
pg_rewind role |
rewind_user |
random 16-char, generated |
restapi_user / restapi_password |
Patroni REST API basic-auth | postgres |
random 16-char, generated |
The postgres / replicator / rewind_user usernames are baked into the rendered
patroni.yml (postgresql.authentication); only the passwords are the generated /
--passwords-file part. restapi_user is the one username you can override, via the
passwords file.
For full control (or when you’d rather author the file yourself) use the same
format with --passwords-file:
pg ha create prints where each password came from (generated-and-stored vs. a
file path). The rendered patroni.yml is written mode 0600 because it embeds
all of them.
Auto-start on Boot
Like the other infra addons, a Patroni member’s container can be brought up
after a host reboot — but it only starts the existing container, reading the
patroni.yml already on disk; it never re-renders config or re-creates data.
Members are toggled one at a time (--ha --scope <scope> --name <member>).
Start order relative to the DCS doesn’t matter: Patroni retries until etcd
answers, then re-elects normally. See the autostart page for
the boot-service mechanics.
Logs
Container name is pgcli-patroni-<scope>-<member> (namespace-prefixed when a
namespace is set).
Connecting
pg ha exec and pg ha psql are the zero-config path: pgcli resolves the
leader from the DCS itself, authenticates with the stored superuser password,
and runs psql in a throwaway container — no dsn to assemble, no member
container to enter, and remote members are reachable (plain TCP + scram, so a
replica on another host works too; --member aims at a specific node).
For clients outside pgcli, connect to the leader’s PostgreSQL port. Find the
leader with pg ha status app (the Leader row’s Host is its
connect_address), then point pg psql at it. On a single host with default
loopback-only members, that is 127.0.0.1:<host_port>.
After a failover the leader changes, so a fixed connection string should be avoided unless fronted by a pooler or the Patroni REST API’s leader redirect.
For a stable endpoint that survives failover — and optional read/write separation — put HAProxy in front of the cluster.
Planned leader changes
Patroni owns failover; pg ha wraps the patronictl verbs that move the
leader on purpose. All three take just the scope:
switchoveris the planned, graceful one: the current leader is demoted first, so nothing is in flight when the candidate is promoted. Both members must be healthy and caught up. The old leader automatically rejoins as astreamingreplica a few seconds later (you may briefly see it asstoppedwhile Patroni restarts its postmaster). A new timeline is opened — this is normal, not a split-brain.failoverforce-promotes a replica immediately without a clean handoff from the leader. Use it only when the leader is already gone or you are deliberately discarding it; on a healthy cluster it just causes an unnecessary blip. Reach forswitchoverfor anything planned.pauseturns off automatic failover cluster-wide. Do this before any planned container surgery —pg ha stop --all, apg ha createrecreate, orpg ha extension— so Patroni doesn’t promote a replica out from under you.pg ha resumeputs auto-failover back. (A paused cluster has no automatic failover until resumed.)
These are pure DCS operations — they run patronictl in a throwaway container,
touch no member’s container or data, and work even when some members live on
other hosts. Like every pg ha control command, the scope is resolved to the
namespaced DCS key (app → app-default), so a single-host or cross-host
leader is switched the same way.
Because clients dial the leader’s port, the connection target moves after a switchover — see Connecting and put HAProxy in front if you need one stable endpoint.
pg ha vs. pg replica — which to pick
pgcli has two ways to get a standby:
pg replica + pg failover |
pg ha (Patroni) |
|
|---|---|---|
| Model | Manual: pg replica builds a standby, pg failover promotes it on demand |
Automatic: Patroni keeps N members in sync and self-heals |
| Failover | A human runs pg failover; primary stays down until promoted |
Patroni detects the loss and promotes a replica within ~30s |
| Ownership | pgcli drives postmaster (as with any instance) | Patroni drives postmaster; pgcli owns containers only |
| Best for | Simple read-scaling, single planned promotion, staying on pg-instance tooling |
Zero-RTO availability requirements, unattended failover |
If you need a standby you control by hand, use pg replica. If you need the
cluster to survive a node crash without a human, use pg ha.
Configuration
The rendered patroni.yml per member is derived entirely from pg.yaml under
addons.patroni.<scope>. A typical entry:
Ports come from two independent pools — patroni_start_port (default 35532) for
PostgreSQL and patroni_restapi_start_port (default 39060) for the REST API —
so Patroni members never collide with plain-instance or addon ports.
The DCS-scope key is the scope plus the namespace suffix (Patroni has no namespace concept of its own, so the prefix is baked into the scope to keep two pgcli namespaces sharing one etcd from cross-talking).
Default patroni.yml
The file below is what pgcli renders and mounts read-only into each member’s
container. Passwords are auto-generated at bootstrap time; --passwords-file
lets you supply your own set.
Key points:
scope= Patroni cluster name. Combined withnamespace, it forms the etcd key prefix.etcd3.hosts=host:portonly (nohttp://scheme). Multiple endpoints are comma-separated.bootstrap.dcsvalues are defaults only — they take effect on first bootstrap; afterwards usepg ha edit-config.postgresql.listen: 0.0.0.0is set when--advertise-hostis used (cross-host). Without it, listen is127.0.0.1.- Passwords are generated once and stored in
pg.yaml; exported viapg ha passwords.
Dynamic Configuration
Patroni’s dynamic configuration lives in the DCS (etcd) under /service/<scope>/config.
Every member reads it on each loop (every loop_wait seconds) and applies
changes live — so pg ha edit-config is the right way to tune runtime parameters
without restarting containers.
bootstrap.dcsis one-time. Thebootstrap.dcsblock inpatroni.ymlonly takes effect when the first member of a scope runspg ha create(i.e. the cluster bootstrap). Once Patroni writes the config into the DCS, subsequent changes tobootstrap.dcsin the YAML file are completely ignored — even on re-install (pg ha create). To change dynamic configuration after bootstrap, usepg ha edit-config.The common pitfall: re-running
pg ha createdoes not re-readbootstrap.dcsfrom the YAML — Patroni sees the existingconfigkey in the DCS and uses it directly.
Method Description pg ha edit-config app -- -s key=valueRecommended — pgcli’s standard way pg ha ctl app -- edit-configPassthrough to patronictl, same effect Patroni REST API ( PATCH /config)Requires access to a member’s REST API port
Persistence: changes are written directly to etcd, not to container files.
Container restarts, pg ha start/stop, or even pg ha create (re-install)
do not lose these settings — new members automatically pick up the latest config
from the DCS.
Viewing the current config
This is a convenience alias for pg ha ctl app -- show-config. The output is
the full JSON blob stored in /service/<scope>/config.
Modifying parameters
After a change, all members apply it on their next loop (within loop_wait
seconds). No restart needed.
Common tunable parameters
| Parameter | Default | Description |
|---|---|---|
loop_wait |
10 |
Seconds between leader-loop iterations (lock renewal, DCS updates) |
ttl |
30 |
Leader lock TTL. If the leader fails to renew within this window, replicas trigger failover |
retry_timeout |
10 |
Timeout for DCS/PostgreSQL operations. Must be < ttl - loop_wait to give the leader at least one retry chance |
maximum_lag_on_failover |
1048576 |
Maximum replication lag (bytes) for a replica to be eligible for promotion. Default 1 MB |
synchronous_mode |
false |
Enable synchronous replication (zero data loss, higher latency) |
synchronous_node_count |
1 |
How many synchronous standby nodes (when synchronous_mode=true) |
use_pg_rewind |
true |
Use pg_rewind to rejoin a failed leader (faster than full pg_basebackup) |
use_slots |
true |
Use replication slots (prevent WAL loss when a replica disconnects) |
failover_timeout |
0 |
How long to wait before failover (0 = immediate when leader is lost) |
PostgreSQL runtime parameters can also be set under postgresql.parameters:
These trigger a PostgreSQL reload (or restart, depending on the parameter’s
context). Check pg_hba.conf and postgresql.conf parameter documentation for
which settings require a restart.
What not to edit
- Do not edit
patroni.ymlon disk — it is regenerated frompg.yamlon everypg ha create, and Patroni reads dynamic config from the DCS anyway. - Do not edit
postgresql.confdirectly — Patroni overwrites it each loop from the DCS config. - Do not change
scopeornamespace— these are baked into the DCS key at bootstrap time and cannot be changed without recreating the cluster.
Full parameter reference: Patroni Dynamic Configuration covers every DCS-tunable parameter with defaults, constraints, and examples.
Extensions
Full reference: Extensions in HA Clusters covers the orchestration order, cross-host workflow,
shared_preload_librariesordering, and builtin-only fast path.
Notes
- Linux only (root or rootless). Rootless members run as the host user via
--userns=keep-id; root members chown config and data dirs to postgres (uid 999) so the container can read0600config and write its data dir. pg_hba.confis permissive by design (host all all all scram-sha-256- a
replicationline, pluslocaltrust lines). Rootless podman’s pasta rewrites loopback sources, and Patroni’s ownreplace_pg_hbastep only ever grants its resolved loopback TCP address duringcustom bootstrap— sopostgresql.use_unix_socket/use_unix_socket_replare set to make Patroni connect to its own instance over the unix socket instead, which is unaffected by the rewrite and keeps bootstrap from deadlocking on its own pg_hba. Tightening the allow-list to a fixed set is a planned future refinement — do not expose these ports to untrusted networks yet.
- a
- The DCS (etcd) has its own security caveats — see the etcd page: pgcli-managed etcd runs without TLS or auth.
- No
init.sh/docker-entrypoint-initdb.d. Patroni bootstraps the cluster itself, so theadmin/default-db convention of plain instances does not exist here; usepostgres(superuser) to connect and create roles. use_slots/use_pg_rewindare enabled: Patroni owns replication slots and can rejoin a crashed leader viapg_rewindinstead of a full rebase.
7.2 - Patroni Dynamic Configuration
These parameters are stored in the DCS (etcd) under /service/<scope>/config and
applied to every member of the cluster. Modify them with pg ha edit-config:
Reference: Patroni official docs, Pigsty dynamic config.
bootstrap.dcsis one-time. Thebootstrap.dcsblock inpatroni.ymlonly takes effect when the first member of a scope bootstraps the cluster. Once Patroni writes the config into the DCS, subsequent changes tobootstrap.dcsin the YAML file are completely ignored — even on re-install (pg ha create). Re-runningpg ha createdoes not re-readbootstrap.dcs— Patroni sees the existingconfigkey in the DCS and uses it directly.
| Method | Description |
|---|---|
pg ha edit-config app -- -s key=value |
Recommended — pgcli’s standard way |
pg ha ctl app -- edit-config |
Passthrough to patronictl, same effect |
Patroni REST API (PATCH /config) |
Requires access to a member’s REST API port |
Core timing parameters
| Parameter | Default | Min | Description |
|---|---|---|---|
loop_wait |
10 |
1 | Seconds the main loop sleeps between iterations (lock renewal, DCS updates, state refresh) |
ttl |
30 |
20 | Leader lock TTL in seconds. Effectively the wait time before auto-failover triggers |
retry_timeout |
10 |
3 | DCS and PostgreSQL operation retry timeout. If the DCS or network is down for less than this, Patroni does not demote the leader |
Constraint when changing
loop_wait,retry_timeout, orttl:
Failover parameters
| Parameter | Default | Description |
|---|---|---|
maximum_lag_on_failover |
1048576 |
Maximum replication lag (bytes) for a replica to be eligible for leader promotion |
maximum_lag_on_syncnode |
-1 |
Maximum lag (bytes) before a synchronous standby is replaced by a healthy async one. When ≤ 0, Patroni does not replace unhealthy sync standbys. Set high enough to avoid frequent replacement during high-transaction workloads |
max_timelines_history |
0 |
Max timeline history entries kept in the DCS. 0 = keep all |
primary_start_timeout |
300 |
Seconds the leader has to recover from a failure before failover triggers. 0 = immediate failover on crash detection (may lose transactions with async replication). Max failover time = loop_wait + primary_start_timeout + loop_wait; with 0, just loop_wait |
primary_stop_timeout |
— | Seconds to wait when stopping PostgreSQL (only effective with synchronous_mode). If the stop exceeds this timeout, Patroni sends SIGKILL to the postmaster. ≤ 0 or unset = no effect |
failover_timeout |
0 |
Seconds to wait before triggering failover after the leader is lost. 0 = immediate |
Replication mode
| Parameter | Default | Description |
|---|---|---|
synchronous_mode |
false |
Enable synchronous replication (off / on / quorum). The leader manages synchronous_standby_names; only the last-known leader or a sync standby may run for leader. Guarantees zero data loss at the cost of write unavailability when durability cannot be assured |
synchronous_mode_strict |
false |
When no sync standby is available, refuse to disable sync replication — blocks all client writes to the leader |
synchronous_node_count |
1 |
Number of synchronous standby nodes. Dynamically adjusted as members join/leave. Clamped to the number of eligible nodes |
failsafe_mode |
false |
Enable DCS failsafe mode: when the DCS is unreachable, the leader keeps running instead of demoting itself |
PostgreSQL settings
| Parameter | Default | Description |
|---|---|---|
postgresql.use_pg_rewind |
false |
Use pg_rewind to rejoin a failed leader (faster than full pg_basebackup). Requires data page checksums (--data-checksums at initdb) or wal_log_hints=on |
postgresql.use_slots |
true |
Use replication slots (prevents WAL loss when a replica disconnects). Default on PostgreSQL 9.4+ |
postgresql.pg_hba |
— | Rules for generating pg_hba.conf. Ignored if the PostgreSQL hba_file parameter is set to a non-default value |
postgresql.pg_ident |
— | Rules for generating pg_ident.conf. Ignored if ident_file is non-default |
postgresql.parameters |
— | PostgreSQL GUCs as key-value pairs, e.g. {max_connections: 100, wal_level: "replica", wal_log_hints: "on"}. Many are required for replication to work |
postgresql.recovery_conf |
— | Additional recovery.conf entries for standby configuration (PG12+ handled transparently) |
Set PostgreSQL parameters via the postgresql.parameters key:
Standby cluster
If defined, the cluster bootstraps as a standby cluster that streams from a remote primary.
| Parameter | Description |
|---|---|
standby_cluster.host |
Remote primary address |
standby_cluster.port |
Remote primary port |
standby_cluster.primary_slot_name |
Slot name for replication (optional, defaults to member name) |
standby_cluster.create_replica_methods |
Ordered list of methods to bootstrap the standby leader from the remote primary |
standby_cluster.restore_command |
WAL restore command |
standby_cluster.archive_cleanup_command |
Archive cleanup command for the standby leader |
standby_cluster.recovery_min_apply_delay |
Delay before applying WAL records |
Replication slots
| Parameter | Default | Description |
|---|---|---|
member_slots_ttl |
30min |
How long a replica’s physical replication slot is retained after it shuts down. 0 = delete immediately when the member key expires from the DCS. Only effective on PostgreSQL 11+ |
slots |
— | Permanent replication slots (hash map). Preserved across switchover/failover. Physical slots on PG11+ are created on all nodes and advanced every loop_wait seconds. Logical slots are copied from primary to replicas via restart, then advanced every loop_wait seconds. Requires use_slots: true |
ignore_slots |
— | Slots managed externally that Patroni should not touch (list of attribute sets). Any subset match causes the slot to be ignored |
Permanent slots example
Node-pinned physical slots
For a fixed cluster topology, define a permanent physical slot per node to prevent slot deletion during temporary outages:
Warning: Permanent replication slots are synced from the primary/standby_leader to replicas only. Applications should use them on the leader node. Using permanent slots on replicas causes unbounded
pg_walgrowth across the cluster. Exception: physical slots matching a Patroni member name (created and maintained by Patroni) are synced across all nodes for inter-node replication.
Viewing the current configuration
Or read directly from etcd:
7.3 - Patroni REST API
Patroni exposes an HTTP REST API on each member, providing health check endpoints (for load balancers and Kubernetes probes), monitoring data (including Prometheus metrics), and cluster management operations.
Reference: Patroni official docs, Pigsty REST API.
Finding the REST API
Each member’s REST API port is assigned automatically by pgcli and stored in
pg.yaml under addons.patroni.<scope>.members.<name>.restapi_port. The port
is also embedded in the rendered patroni.yml as restapi.connect_address.
Authentication
pgcli enables basic-auth on the REST API (username postgres, auto-generated
password). But authentication is method-based, not blanket:
| Method | Auth required? | Endpoints |
|---|---|---|
GET / HEAD / OPTIONS |
No | All read endpoints — /, /primary, /replica, /health, /cluster, /config, /metrics, /patroni, … |
POST / PATCH / PUT / DELETE |
Yes | Write endpoints — /failover, /switchover, /config (modify), /reload, /restart, … |
This is by design: read-only health checks stay open so a load balancer or
Prometheus can poll them without credentials, while operations that change
cluster state (POST /failover, PATCH /config) require authentication. The
credentials exist so patronictl and other Patroni members can perform writes.
So a load balancer health check needs no auth:
Write operations do require auth. The password is the same restapi_password
used in patroni.yml’s restapi.authentication section. Export it with:
Health Check Endpoints
All health check endpoints respond with GET requests. Patroni returns a JSON
document describing the node state, along with an HTTP status code. Use HEAD
or OPTIONS instead of GET when only the status code is needed (no response
body).
Primary-only endpoints (200 only on leader)
These return HTTP 200 only when the node is the current leader holding
the leader lock:
| Endpoint | Description |
|---|---|
GET / |
Root — primary health check |
GET /primary |
Alias for / |
GET /read-write |
Alias for / |
GET /leader |
Like / but doesn’t distinguish primary vs standby_leader |
GET /master |
Legacy alias for /leader |
These endpoints are useful for load balancer health checks that should route writes only to the current primary.
Replica-only endpoints (200 only on replica)
| Endpoint | Description |
|---|---|
GET /replica |
Replica health check — 200 when node is running, role is replica, and noloadbalance tag is not set |
GET /replica?replication_state=streaming |
Only 200 when replica is actively streaming (not still catching up via archive recovery) |
GET /replica?lag=<max> |
Only 200 when replication lag is below the threshold (bytes or human-readable: 10MB, 1GB) |
Read-only endpoints (200 on both primary and replica)
| Endpoint | Description |
|---|---|
GET /read-only |
Any running node (primary or replica) |
GET /synchronous / GET /sync |
Sync standby only |
GET /asynchronous / GET /async |
Async standby only |
GET /read-only-sync |
Primary + sync standby |
GET /read-only-quorum |
Primary + quorum standby |
GET /quorum |
Quorum standby only |
PostgreSQL health
| Endpoint | Description |
|---|---|
GET /health |
200 when PostgreSQL is running (regardless of role) |
Kubernetes Probes
| Endpoint | Description |
|---|---|
GET /liveness |
200 if Patroni heartbeat loop is running. 503 if primary’s last heartbeat exceeds ttl seconds, or replica exceeds 2*ttl. Lightweight — no SQL queries. Suitable for livenessProbe. |
GET /readiness |
200 when node is leader, or when PostgreSQL is running, replicating, and within the allowed lag. Accepts ?lag=<max> (default: maximum_lag_on_failover) and ?mode=apply|write (default: apply). Suitable for readinessProbe. |
Monitoring Endpoints
GET /patroni
Returns detailed node status as JSON. Used internally by Patroni during leader election and also useful for monitoring:
Response fields:
| Field | Description |
|---|---|
state |
Node state: running, stopped, starting, etc. |
role |
primary or replica |
server_version |
PostgreSQL version as integer |
xlog.location |
Current WAL position (primary only) |
xlog.received_location |
WAL received from primary (replica only) |
xlog.replayed_location |
WAL replayed (replica only) |
timeline |
Current timeline number |
replication |
Array of connected replicas (primary only) |
cluster_unlocked |
true if no leader lock is held |
pause |
true if auto-failover is paused |
dcs_last_seen |
Epoch timestamp of last successful DCS contact |
patroni.version |
Patroni version |
patroni.scope |
Cluster scope name |
patroni.name |
Member name |
GET /cluster
Returns the full cluster topology — all members with their roles, states, and replication status:
GET /config
Returns the current dynamic configuration stored in the DCS:
This is equivalent to pg ha edit-config <scope> --show.
GET /history
Returns the timeline history. Empty array [] when no timeline switch has
occurred (fresh cluster).
GET /metrics
Returns monitoring data in Prometheus exposition format, suitable for scraping by Prometheus or compatible systems:
Key metrics:
| Metric | Description |
|---|---|
patroni_primary |
1 if this node is the leader |
patroni_replica |
1 if this node is a replica |
patroni_postgres_running |
1 if PostgreSQL is running |
patroni_postgres_streaming |
1 if PostgreSQL is streaming (replica) |
patroni_xlog_location |
Current WAL position (primary only) |
patroni_xlog_received_location |
WAL received (replica only) |
patroni_xlog_replayed_location |
WAL replayed (replica only) |
patroni_cluster_unlocked |
1 if no leader lock |
patroni_is_paused |
1 if auto-failover is disabled |
patroni_pending_restart |
1 if node needs restart |
patroni_postgres_timeline |
Current timeline |
patroni_dcs_last_seen |
Epoch of last DCS contact |
patroni_server_version |
PostgreSQL version |
Tag-based Filtering
Health check endpoints accept query parameters to filter by custom tags defined
in the member’s patroni.yml tags section:
Practical Patterns
Load balancer routing
Because GET health checks need no auth, a load balancer can poll the REST API
endpoints directly. The pattern (from Patroni’s example haproxy.cfg) is: the backend TCP port
is the PostgreSQL port, but the health check hits the REST API port with
GET /, which returns 200 only on the leader.
Single read-write endpoint (all traffic → current leader):
check port 8008/8009— the HTTP health check goes to the REST API port, not the PG port.GET /returns 200 only on the leader → HAProxy marks only the leader asUP.on-marked-down shutdown-sessions— on failover, sessions to the old leader are killed so clients reconnect to the new leader.fall 3/rise 2withinter 3s— ~9s to mark down, ~6s to mark up.
Read/write split — add a second listener that uses GET /replica (200 only on replicas) for read traffic:
app_rw(:5000) —GET /→ only the leader isUP→ writes land on the leader.app_ro(:5001) —GET /replica→ only replicas areUP→ reads spread across replicas.
The same GET /replica?lag=1MB refinement (from the replica endpoints above)
keeps a lagging replica out of the read pool.
Monitoring with curl
Quick health check script:
Prometheus scrape config
GET /metrics needs no auth, so the scrape config works without credentials:
7.4 - Extensions in HA Clusters
Installing PostgreSQL extensions in a Patroni-managed HA cluster is
fundamentally different from a single-node instance. This page explains why,
and walks through the pg ha extension command subtree that handles it.
Why not pg extension install?
The single-node flow (pg extension install <instance> <ext>) works by:
- Building a
-extderived image with Pigsty packages - Stopping and recreating the container from the new image
- Editing
postgresql.confto setshared_preload_libraries - Running
CREATE EXTENSIONinside the container
In a Patroni cluster, steps 2-4 all break:
- Patroni is PID 1. Recreating a container takes that node offline. If it’s the leader, Patroni triggers a failover — the cluster reshuffles while you’re mid-install.
- Patroni regenerates
postgresql.confevery loop from the DCS. Any direct edit to the file is overwritten within seconds.shared_preload_librariesmust be set viapatronictl edit-config(which writes to the DCS). CREATE EXTENSIONmust run on the leader, which may be on a remote host — not inside any local container.
pg ha extension orchestrates all of this correctly.
How It Works
The install flow follows a specific order to avoid the pitfalls above:
The critical ordering is resume before edit-config: a paused Patroni cluster
does not apply edit-config changes. If you edit-config while paused, the
shared_preload_libraries update is silently lost.
Builtin-only fast path
If all requested extensions are builtin (contrib extensions like hstore,
uuid-ossp that ship with PostgreSQL), steps 2-4 are skipped entirely — no
image build, no container recreate, no pause/resume. The flow goes straight to
edit-config + CREATE EXTENSION.
Commands
Install
Builds the -ext image, pauses the cluster, and recreates local member containers.
- Single-host cluster (all members local): automatically runs apply (resume + edit-config + CREATE EXTENSION)
- Cross-host cluster: only performs pause + recreate, cluster stays paused. Run install on each host separately, then run
pg ha extension applyon any host to complete the installation.
Extensions are passed as a comma-separated list:
Flags:
| Flag | Default | Description |
|---|---|---|
--database |
postgres |
Target database for CREATE EXTENSION |
--auto-restart |
false |
Skip confirmation for the rolling restart triggered by edit-config |
⚠
--databaseaffects all extensions. When you specify--database, theapplyphase runsCREATE EXTENSION IF NOT EXISTSfor all installed extensions (not just the newly added ones) in the target database. For example, if the cluster has[pg_cron, pg_stat_statements, pgvector]installed, runningpg ha extension install app hstore --database mydbwill create all four extensions inmydb. The extension.sofiles are already in the image and won’t be reinstalled — only the SQL objects (functions, types, etc.) are registered in the target database.If you only want to enable an existing extension in a specific database, there’s no need to reinstall. Simply connect to that database with psql and run
CREATE EXTENSION IF NOT EXISTSdirectly.
Remove
Runs DROP EXTENSION on the leader, then updates shared_preload_libraries
via patronictl edit-config (triggers a rolling restart). Does not rebuild
the image or recreate containers — the -ext image only grows; disk reclamation
is rare and manual.
Extensions are passed as a comma-separated list:
List
Shows three views of the cluster’s extensions:
- Config (pg.yaml): the
extensionslist stored inpg.yaml - DCS (preload): the
shared_preload_librariesvalue frompatronictl show-config - Leader (installed): actual extensions from
pg_extensionon the leader
Apply
Manually triggers the second half of the install flow: resume the cluster,
run patronictl edit-config, and execute CREATE EXTENSION on the leader.
Used in cross-host clusters — after running install on each host (each
builds the image and recreates its local members), run apply once on any host
to complete the DCS update and extension creation.
⚠
--databaseaffects all installed extensions.applyrunsCREATE EXTENSION IF NOT EXISTSfor all extensions in the cluster in the specified database, not just the newly added ones.
Cross-Host Workflow
In a cross-host cluster, each host manages only its own members. The extension installation workflow splits into two phases with global pause/resume — the cluster is paused once and resumed once, not per-host:
Phase 1 — per host (sequentially): Run pg ha extension install on each host.
Each host:
- Builds the
-extimage locally (merging DCS’s existing preload list to include all packages) - Pauses the cluster (idempotent — second host won’t fail on “already paused”)
- Recreates its own local members from the new image (replicas first, leader last)
- Saves config — cluster stays paused, no resume
Recommended order: start from replica hosts. If the host with the leader runs
installfirst, recreating the leader triggers a failover (leader moves to another host). Starting from replica hosts keeps the leader in place until the very end, minimizing failover-related data sync overhead.
Phase 2 — once, on any host: Run pg ha extension apply to:
- Resume the cluster (idempotent — tolerates “not paused”)
- Update
shared_preload_librariesviapatronictl edit-config - Wait for the rolling restart
- Run
CREATE EXTENSIONon the leader
Note: After
install, the cluster is in paused state (auto-failover disabled). If you forget to runapply, runpg ha extension applyor manuallypatronictl resume <scope>to re-enable failover.
For single-host clusters (all members local), install automatically runs
the apply step — no separate command needed.
About leader drift: Leader drift cannot be completely avoided at this time. When the container on the host where the leader resides is recreated, the Patroni process stops. After the leader key in the DCS expires (TTL), even if the cluster is paused, other replicas will still detect the expired key and trigger a new election. The
replicas-firstrecreation order ensures that the host with the leader is the last one to runinstall, but it cannot prevent the leader drift itself. This is an inherent limitation of Patroni + container recreation.
The extensions Config Field
Extensions are tracked at the cluster level in pg.yaml, under the Patroni
cluster config:
This list is the source of truth for what pg ha extension list reports and
what apply installs. Both install and remove update it automatically.
shared_preload_libraries Ordering
Some extensions must appear at position 0 in shared_preload_libraries
(PostgreSQL fatals if they’re not first). pgcli tracks this as a catalog
attribute (PreloadFirst) — currently set on:
citus— distributed PostgreSQL, must be loaded before anything elsetimescaledb— time-series engine, same constraint
Additional extensions may require companion DCS parameters:
pg_cronneedscron.database_name
pg ha extension handles all of this automatically:
- Extensions with
PreloadFirstare always placed at the start of the CSV, regardless of input order - When
pg_cronis present,cron.database_nameis set to the--databasevalue (defaultpostgres) - When
pg_cronis removed,cron.database_nameis cleared
Examples
Install pg_stat_statements and pg_cron
This builds an -ext image, recreates all members (with pause/resume), sets
shared_preload_libraries=pg_stat_statements,pg_cron and
cron.database_name=mydb in the DCS, then creates both extensions in mydb.
Install Citus (must be first in preload)
Even though citus is listed second, the preload CSV is generated as
citus,pg_stat_statements — extensions with PreloadFirst are always
placed at position 0.
Remove an extension
Drops pg_cron from the leader, removes it from shared_preload_libraries,
and clears cron.database_name. Triggers a rolling restart.
Check what’s installed
Limitations
- Image rebuild is additive. Removing an extension does not rebuild the
-extimage or shrink it. To reclaim disk, manually prune old images withpodman image prune. - No per-member extension list. Extensions are cluster-wide — all members
share the same
-extimage and the sameshared_preload_libraries. CREATE EXTENSIONtargets one database. PostgreSQL extensions are per-database. To install in multiple databases, re-run with--databasepointing at each one.- Cross-host requires manual coordination. Each host must run
installbeforeapplyis run once. pgcli does not SSH into remote hosts.
See Also
- Patroni HA — cluster setup, commands, and architecture
- Extensions — single-node extension management and the Pigsty catalog
- Patroni Dynamic Configuration — DCS parameters reference
7.5 - Patroni Cluster Backup
This page walks the complete procedure for backing up a Patroni HA cluster:
getting the backup infrastructure up with pg backup setup, taking
full/incr/diff backups with pg ha snapshot, and one operational pitfall — when a
replica’s Replay LSN stalls and pg ha status shows members disagreeing on LSN,
how to pull it back with patronictl reinit. Command and mechanism details live in
Patroni HA and Backup;
this page strings them into a path you can run end to end.
Model: one stanza per cluster, the backup container finds the primary
A Patroni cluster (scope) maps to one pgBackRest stanza: pgcli_<scope>
(matching the namespace-qualified DCS scope, so two pgcli namespaces sharing one S3
bucket never collide). The stanza is cluster-wide — the backup-side config lists
every member as a pg*-host, and pgBackRest SSH-probes them, locates the current
primary itself, and keeps following it across failovers with no config change.
Therefore:
- There is no per-member backup.
full/incr/diffall operate on the one cluster stanza; pgBackRest snapshots whatever host is the leader at that moment. - Backups run from the shared backup container, reaching the primary over SSH.
Cross-host members are reachable too (trust is wired up automatically by
setup). pg ha snapshotcommands never need you to name the leader — pass the scope; pgBackRest resolves the primary.
Prerequisite: pg backup setup
One idempotent run brings up the whole backup path and gives the cluster archiving:
What setup does (when an S3 repo is configured, it first runs an endpoint preflight — a plain TCP dial that fails the run immediately on a mistyped host or a store that is down, rather than after the image pull / container start buries the real cause):
- Build/pull the pgbackrest image, create the network and dirs, generate the
backup-container
pgbackrest.confand the member-local archive viewpgbackrest-archive.conf. - Start the shared backup container, then verify repository connectivity: a
pgbackrest repo-lsproves the whole stack (TLS, credentials, bucket) and maps a failure to an actionable hint — a cert error points atpg backup fetch-ca, access-denied at the credentials, a refused/timeout connection at the endpoint. - Wire up cross-host backup trust: each host publishes its backup public key
into the cluster’s etcd registry at
pg ha create;setupmerges every member’s key into one per-clusterauthorized_keysbind-mounted into each member container. sshd re-reads that file on every login and the merge rewrites it in place, so hosts that join later are trusted without a restart. The S3 repo CA follows the same path: a host withca_filepublishes it into the registry, joiners with it empty pull it. (Details: HA → Backing up a cross-host leader.) - Enable WAL archiving: when a member’s archive config is stale, pause the
cluster, recreate stale members replicas-first / leader last (a recreate is exactly
the postmaster restart
archive_modeneeds), resume, and render the samearchive_commandinto every member’spatroni.yml. - Run
stanza-create+checkfor every cluster stanza.
HTTPS is mandatory. pgBackRest rejects plaintext S3. A MinIO meant to receive cluster archives must serve TLS — install it with
pg addon install minio --tls(pgcli’s self-signed CA; point--s3-ca-fileat itsca.crt). When the MinIO is on another machine, pull its CA with one TLS handshake —pg backup fetch-ca <store-host>:<port>— no scp needed (see MinIO → Getting the CA onto a remote host).
Confirm after setup:
Once the backup container is Up and the stanzas are ready, start backing up.
Create members after setup, on every host. A member’s archiving is fixed at creation time: the container only mounts
pgbackrest-archive.confif that file already exists, and the renderedpatroni.ymlonly carries the archive GUCs if an S3 repo is configured. A memberpg ha created beforepg backup setupon its host therefore starts without WAL archiving —pg ha snapshot/pg ha restorecannot cover it until a latersetupflags it stale and recreates it inside a pause window.pg ha createwarns about this up front; runpg backup setupfirst on each host for a backup-ready create.
Snapshot operations: pg ha snapshot
These act on a cluster (scope), running pgBackRest against the current primary from
the shared backup container. This is the Patroni-cluster counterpart of pg snapshot
(single instance).
Notes:
- Before each command,
pg ha snapshotre-renders the backup-containerpgbackrest.conffrom the current topology, so member adds/removes and port changes are reflected in the stanza immediately — the file is bind-mounted, no recreate. - You can only delete a non-unique full: pgBackRest keeps at least one full, so deleting the only one is refused (take a new full first).
- create connects to whatever host is leader at the time; after a failover it still succeeds — that is the point of the cluster-wide stanza.
Pitfall: a stuck replica LSN → patronictl reinit
Symptom. In pg ha status app one replica’s Replay LSN trails its own
Receive LSN (or visibly differs from another replica) and never converges over
time. Note Patroni’s Lag column is relative to the leader, so every number moves
when the leader’s LSN advances; to spot a real stall compare a replica’s own recv vs
replay, or watch whether its Replay LSN stays frozen across checks.
Confirm. Three signals point to the same conclusion — the replica’s WAL replay is at a hole and streaming has actually stopped:
record with incorrect prev-link + a repeating waiting for WAL to become available
means the WAL stream has a hole/discontinuity at a segment boundary and the startup
process is dead-waiting for the next segment. Patroni often keeps logging
“no action. I am … a secondary, and following a leader” each cycle — it believes it
is following while the postmaster’s walreceiver has actually stopped, so it will not
self-heal. This is a replication runtime issue, unrelated to backups.
Fix: reinit the replica. Have Patroni take a fresh pg_basebackup from the leader
and restart replication. This destructively rebuilds that replica’s data directory
(the leader and other replicas are untouched), so Patroni requires --force:
-f does not work: patronictl reinit only accepts the full --force (unlike
switchover/failover’s --yes). Success: reinitialize for member node1 means it
was dispatched.
Verify recovery. reinit wipes and rebuilds the data dir, then basebackups + replays.
Poll until it is recv == replay and status=streaming:
After reinit completes, run pg ha snapshot create app --type full once to realign the
snapshot baseline with the healthy topology.
7.6 - Patroni Cluster Restore
This page is the restore-side companion to Patroni Cluster Backup.
Once a cluster has a stanza with WAL archiving (pg backup setup + pg ha snapshot),
pg ha restore brings the whole cluster back to a point in time — the Patroni
counterpart of the single-instance pg restore. It walks the
mechanism (why a cluster PITR is not just pgbackrest restore), the safety
pre-check on where the leader lives, an end-to-end runbook, and the re-baseline
steps that must follow. Command/flag details also live in
Restore → Patroni cluster; this page strings them
into a procedure you can run.
Prerequisite: a stanza with WAL archiving, on this host
PITR can only replay up to what was archived. Before restoring, the cluster must already have:
- a cluster stanza provisioned against an S3 repo —
pg backup setup --s3-endpoint ...(see Backup → S3 repository and HA backup); - at least one full snapshot, plus the WAL segments covering the target time
(taken with
pg ha snapshot create <scope> --type full).
The target time is bounded by that history: earlier than the newest full backup’s stop time, or later than the last archived WAL, and the recovery cannot land there.
pg backup setup must have run on this host, and the backup container must be
up. This is stricter than it sounds for a cross-host cluster:
- The member that carries the bootstrap restores from S3 using the repo config
mounted into its container —
pgbackrest-archive.confat/etc/pgbackrest.confplus the S3 CA at/etc/pgbackrest/ca.crt. Both are generated bypg backup setup, and the container only mounts them when the files exist. A host that never ran setup has no repo config to mount, so thepgbackrest restorebootstrap cannot reach S3 and fails to start. - The leader-locality and stop-time pre-checks run
pgbackrest ... infoinside the backup container.pg ha restorerefreshes the sharedpgbackrest.confbest-effort but does not create the member archive config and does not start the backup container — so bring it up first (pg backup setup, orpg backup statusto confirm it isUp). If the backup container is down, the pre-checks are skipped and the real failure surfaces only during the bootstrap; the post-restore fresh snapshot needs it running anyway.
In short: run pg ha restore on a host where pg backup setup has provisioned the
S3 repo and the backup container is Up.
Mechanism: custom-bootstrap PITR, not a bare pgbackrest restore
A Patroni cluster cannot be PITR’d by running pgbackrest restore under a live
postmaster — Patroni owns the data directory, the timeline, and the DCS identity.
pg ha restore instead follows Patroni’s custom-bootstrap recipe:
-
Pause the cluster (
patronictl pause). This freezes failover before anything is stopped: stopping the local leader while the cluster is live lets Patroni promote a remote replica this host cannot stop, and that new remote leader then races the local bootstrap to re-claim the DCS on the old timeline — defeating the leader-locality pre-check from inside the destructive path. A restore re-run against a cluster a previous attempt already tore down (DCS cleared, members stopped) has nothing live to pause; that is the already-frozen state, so the pause failure on an empty DCS is tolerated and the run continues. -
Clear the cluster’s DCS identity (
patronictl remove). Patroni’sbootstrap.dcsblock only runs when the DCS has no config key, so removing it is what re-arms the bootstrap path. -
Pick one LOCAL member and rewrite its
patroni.ymlso the default initdb bootstrap is replaced by apgbackrest restoremethod targeting the point in time:no_params: truestops Patroni appending--scope/--datadir(whichpgbackrestrejects with[031] invalid option);keep_existing_recovery_conf: truepreserves therecovery.signal+restore_command+recovery_target_*thatpgbackrest restorewrites itself. Themethod/pgbackrestkeys are siblings ofbootstrap.dcs, never nested inside it — recovery GUCs leaked into the DCS config would apply as live settings on the promoted leader. -
Wipe that member’s PGDATA and recreate its container. Patroni sees an empty data dir with no DCS config, runs the method, recovers to the target on a new timeline, and — via
--target-action=promote— promotes itself to the writable leader. -
The remaining members rejoin the new leader: this host’s other local members are wiped and restarted as standard replicas (they
pg_basebackupthe new leader); cross-host members rejoin through the DCS (or are reinitialized).
The leader-locality pre-check
pg ha restore refuses to run unless the current leader is a local member of this
host. Before touching anything it reads the leader from the DCS and checks it is
one of the members this host can control.
Why. Step 1 clears the DCS and step 3 re-bootstraps a local member on a new timeline. A leader that lives on another host keeps its Patroni daemon running — this host cannot stop it — so after the DCS key is removed that remote leader races to re-claim leadership on the old timeline, colliding with the local bootstrap. Requiring leadership to be local (so it can be stopped) removes the race.
What you see.
Fix. Move leadership onto a member of the host you are standing on, then retry:
Or run pg ha restore on the leader’s own host instead. An empty / unreadable
leader (e.g. the very first bootstrap, before any member has published itself) does
not block the run.
Differences from single-instance pg restore
- It always promotes. There is no read-only “hold, inspect, retry another time”
two-step — the bootstrap recovery ends on a writable leader on a new timeline.
Confirm the target with
--dry-runfirst. (Thepatronictl pausein step 1 of the mechanism is unrelated: it freezes failover during the restore, it is not a read-only inspection window and the cluster is resumed implicitly when the new bootstrap republishes the DCS config.) - There is no
--promoteflag — promotion is automatic (it is baked into the bootstrap command). - It must run on a host that owns a member, and the leader must be local (see the pre-check above).
Runbook: restoring a cluster to a point in time
The full flag set is --time (required, all the formats in Restore),
--member, --dry-run, --tail-logs, --force.
1. Confirm the target is safe — dry-run. Nothing is touched; you see the stanza,
the chosen bootstrap member, the exact pgbackrest command, and (if applicable) the
leader-locality warning.
2. Make sure the leader is local (only if the dry-run warned). Switchover, then re-check status.
3. Restore. The default prompts for confirmation, listing scope, stanza, target,
bootstrap member, and the “PERMANENTLY LOST” warning. Stream the recovery logs with
--tail-logs; skip the prompt with --force for automation.
4. Verify the new cluster comes up. waitForLeader polls the DCS until a leader
appears (15-minute ceiling), then rejoin members start as replicas.
After the restore: re-baseline the new timeline
The restore is not the last step. The data directory was rebuilt, so two follow-ups are required before the cluster is backup-ready again:
-
Take a fresh full snapshot so future PITR has a base on the new timeline:
This usually succeeds directly: a pgBackRest restore of the same stanza preserves the PostgreSQL system-id across the timeline switch (verified), so the post-restore snapshot does not hit
[051] system-id ... do not match stanza. You do not needpg backup stanza-upgradeafter a cluster restore. (A real system-id change — a fresh initdb into the same stanza, e.g. a brand-new cluster reusing an old stanza name — is what[051]and stanza-upgrade are for.) -
Bring back any member that did not auto-rejoin — typically a cross-host member that lost the old leader. Reinitialize it from the new leader (destructive to that replica’s data dir only):
(Full details in HA backup → stuck replica reinit.)
Troubleshooting
- “no local member on this host” — you ran it on a host that owns none of the
scope’s members.
pg ha restorecan only rebuild a local data dir + container. Run it on a host that owns a member, or on the leader’s host. - “target time is before the latest backup stop time” — the earliest usable point
is the newest full backup’s stop time; the error prints it and a suggested
--time. Take a fresher full snapshot if you need a later base. - Recovery exceeds the last archived WAL — you cannot restore past what was
archived. Reduce the target time, or ensure
archive_commandis flowing (pg backup status) before retrying. - First post-restore snapshot fails
[051]— not expected: a same-stanza restore keeps the system-id (see the re-baseline section). If you do hit it, apg backup stanza-upgradefixes it non-destructively. FATAL: recovery ended before configured recovery target was reachedon a repeat PITR into the same repo — the stanza already hosts a previously promoted timeline, and the defaultrecovery_target_timeline=latestjumps onto that branch, which has no commits before your target. Restore again with an explicit older timeline pinned on the command, e.g.--recovery-option=recovery_target_timeline=<old-tl>(added to the bootstrappgbackrest ... restorecommand in the member’spatroni.yml). A first PITR after the target — no branch crossing — never hits this.
7.7 - HA Exec / psql
pg ha exec and pg ha psql are the direct way to run SQL against a Patroni
cluster. They are the cluster-side twins of the single-instance
pg exec and pg psql: you name
a scope, they resolve the target and connect. No --dsn to hand-assemble, no
member container to podman exec into, and — because the connection is plain TCP
— a leader or replica on another host is just as reachable as a local one.
Why not pg exec --dsn or podman exec
Before these commands, ad-hoc SQL on a cluster meant one of two awkward paths:
pg exec --dsn postgres://postgres:<pass>@<leader>:<port>/db— correct, but you assemble it by hand: read the leader’sconnect_addressout ofpg ha status, copy the superuser password out of the config, plug in the port. A fixed dsn also goes stale the moment the leader moves.podman exec -it <member> psql ...— reaches only a local member, so a leader on another host is out of reach; and you are inside a container the tool is meant to abstract away.
pg ha exec/psql fold both away: pgcli resolves the leader from the cluster’s
own state, authenticates with the stored password, and runs psql in a short-lived
container. You never see it.
How the target is resolved
The DCS roster (patronictl list -f json) is the only membership view that
spans the whole cluster. Each host’s pg.yaml registers only its own members,
so this host’s config cannot even see a node running elsewhere — but the DCS
knows every member and its advertised connect_address. Both commands go through
it:
- Default (no
--member) → the current leader. If leadership has moved since you last looked, you still hit the right node — there is no stale dsn. --member <name>→ that named member, wherever it lives. Aim it at a replica for a read-only query (SELECToff a standby,pg_is_in_recovery()returnst), or at a specific remote node.
Authentication is the cluster’s superuser over scram; pg_hba
(host all all all scram-sha-256) accepts any source, so a remote member is
reachable across hosts exactly the way replication traffic already is.
Usage
pg ha exec takes the SQL as trailing words (so you normally quote one string);
pg ha psql opens an interactive shell and forwards anything after -- straight
to psql. --database selects the target database on both (default postgres).
There is no --user — the managed superuser is the only role pgcli holds
credentials for, so that is what connects.
Interactive vs. scripted
pg ha psql allocates a TTY when your stdin is one; when it is not (piping a
script, running under CI) it turns the pager off so the session terminates
instead of blocking in less. That makes both of these behave as expected:
Reading the output
pg ha exec streams psql’s own formatting — column headers, alignment, row
counts, and errors go straight to your terminal, exactly as psql -c would print
them. It is not a machine-parseable dump; if you need one, pipe through your own
tooling or use pg ha exec app "..." --csv-style psql flags via pg ha psql app -- -c "...".
Where these fit
For a stable client endpoint that survives failover independently of pgcli,
put HAProxy in front of the cluster and connect through it.
pg ha exec/psql are for operators and scripts driving a cluster directly,
not a substitute for a pooler in front of an application.
The pg ha status / pg ha ctl verbs remain the way to inspect cluster state and
reach unwrapped patronictl commands; these two are specifically about running
SQL.
7.8 - Example: HA Cluster with a Self-CA MinIO
This page is a worked example, run end to end and verified: create a Patroni HA cluster, stand up a MinIO addon that serves a bring-your-own domain certificate (a self-signed CA of our own making — the same shape as a public-CA or corporate-CA cert, just without buying one), and point the cluster’s pgBackRest backups and WAL archiving at it. Every command below is a real one from the run; only hostnames, ports, passwords, and the certificate material are genericized.
Two ideas make the example worth reading rather than skimming:
- BYO TLS on the store. The MinIO addon does not have to serve pgcli’s
generated self-signed pair —
--tls-cert/--tls-keylet it serve a cert you already hold. A client that trusts the issuing CA needs no extra material; with a private (here: self-signed) CA you hand that CA to the backup stack via--s3-ca-file, exactly as with the generated one. See MinIO → Bring your own certificate. - Isolation via a second config file.
backup.repo.s3is a single, global repository shared by every stanza in one pgcli environment. Repointing it at a new store would silently redirect the backups of every existing cluster on that host too. The clean way to try something new against a live estate is a separate config file with its ownbase_dir,namespace, and port ranges — which is what this example does.
The environment we built
| Piece | Value | Notes |
|---|---|---|
| Config | ~/.pgcli-app1/pg.yaml |
separate from the production ~/.pgcli/pg.yaml |
| Base dir | /home/fish/bucket/pgcli-data-app1 |
nothing touches the production data dir |
| Namespace | app1 |
prefixes every container name — pgcli-minio-app1-store1, pgcli-patroni-app1-app1-nodea, pgcli-backup-app1 |
| DCS | existing etcd m1 at 127.0.0.1:2379 |
created on the production config long before this example — see Prerequisite; reused here as an external endpoint, and cluster membership is keyed by scope, so a new scope is naturally isolated |
| MinIO | store1, ports 9010/9011, listen 0.0.0.0 |
BYO self-signed cert, SANs minio1.test,127.0.0.1,<host-ip> |
| Patroni | scope app1, member nodea, PG <host-ip>:35632 |
single member; the leader by definition |
| Backup container | pgcli-backup-app1 |
coexists with the production pgcli-backup-default |
Prerequisite — the etcd DCS
The example reuses the etcd that already serves this host’s production clusters. If you are starting from scratch, that first piece to install is the same addon, one member at a time — a lone member is a healthy one-node etcd cluster, plenty for HA dev/test:
(For members other hosts will join as a DCS, give it a reachable address at
install time — --advertise-host <host-ip> — so its peer/client URLs announce
that IP instead of loopback; and grow it to a real 3-node ensemble with
--name m2/--name m3 the same way. None of that matters for this example:
the cluster lives on the same host and dials 127.0.0.1:2379.)
Step 0 — a domain certificate
Any PEM leaf + key works. For the demo we minted a self-signed one with pgcli’s
own pg cert (validity via --valid-duration, SANs covering the name and IPs
clients will dial):
pg cert writes a single self-signed leaf (CA:FALSE, server-auth) that is its
own trust anchor — exactly the shape a public- or corporate-CA cert has, minus
having bought one. With a public-CA (or corporate-CA) cert this step is
simply “you already have the files” — point the flags at your fullchain.pem
(leaf first, then intermediates) and key, and the client-side story gets
easier: chains rooted in a trusted CA need no --s3-ca-file at all.
Step 1 — an isolated environment
--namespace makes every container name this config creates distinct, so two
environments live side by side in one podman. The config file is passed with
-c on every subsequent command — from here on, every pg in this page means
pg -c ~/.pgcli-app1/pg.yaml.
Port ranges. The default port pools (etcd 2379, MinIO 9000, Patroni 35532, …) start at the same values as the production config, and podman publishes the loopback ports of a host-networked container — so pass explicit, disjoint ports everywhere rather than letting both environments auto-assign the same numbers.
Step 2 — MinIO with the BYO cert
Install-time validation pairs the cert and key (a mismatched, expired,
CA-instead-of-leaf, or non-server-auth pair fails right here), prints the
validity window, and — because the cert is self-signed — tells you clients will
pin it as their trust anchor. --listen 0.0.0.0 rather than loopback: the
backup container reaches the store over host networking via the host IP, and
that IP is one of the SANs. A quick proof it serves your certificate:
Create the repository bucket (pgBackRest does not create it):
Step 3 — the Patroni cluster
The warning is the designed order of operations, not a problem: at create time
no repo exists yet, so the member comes up without archiving; the backup setup in the next step configures the repo and recreates the member with
archiving enabled. Note the DCS scope is app1-app1 (namespace prefix + scope)
— pass the plain scope (app1) to every pg ha command and pgcli resolves
the qualified form; it also keeps the stanza distinct from any app1 another
namespace might run on the same bucket.
Seed something worth backing up (this also proves client auth over scram from the host):
Step 4 — point the backup stack at the BYO store
--s3-ca-file takes our own cert as the trust anchor — for a self-signed
cert the leaf embeds its own root, so the file you passed to --tls-cert is
the CA file. pgBackRest passes the bundle to its verifier with no content
inspection, so a public-CA cert needs no flag at all and a private-CA one just
gets its chain here. The run then proves the full stack and rewires the member:
The “repo CA published to the cluster registry” line is the etcd-distribution path: every other member of this scope (or future ones, like a second host) picks the CA up from the DCS with no manual scp and no extra flags.
Step 5 — back it up, and prove it landed
The independent proof is that the objects are physically in the bucket, written over a TLS session terminated by our certificate:
archive/ is continuous WAL archiving (the archive-push the recreated
member runs), backup/ is the full snapshot. Confirm the archiver is healthy:
One subtlety from our run, worth knowing so you don’t chase a ghost:
failed_count was 1, for 00000002.history — the timeline-history file,
archived one second later successfully. It raced the brief window in which
backup setup was pausing and recreating the member. A lone recent failure
during a setup/recreate is self-healing; a growing count with the same WAL
name is a real repo problem.
What the example demonstrates
- A MinIO addon serving a bring-your-own certificate works as a pgBackRest
repository end to end: stanza-create,
check, full backup, and continuous WAL archiving all flow over TLS verified against the supplied cert. - Self-signed is not a lesser path: the same
--s3-ca-fileslot carries it (leaf==root), a public CA’s chain, or an internal CA bundle — the transport and the trust plumbing are identical. backup.repo.s3is one global repo per config file. To experiment beside a running estate, don’t repoint it — init a second config (pg config init --namespace … --base-dir …) and run the whole experiment there. Namespace-qualified container names, DCS scopes, and stanzas keep the two environments from ever seeing each other.
Teardown
Everything the example created lives under the app1 config, so teardown is
ordered through that same -c (data directories deleted explicitly):
--scope-all (not --member) is what clears the scope’s DCS keys in the
shared etcd — patronictl remove plus the pgcli member registry. Removing
members one by one leaves both behind.
The etcd m1 belonged to the production environment — it is untouched by all
of the above, which is exactly the point of the isolation.
Related
- Patroni Cluster Backup — the backup machinery in depth
- MinIO — the addon, and its BYO certificate section
- Namespace Isolation — how
--namespacescopes names - Patroni HA — the
pg hacommand set
7.9 - Generating a Certificate — pg cert
pg cert mints a self-signed certificate — its SubjectAltNames covering any
mix of DNS names and IP addresses you ask for — for any place you need TLS
without going through a CA: a dev or test server, an internal endpoint, a
service whose clients you control. The certificate it writes is a normal TLS
server leaf, usable anywhere.
The use this docs tree exercises is pgBackRest’s S3 path: a Patroni cluster’s
backups push to an S3 store (MinIO or silo, typically) that must serve
TLS — pgBackRest refuses plaintext S3 — and pgcli’s MinIO/silo addons can
serve a bring-your-own certificate (pg addon install minio --tls-cert ... --tls-key ...). The quickest way to get a cert to try that with is pg cert:
Nothing is registered with pgcli — it touches no pg.yaml and starts no
container; it just writes the two files you name and prints the SANs. See
MinIO → Bring your own certificate
for the serving side, and the worked
HA + self-CA MinIO example for it end to end.
What it produces
A single self-signed leaf certificate, not a chain:
- one
CERTIFICATEblock in--cert-file— the cert signs itself; CA:FALSE, extended key usageserverAuth— a proper TLS server leaf;- SANs split automatically: any
--hostentry that parses as an IP lands in the IP SANs, the rest in the DNS SANs, so"minio.test,10.0.0.9"needs no special syntax (and*.wild.testworks as a wildcard DNS entry).
Because it is self-signed, the leaf is its own trust anchor — hand the same
.crt to any TLS client that lets you point at a CA file or bundle (curl
--cacert, a browser’s import, an app’s SSL_CERT_FILE …). For pgBackRest’s
S3 path specifically that means backup.repo.s3.ca_file /
pg backup setup --s3-ca-file minio.crt. A remote host that never saw the
file needs no scp either: pg backup fetch-ca <endpoint> recognizes a
self-signed leaf in the TLS handshake and saves it back as the anchor. This is
not a workaround pgBackRest merely tolerates: OpenSSL treats whatever you hand
its trust store as an anchor regardless of CA:TRUE, and pgBackRest’s S3 path
(curl over OpenSSL) is that mechanism — verified directly,
openssl verify -CAfile minio.crt minio.crt returns OK.
With a real public- or private-CA certificate none of the self-anchor
machinery applies: a chain rooted in a trusted CA needs no ca_file at all,
and you already hold the issuing CA for a private one.
Flags
| Flag | Default | Meaning |
|---|---|---|
--host |
127.0.0.1 |
Comma-separated DNS names and/or IPs for the SAN (repeatable). IPs are auto-detected; *.wild.test works as a wildcard. |
--cert-file |
cert.pem |
Path to write the PEM certificate. |
--key-file |
key.pem |
Path to write the PEM private key (PKCS8, mode 0600). |
--valid-duration |
825 days (19800h) |
How long the cert stays valid, e.g. --valid-duration 8760h for a year. |
--ecdsa |
P-256 |
Curve: P-224/P-256/P-384/P-521. Set to "" to disable ECDSA (then pass --rsa). |
--rsa |
(off) | RSA key size (e.g. 2048, 4096); set only when you specifically need RSA instead of the default ECDSA key. |
--ca |
false |
Issue a self-signed CA (CA:TRUE, keyCertSign) instead of a server leaf — see below. |
Selecting no key type at all (--rsa 0 --ecdsa "") is an error: exactly one
of them must produce the key.
The --ca mode is not for --tls-cert
--ca makes the cert its own Certificate Authority — for when you want a
private root to go sign further certs with. It is not something to hand to
MinIO’s --tls-cert: ValidateBYOCert inspects the file’s first certificate
and rejects one that is a CA, because a CA’s private key is a signing key, not
a server key. The default (no --ca) is the server leaf you do want.
Standalone binary: gencert
The same generator is built as a standalone program for use outside pg:
Identical flag set, single-dash form (-host, -cert-file, …). Both front the
one internal/certgen package, so their output is the same kind of
certificate — pg cert is just the path that keeps you inside the CLI.
Example: full BYO chain in one host
Step 3 references the .crt you minted, because on this host you already hold
it. A remote host that never saw the file pulls it back with one handshake
instead of an scp — fetch-ca recognizes the self-signed leaf MinIO serves and
saves it as the anchor:
Related
- Silo — the same BYO TLS surface on Pigsty’s MinIO fork
- MinIO — the addon itself, and its bring-your-own certificate and generating a test certificate sections
- Example: HA + self-CA MinIO — the whole chain, run end to end in an isolated environment
- Cluster Backup — the S3 repository and WAL archiving that consume this certificate
- Patroni HA — the
pg hacommand set
7.10 - S3 Storage High Availability
A Patroni cluster survives a node loss; the S3 repository its WAL and backups stream into must survive one too, or “high availability” quietly ends at the backup path. The store is a MinIO or silo addon (the two are interchangeable — silo is Pigsty’s MinIO fork with the same feature surface). Two fault domains matter, and they are orthogonal:
| Fault | Who absorbs it |
|---|---|
| A disk dies inside a node | the drive-level EC under SNMD/MNMD, or the storage layer under MinIO’s data directory (ZFS) |
| A node dies entirely | MinIO’s own erasure coding (EC) across hosts |
This page records the shapes pgcli supports for combining the two, and how to pick among them.
The deployment modes
MinIO classifies its layouts by node count and drive count per node (SNSD / SNMD / MNSD / MNMD). pgcli exposes all four:
| Mode | Shape | Use it for |
|---|---|---|
| SNSD (single-node, single-drive) | one node, one data directory — the default | dev, test, demos — and, paired with ZFS below, any single-host deployment that needs disk redundancy |
| SNMD (single-node, multi-drive) | one node, several drives — one --drive per drive |
a single host with N ≥ 4 data disks that must survive a disk loss without a filesystem layer |
| MNSD (multi-node, single-drive) | ≥ 4 nodes, one data directory per node | compact high-availability deployments |
| MNMD (multi-node, multi-drive) | ≥ 2 nodes, several drives each — --drive plus the full --endpoint matrix |
surviving a disk loss and a node loss without a filesystem layer |
SNMD is native: pg addon install minio --drive /mnt/disk1 --drive ...
(one flag per drive) starts one MinIO process that erasure-codes across the
drives. What it buys, measured on a live 4-drive set: 2 parity shards by
default, so it tolerates 2 drive failures; with 1 drive down reads and
writes continue, with 2 down reads still succeed but writes are refused (the
quorum boundary); usable capacity is about half the raw total; a drive that
returns is healed by MinIO itself. The same --drive works on
silo.
MNMD is native too: keep --drive for this node’s drives and add the whole
cluster’s host×drive endpoint matrix with --endpoint — one URL per drive on
every node, each naming that node’s /data1../dataN slot. Measured on a live
4-node × 4-drive set (both minio and silo): 16 drives online report EC:4
in a single erasure set of stripe size 16; losing one whole node (12/16)
keeps reads and writes working; losing a second node (8/16) refuses writes
and fails reads, and the set self-heals to 16/16 on restart. The full
walkthrough is Addons → MinIO → Multi-Node Multi-Drive
(MNMD).
SNMD vs ZFS: two ways to survive a disk
Both protect a single host’s data against disk loss; they differ in where the redundancy lives and what that costs.
native SNMD/MNMD (--drive) |
ZFS pool under --data-dir |
|
|---|---|---|
| quorum unit | the drive — MinIO counts drives as failure members | invisible to MinIO — one big drive, the pool absorbs disk loss |
| layout changeable later | fixed at install (the drive set is the EC set) | freely — swap disks, grow, migrate raidz1→raidz2; --data-dir never changes |
| usable capacity, 4 disks | ~half (2 data + 2 parity) | depends on vdev: raidz2 = 2×disk, raidz1 = 3×disk (more usable) |
| rebuild | MinIO heals a returned drive | ZFS resilvers locally |
| serves which modes | SNMD (single host) and MNMD (across hosts) | SNSD and MNSD (same recipe under each node) |
| heterogeneous nodes | every node must contribute the same drive count | a 2-disk node and a 6-disk node look identical (one endpoint each) |
The honest tradeoff: native multi-drive is simpler — one command, no
filesystem to provision, and it gets you disk redundancy with zero ZFS setup.
ZFS is more flexible — the layout stays changeable underneath, it saves
more capacity on the same disks (raidz1 keeps 3 of 4 usable where SNMD’s
EC:2 keeps 2 of 4), and it tolerates nodes that are not identical. So:
- single host, want disk redundancy without touching ZFS → SNMD
- single host, want the most usable capacity / a layout you can change later → SNSD + ZFS
- distributed cluster, disk and node failure → both paths are first-class now: native MNMD, or MNSD + per-node ZFS — see “HA with 4 hosts, each with several disks” below
Neither is wrong on a single box — SNMD’s 50% capacity is the price of not managing a pool, and ZFS’s flexibility is the price of provisioning one. The rest of this page documents the ZFS path and the MNMD alternative for multi-host sets.
ZFS: the flexible disk layer
The recipe is identical under SNSD and MNSD: build a zpool from the host’s
data disks, create one dataset for the store, and point --data-dir at its
mount point. MinIO sees “one big reliable drive”; it never learns how many
physical disks are under it.
The pool must sit on separate devices from the root filesystem — which is also
what MinIO’s drive check and
pgcli’s install-time advisory want (drive is part of root drive, will not be used). A ZFS mount on its own disks satisfies that trivially.
Layout by disk count
| Data disks | SNSD (ZFS is the only defense) | MNSD node (EC above absorbs node loss) |
|---|---|---|
| 1 | no redundancy — fine for dev/test, a dead disk means re-seeding the repo | one big disk per node is the plain MNSD shape; no ZFS needed |
| 2 | mirror |
mirror |
| 3 | raidz1 (2D usable) |
raidz1 |
| 4 | raidz2 on spinning disks; raidz1 when SSD rebuilds are quick and the capacity matters; 2 × mirror for write-heavy stores |
raidz1 — one parity is enough to keep disk loss invisible to MinIO |
| 5–8 | raidz2 ((N−2)D usable) |
raidz1, or raidz2 with large HDDs |
| > 8 | prefer two smaller raidz2/raidz1 vdevs striped over the pool — a resilver across 12+ disks is a long exposure window |
same: keep any single vdev ≤ ~8 disks |
The two columns differ in one line of reasoning: under SNSD ZFS must survive the disk and whatever happens during its rebuild, so parity depth buys safety outright; under MNSD, ZFS only has to keep a disk failure from escalating into a node loss, so one parity plus quick local resilver is the job — and beyond that, prefer more nodes over deeper local RAID.
For a 4-disk host under SNSD — the most common single-box question — the usual choices are:
| Layout | Usable | Survives | Character |
|---|---|---|---|
raidz2 (like RAID6) |
2 × disk | 2 disks | the safe default on spinning disks: resilvers on large drives are long, and raidz1’s single parity does not survive one more loss during a rebuild |
raidz1 (like RAID5) |
3 × disk | 1 disk | the capacity pick for SSDs / small disks, where a resilver takes minutes, not hours |
2 × mirror (striped) |
2 × disk | 1 disk per mirror (2 if in different mirrors) | best small-write performance — wide raidz is the worst shape for it |
Under SNSD ZFS is the store’s only line of defense, so the default leans
conservative: raidz2 unless the disks are fast enough to rebuild quickly and
the capacity is worth the thinner margin.
MinIO’s own guidance to avoid RAID underneath its EC mode targets the double-redundancy of RAID + cross-node EC. Under SNSD there is no EC — ZFS is the only protection the data has — so the guidance does not apply, and following it literally (“single drive, no ZFS”) on a 4-disk host would mean losing the whole store to one dead disk.
Co-locating with PostgreSQL: ZFS caches aggressively (ARC, by default up to half of RAM). On a host that also runs the database, cap it — e.g.
options zfs:zfs_arc_max=8589934592in/etc/modprobe.d/zfs.conf— so the two workloads do not fight over memory.
HA with 4 hosts, each with several disks
This is the hybrid the layout table above points at, and there are two first-class ways to build it: native MNMD — MinIO erasure-codes across every disk of every node — or MNSD + a ZFS pool under each node’s data directory — MinIO sees one drive per node and the pools absorb disk loss. They fail differently, so the choice is real either way.
Native MNMD
One matrix, no filesystem layer: each node passes its own drives with
--drive and every node carries the identical host×drive endpoint list. For
TLS, generate one shared leaf and install the same pair on every node (see the
TLS note after the measurements below).
Measured on a live 4-node × 4-drive set (the same on minio and silo): the 16-drive set reports EC:4 in one erasure set of stripe size 16 — losing 4 drives of 16 stays healthy.
| Event | Online | Effect (measured) |
|---|---|---|
| 1–4 drives lost | ≥ 12/16 | reads and writes continue; returned drives are healed by MinIO |
| 1 node down (its 4 drives) | 12/16 | reads and writes continue — a 64 MiB round-trip stayed byte-identical |
| 2 nodes down | 8/16 | writes refused (Resource requested is unwritable), reads fail too |
| nodes restarted | 16/16 | self-heals |
Usable capacity follows the parity ratio: EC:4 over 16 drives keeps 12/16
of raw bytes. The costs: the layout is fixed at install (the matrix is the EC
set), every node must contribute the same number of drives, and pgcli cannot
detect a matrix typo on another node — keeping the N pg.yaml files
identical is the operator’s job.
For TLS across the grid, serve one shared certificate: mint a single self-
signed leaf with pg cert --host <every node address, comma-separated> and
install it on every node with --tls-cert/--tls-key (the same two files
byte-identical everywhere, the endpoint matrix all https://). The grid then
forms with no per-node CA to reconcile — verified on a live 4-node set: the ring
came up Network: 4/4 OK with an empty CAs/ dir, because that shared leaf is
its own trust anchor and every node already holds it. The fallback is the
generated mode: if you let each node run a bare --tls, every host mints its
own CA and the first cross-node handshake dies with x509: certificate signed by unknown authority until you seed one shared CA into every node’s cert dir by
hand — which the shared-pg cert path avoids entirely.
MNSD + per-node ZFS
MNSD across the hosts, ZFS under each node’s data directory. Each node
exposes exactly one endpoint (its ZFS-backed /data); disk failures are
healed by the local pool and never reach MinIO; node failures are absorbed by
EC quorum.
What each layer then tolerates on a 4-node cluster:
| Event | Handled by | Effect |
|---|---|---|
| 1 disk in a node’s raidz pool | ZFS (resilver) | invisible to MinIO, no quorum math |
| 1 node down | EC (writes need ⌈4/2⌉+1 = 3 of 4 online) | reads and writes continue |
| 2 nodes down | EC reads only (⌈4/2⌉ = 2 of 4) | readable, writes refused until a node returns |
| a disk and its node failing together | EC (3 of 4) | still safe — the surviving pools heal after the node returns |
Two properties make the scheme flexible:
- Endpoint count = node count, not disk count. A node with 2 disks and a
node with 6 disks look identical to MinIO. The four hosts may run
different layouts —
raidz1×4 disks here, 2-waymirrorthere,raidz2×6 over there — heterogeneity costs nothing. - Pool capacities should be roughly aligned. EC sets the usable capacity
of the whole cluster to the weakest member’s free space (3 × 12T pools +
1 × 4T pool → you get 4T × striping, not 40T). If the disks genuinely
differ, thin provisioning (
zvol-based sparse datasets, or simplyzfs set refquota) hides the mismatch from MinIO — the quota caps the big pools at the small one’s size, and nothing wastes a rebuild.
Why not stripe the per-node pool to reclaim capacity. MinIO’s “no RAID
under EC” advice is about capacity, and the arithmetic is real: a plain
4-node MNSD EC set already halves the raw total, so an unmirrored raidz_none
pool on each node (all disks striped, zero local redundancy) does squeeze out
roughly a third more cluster capacity than raidz1 would. But EC counts a
failure in nodes, and a striped pool turns a single dead disk into a whole
offline node — one ordinary disk failure burns a slot of the budget EC set
aside for losing an entire machine, forcing a network-wide rebuild of that
node’s whole pool and leaving zero margin until it finishes. The capacity is
only “free” because you quietly downgraded disk fault-tolerance to node
fault-tolerance. raidz1 per node is the point where the two layers stop
stealing from each other: the pool absorbs disk loss invisibly, MinIO’s EC
budget stays reserved for node loss. (If the reclaim-everything answer truly
fits, the shape that maximizes it is plain MNSD on one big disk per node —
no ZFS at all — not a striped pool that hides the same single-point risk one
layer down.)
Which of the two
| native MNMD | MNSD + per-node ZFS | |
|---|---|---|
| setup | one matrix command per node, no filesystem to provision | zpool + dataset on every node first, then one endpoint per node |
| what EC counts | drives — one node down is just 4 of 16 members lost | nodes — a disk failure never reaches MinIO at all |
| measured fault margin | 4-node × 4-drive set: OK to 12/16 drives (one full node), fails at 8/16 | 4 nodes: reads at 2/4, writes at 3/4 nodes |
| usable capacity | 12/16 of raw on that set (EC:4) — the parity is MinIO’s choice for the stripe | local raidz1 keeps 3/4 per pool, then EC halves across nodes |
| layout later | fixed at install — the matrix is the EC set | changeable — disks, vdevs, layouts move without touching MinIO |
| heterogeneous nodes | not possible: every node must contribute the same drive count | natural — each node is just one endpoint, any local layout |
| TLS | one shared cert serves the whole grid: pg cert a single leaf covering every node address, --tls-cert/--tls-key it identically everywhere (no CA to reconcile) |
the same — it’s still a TLS grid between nodes, so the same shared-leaf setup applies |
Pick MNMD when the nodes are identical, the disk plan is settled, and you want disk and node redundancy from MinIO alone with nothing to provision underneath. Pick MNSD + ZFS when nodes differ in disk count or size, the layout may change later, or you want to reason about failures in whole nodes instead of in drives.
Choosing a shape
| Situation | Shape |
|---|---|
| laptop / demo / CI | SNSD, default data dir — nothing to decide |
| one host with N ≥ 4 data disks, backups must survive a disk | SNMD (--drive × N) — zero filesystem setup; or SNSD + ZFS (raidz2 on spinning disks, raidz1 on fast SSDs) for more usable capacity and a changeable layout |
| a few hosts, one data disk each, must survive a host | MNSD plain |
| identical hosts with several data disks each, must survive a disk and a host | MNMD (--drive + full --endpoint matrix) — no filesystem layer; or MNSD + per-node ZFS, see “Which of the two” above |
| hosts whose disk counts/sizes differ | MNSD + per-node ZFS (layouts may differ per node; keep capacities aligned) — MNMD needs equal drive counts |
The store’s TLS story is independent of the topology: whichever shape you
pick, --tls (or pg cert-minted BYO certificates) works the same — see
Generating a Certificate.
Related
- Addons → MinIO — install,
--tls, distributed mode constraints (the hard rules this page builds on), and the MNMD walkthrough - Addons → Silo — the drop-in fork; identical modes
- Cluster Backup — how the repo is wired into Patroni
(
pg backup setup) - Example: HA Cluster with a Self-CA MinIO — an end-to-end verified walkthrough of the single-node variant
8 - Failover: Replica Promotion
Promote a replica to become the new primary when the current primary fails. pgcli provides a 3-step manual failover workflow — each step runs on its respective host, no auto-detection of same-host vs cross-host topology.
Overview
After failover (promote ro1 → new primary):
3-Step Failover
Each step is an independent command. Run them in order, on their respective hosts.
Step 1: pg replica promote <name>
Run on the host of the replica being promoted to primary.
What happens:
- Validates the instance is a replica (
ReplicaOfis set) and the container is running - Calls
pg_promote()(PostgreSQL 12+ native promotion — no container restart) - Waits for recovery to end (usually sub-second)
- Cleans up
primary_conninfofrompostgresql.auto.confviaALTER SYSTEM RESET - Updates config: clears
ReplicaOfandPrimaryDSN, enablesPITR - Automatically initializes PITR:
- pgBackRest stanza creation
archive_mode/archive_commandconfiguration- PostgreSQL restart to apply postmaster-level parameters
- Prints next-step instructions
Idempotent: If the replica is already promoted (e.g. from a manual pg_ctl promote), the command skips to config update.
Step 2: pg replica drop <name> -i <old-primary>
Run on the old primary host to clean up the replication slot. This step is only needed in specific scenarios.
This drops the physical replication slot pgcli_r_ro1 on the old primary. Without cleanup, the slot would hold WAL indefinitely until the primary runs out of disk space.
When to run:
| Scenario | Action | Why |
|---|---|---|
| Old primary is permanently lost | Skip | Slot is gone with the server |
| Plan to demote old primary to replica | Skip | repoint destroys the data directory (including pg_replslot/), all slots are implicitly removed |
| Old primary recovered, keep running as independent primary | Must run | Slot holds WAL indefinitely; without cleanup the disk will eventually fill up |
| Old primary recovered but will be shut down | Optional | No harm in skipping if the instance will not run again |
Keep the old primary as-is? If you want to preserve the old primary with its original data (e.g. for forensic analysis or as a read-only archive), you can simply leave it alone — do not run
droporrepointon it. The old primary keeps running as an independent instance with stale data. Just be aware that the replication slot for the promoted replica still exists and will accumulate WAL; you may want to drop just that specific slot (pg replica drop ro1 -i pg01) while leaving everything else untouched.
Step 3: pg replica repoint <name> --primary-dsn <dsn> --primary-name <name>
Run on each remaining replica host to re-point it to the new primary.
What happens:
- Queries the new primary’s extensions via DSN (
pg_extensioncatalog) - If non-builtin extensions exist (e.g. pg_cron, timescaledb), builds a local
-extimage with matching packages - Stops the old replica container and destroys its data directory
- Creates a replication slot on the new primary via DSN
- Updates config:
ReplicaOf,PrimaryDSN,ImageTag,Extensions, disablesPITR - Re-initializes via
pg_basebackup -Rfrom the new primary - Starts the replica container in standby mode
Why destroy + rebuild instead of ALTER SYSTEM SET?
After promotion, the new primary advances to a new timeline. Other replicas on the old timeline cannot simply change primary_conninfo — PostgreSQL rejects the connection with:
The only safe approach is a full pg_basebackup from the new primary.
Getting the Primary DSN
Get the new primary’s connection string from pg status on the promoted replica’s host:
Replace 127.0.0.1 with the new primary host’s IP reachable from the replica host (e.g. 10.0.0.11).
Demoting the Old Primary
When the old primary recovers, you can rejoin it as a replica of the new primary using the same repoint command:
This works even though pg01 was a primary (no ReplicaOf set). The command:
- Stops pg01 and destroys its data (including old PITR stanza)
- Creates a replication slot for pg01 on the new primary
- Sets
ReplicaOf = "ro1",PITR.Enabled = false - Re-initializes from the new primary via
pg_basebackup
After repoint, pg01 streams WAL from the new primary as a read-only replica — no WAL archiving, no backups.
Extension Sync
When a replica is repointed to a new primary, pgcli automatically synchronizes extensions:
- Query — Connects to the new primary via DSN and queries
pg_extensionfor installed extensions - Filter — Identifies non-builtin extensions (those requiring external packages, e.g. pg_cron, pgmq, timescaledb)
- Build — If non-builtin extensions exist, builds a local
-extimage:- If a local
-extimage already exists, installs missing packages on top (reuses Pigsty repo — fast) - If no
-extimage exists, builds from the base image with Pigsty repo setup apt-get installis idempotent — installing an already-present package is a no-op
- If a local
- Apply — On replica start,
ApplyExtensionswritesshared_preload_librariestopostgresql.conf - Skip CREATE EXTENSION — Replicas are read-only; extensions are replicated from the primary via
pg_basebackup+ WAL streaming
This ensures the replica container has the required shared libraries (e.g. pg_cron) that are referenced in postgresql.auto.conf.
Same-Host vs Cross-Host
pgcli does not auto-detect topology. You choose where to run each command:
| Scenario | Step 1 | Step 2 | Step 3 |
|---|---|---|---|
| All on one host | pg replica promote ro1 |
pg replica drop ro1 -i pg01 |
pg replica repoint ro2 --primary-dsn "postgres://...@127.0.0.1:..." --primary-name ro1 |
| Primary + replicas split across hosts | On replica host | On old primary host | On each replica host with the new primary’s network IP |
| Mixed | On the respective host | On old primary host | On each replica host |
The --primary-dsn must use an IP/hostname reachable from the host where repoint runs.
Complete Example
Cross-Host Example
Cascading Replication
A replica can itself serve as a primary for downstream replicas, forming a cascading chain. This reduces load on the primary and enables hierarchical topologies.
How It Works
-
Create a replica of a replica: Use the replica as the
-itarget -
WAL propagation:
- ra2 streams WAL from ra3
- ra2_ro1 streams WAL from ra2
- Data flows: ra3 → ra2 → ra2_ro1
-
Replication slots: Each link maintains its own slot
- ra3 has slot
pgcli_r_ra2 - ra2 has slot
pgcli_r_ra2_ro1
- ra3 has slot
Benefits
- Reduced primary load: Only direct replicas connect to primary
- Geographic distribution: Primary → regional replica → local replicas
- Network efficiency: Local replicas can share a regional upstream
Limitations
- Increased latency: Each hop adds replication delay
- Cascading failures: If ra2 fails, ra2_ro1 loses its upstream
- Promotion complexity: Promoting ra2_ro1 requires repointing it to a new primary
Verify Cascading
Failover with Cascading
If ra2 (middle node) fails:
If ra3 (primary) fails and ra2 is promoted:
Notes
- pg_promote() — PostgreSQL 12+ native function, no container restart required. The instance exits recovery in-place and becomes read-write immediately
- Timeline divergence — After promotion, the new primary is on a new timeline. Other replicas cannot be re-pointed with
ALTER SYSTEM SET primary_conninfo— they must be rebuilt viapg_basebackup - PITR on promoted replica — After promotion, the pgBackRest stanza and WAL archiving are initialized automatically (no manual
pg startneeded). The promoted replica has no prior backup history - Replication slots — The old primary’s slot for the promoted replica becomes stale after promotion.
pg replica dropcleans it up. If the old primary is demoted to a replica,repointdestroys the old data and the stale slot is no longer referenced - Extensions — Replica containers inherit
shared_preload_librariesfrom the primary viapostgresql.auto.conf. The repoint command ensures the local image has the required extension packages before rebuilding the replica - CREATE EXTENSION skipped — Replicas are read-only;
pg_basebackupcopies the extension metadata from the primary, soCREATE EXTENSIONis not needed (and would fail with “cannot execute CREATE EXTENSION in a read-only transaction”)
9 - Self-Cert
Why self-sign
Two addons that speak S3 over TLS — minio,
silo, rustfs — can serve certificates
two ways:
- Generated (
--tls): pgcli mints a self-signed CA + leaf for you, owns the cert dir, and refreshes it on restart. Zero setup, fixed SANs. - Bring-your-own (
--tls-cert/--tls-key): you supply the pair. This is where a public CA cert, a wildcard you already hold, or a cert with custom SANs goes.
pg cert is the mint for the second path: it creates a self-signed certificate
whose SANs cover exactly the hostnames and IPs you need, so you can try BYO TLS
without a real CA — and it writes them as a standalone pair, not registered
with pgcli: the files go wherever you point them, pg.yaml is untouched, and
no container is started. Hand them to a store with --tls-cert/--tls-key
yourself.
Quick start
pg cert prints the SANs it wrote and nothing else — it is a pure file
generator, not an addon command.
Flags
| Flag | Default | Meaning |
|---|---|---|
--host |
127.0.0.1 |
Comma-separated DNS names and/or IPs to encode as SANs (repeatable). Entries are auto-detected as one or the other, so "rustfs.test,10.0.0.9" needs no special syntax; *.wild.test works as a wildcard DNS entry. |
--cert-file |
cert.pem |
Path to write the PEM certificate. |
--key-file |
key.pem |
Path to write the PEM private key (PKCS8, mode 0600). |
--valid-duration |
825 days |
How long the cert stays valid, e.g. --valid-duration 8760h for a year. |
--ecdsa |
P-256 |
Curve: P-224/P-256/P-384/P-521. Set to "" to disable ECDSA (and pair with --rsa). |
--rsa |
(off) | RSA key size (e.g. 2048, 4096); set only when you specifically need RSA instead of the default ECDSA key. |
--ca |
false |
Make the cert its own CA (CA:TRUE, keyCertSign) — for when you want a private root to sign further certs with, not the usual case for --tls-cert. |
The same generator is built as a standalone binary for use outside pg —
make gencert → bin/gencert, with the identical flag set in single-dash form
(-host, -cert-file, …). Both front one package, so their output is the same
kind of certificate byte-for-byte.
What pg cert writes
One self-signed leaf, not a chain. The PEM in --cert-file holds exactly one
CERTIFICATE block — the cert signs itself (CA:FALSE, serverAuth EKU, your
SANs). There is deliberately no leaf+intermediate+root bundle: there is no
issuing CA above it, so there is nothing to chain.
This is also why the --ca output is not something to point --tls-cert
at: pg addon install … --tls-cert runs ValidateBYOCert, which inspects the
first certificate and rejects one that is a CA. The --ca cert is a trust
anchor you sign others with, not a server cert to serve.
Using it as the client’s trust anchor
Because the result is self-signed, hand the same .crt to every TLS client as
its CA file — the served leaf is its own trust anchor. For pgBackRest’s S3
repository that means pointing --s3-ca-file at the pg cert output directly:
This is not a workaround pgBackRest merely tolerates: OpenSSL treats whatever
you hand it as an anchor (CA:TRUE not required), and pgBackRest’s S3 TLS path
is that same mechanism. Verified directly: openssl verify -CAfile <cert> <cert>
on the self-signed CA:FALSE leaf returns OK.
To place the cert on a host that dials the store without shipping the file, use
pg backup fetch-ca — it pulls the
issuing cert straight out of the live TLS handshake.
Serving it from a store
The three S3 stores each mount your pair under the filenames their binary
requires, so your filenames are free — pg cert --cert-file anything.crt
works for all three:
pgcli never re-owns or writes to your source files; in rustfs’s case the pair is mounted read-only and copied into the container’s own cert dir (see rustfs → Privileges and ownership).
Verified on rustfs
On a real install, a pg cert leaf served by rustfs was confirmed off the live
TLS handshake (subject == issuer on the cert actually presented, not just what
pg.yaml recorded), the container came up healthy over HTTPS, and pg mc
completed a full alias set → mb → put → ls → get round-trip against it
with the fetched object byte-identical to the one uploaded.
Renewing a BYO cert
A single-file bind mount pins the source inode, so replacing the file in place does not take effect on a running container. To renew, mint a fresh pair (or overwrite the files), then recreate:
Turning BYO off (fall back to the generated pair, or plain HTTP): delete the
cert_file/key_file lines for that addon in pg.yaml and --force a
recreate.
See also
minio·silo·rustfs— each has a “Bring your own certificate” section- Backup → S3 object storage repository
— the
--s3-ca-file/fetch-caclient side
10 - Auto-start on Boot
pgcli can automatically start PostgreSQL instances, backup containers, PgBouncer services, etcd members, and the other supported addons after a host reboot. This feature uses system service managers:
- Linux: systemd user units
- macOS: launchd LaunchAgents
How It Works
When you enable auto-start, pgcli creates a system service that runs pg start --autostart at boot time (or user login). The --autostart flag starts only the instances and services marked with autostart: true in your configuration.
Auto-start is config-driven: if you run pg stop (or pg stop --all) on an instance, it will still be started automatically at the next boot. To prevent auto-start, use pg autostart disable.
Default behavior: The backup container has autostart: true by default in new configurations. Instances, PgBouncer, and etcd members default to autostart: false and must be explicitly enabled.
Enable Auto-start
For a Single Instance
Example:
This adds autostart: true to the instance configuration and creates/updates the boot service.
For the Backup Container
Enables auto-start for the shared pgBackRest backup container.
For PgBouncer
Enable auto-start for a PgBouncer associated with a specific instance:
Or for a remote PgBouncer:
For an etcd Member
Enable auto-start for an etcd member installed via pg addon install etcd:
The member name defaults to etcd when --name is omitted:
Repeat for each member of a cluster (m1, m2, m3, …) — autostart is
per-member. On a cross-host cluster, run the command on each host for that
host’s member(s).
Boot is start-only. At boot pgcli just brings the member’s existing
container up (podman start, recreating the container from config only if it
is missing or stuck in an improper state). It never re-registers membership —
a data-dir-initialized etcd member reloads its cluster from disk and rejoins
on a plain start, so it must have been installed while the cluster was
reachable first. Starting members before quorum forms briefly reports
etcdserver: no leader; that is normal and resolves once enough members are
up.
For a Kafka Node
Enable auto-start for a Kafka instance installed via pg addon install kafka:
The name defaults to kafka. At boot pgcli re-renders server.properties from
pg.yaml, starts the existing container and waits for the broker API to
answer. It never re-formats an already-formatted data dir — the cluster id and
node id are read from meta.properties, so the existing log is reused as-is.
For a SQLite Database
Enable auto-start for a managed SQLite database (Linux only):
--name is required — SQLite has no default database name. Replication must
already be enabled and its litestream.yml must exist:
At boot pgcli restarts the database’s litestream replicate process — there is
no container and the database file itself has nothing to start. The process
starts after the object-storage addons in the boot sequence, so a same-host
MinIO/silo/rustfs/garage/SeaweedFS backend is listening first. If the database
file is missing, that database’s autostart reports an error (and the rest of
the boot pass continues); restore the file or disable that target.
pg sqlite replication disable also clears the database’s autostart flag,
because Litestream cannot run without replication configured.
Disable Auto-start
When all auto-start targets are disabled, the boot service is automatically removed.
Check Status
Shows:
- Which instances and services have auto-start enabled
- The boot service unit name and state
- Linger status (Linux) — required for rootless podman to start services at boot time
Platform-Specific Behavior
Linux (systemd)
The boot service is created as a systemd user unit: pgcli-autostart-<hash>.service
Important: Rootless podman requires loginctl enable-linger to start containers at boot time (before the user logs in). pgcli attempts this automatically but will print a hint if it fails.
Without linger, the service starts at user login instead of at system boot.
Systemd Unit Configuration
The systemd unit generated by pgcli includes the following key settings:
- Delegate=yes - Delegates cgroup control to the service process, allowing rootless podman to properly manage container cgroup hierarchies
- RemainAfterExit=yes - Keeps the service in
activestate after execution completes, rather than immediately transitioning toinactive - Type=oneshot - Executes
pg start --autostartonce and exits
Cgroup Isolation and Automatic Fallback
Problem: When containers are started by a systemd user unit, accessing them from SSH sessions or other contexts may encounter cgroup permission errors:
Cause: Containers started by systemd user units reside in the user@1000.service/app.slice cgroup subtree, while SSH sessions are in session-N.scope - different cgroup scopes.
Solution: pgcli automatically detects cgroup permission errors and re-executes the command via systemd-run --user --scope. This creates a transient scope that can join the container’s cgroup subtree.
Affected commands (automatic fallback):
pg exec- Execute SQL or container commandspg psql- Interactive psql sessionspg status- Check container statuspg backup- Backup operationspg extension- Extension management- All other
podman execoperations
Unaffected commands:
pg start- Start containers (uses systemd-run itself)pg stop- Stop containerspg restart- Restart containers
Viewing Service Status
pg autostart status now displays detailed systemctl information:
Output includes:
- Enable status of all auto-start targets
- systemd unit name, load state, active state
- Service logs and recent execution records
- Linger status
Example output:
macOS (launchd)
The boot service is created as a LaunchAgent: com.pgcli.autostart-<hash>.plist
Note: macOS LaunchAgents run at user login, not at system boot. This is a macOS limitation — rootless podman cannot start services before the user logs in.
Configuration File
The autostart: true flag appears in your pgcli configuration file:
Boot Service Behavior
The boot service runs pg start --autostart, which:
- Starts all instances with
autostart: true - Starts the backup container if
backup.autostart: true - Starts PgBouncer services with
autostart: true - Starts etcd members with
autostart: true(start-only; see above) - Starts the other addons with
autostart: true; SQLite databases restart their Litestream replication after the object-storage addons
If no auto-start targets are configured, the service exits successfully (no error).
Each manager (PostgreSQL, backup, PgBouncer, etcd) self-heals stale rootless
podman state at startup — after a reboot the pause-process record is dead, so
the service transparently runs podman system migrate before touching
containers. This means boot works even when only addons (no PG instance) are
marked for auto-start.
Troubleshooting
Service Failed to Start
Check the boot service logs:
Linux (systemd):
macOS (launchd):
Linux: “XDG_RUNTIME_DIR is not set”
This error occurs when systemd user units are not available. Ensure you have a proper user session:
- SSH sessions should support systemd –user by default
- Terminal sessions in desktop environments support systemd –user
- Cron jobs and other non-interactive contexts do not support systemd –user
Linux: Auto-start Only Works After Login
Run loginctl enable-linger <your-username> to allow rootless podman to start services at boot time.
macOS: Auto-start Only Works After Login
This is expected behavior. macOS LaunchAgents cannot run before user login due to rootless podman constraints.
Examples
Enable auto-start for a complete production setup:
After the next reboot, all these services will start automatically.
11 - Extensions
pgcli supports installing and managing PostgreSQL extensions from the Pigsty DEB repository.
How It Works
Extensions are baked into a derived container image:
pg extension installbuilds a new image (based on the current image + Pigsty repo + extension packages)- Stops and removes the old container
- Recreates the container from the new image (host data volumes are preserved)
- Updates
image_tagin the config file
Benefits of this approach:
- Extensions survive container rebuilds (baked into the image layer)
pg startdoes not need to runapt-get installon every boot- Extension files are persistent and decoupled from the container lifecycle
Commands
Install Extensions
Restart confirmation: Extensions requiring shared_preload_libraries (e.g., pg_stat_statements, pg_cron) need a PostgreSQL restart. By default, you will be prompted for confirmation:
If you decline the restart, you can apply the changes later:
List Installed Extensions
Example output:
- managed: tracked by pgcli (recorded in config, included in image)
- unmanaged: manually installed extensions (not tracked in config)
Remove Extensions
Workflow:
DROP EXTENSION IF EXISTS pgmq- Update config and
shared_preload_libraries - No image rebuild — the
-extimage is shared across instances and packages are never uninstalled
Restart confirmation: If removing extensions that require shared_preload_libraries (e.g., pg_stat_statements, pg_cron), you will be prompted for confirmation before restarting:
If you decline the restart, you can apply the changes later:
View Available Extensions
Lists all 440 known extensions:
- 45 builtin (contrib, already in the base image — no image build needed)
- 395 Pigsty catalog (from Pigsty DEB repo, requires image build)
Extension Catalog
See Extension Catalog for the complete list of 45 built-in extensions (no install needed).
See Popular Extensions for usage examples of the most commonly used extensions.
Extensions Outside the Catalog
Only extensions in the catalog (builtin + Pigsty) can be installed via pg extension install. Unknown extension names are rejected before the build starts:
Full catalog: https://pigsty.cc/ext/list/
Configuration
After installing extensions, the config is updated:
The image_tag points to the derived image containing all installed extensions.
Shared Preload Libraries
Extensions requiring shared_preload_libraries are automatically configured in postgresql.conf:
Extensions that must be loaded first (e.g., citus, timescaledb) are
automatically placed at position 0 regardless of install order — PostgreSQL
fatals if they appear anywhere else.
This is a postmaster-level parameter; PostgreSQL must be restarted after changes.
Troubleshooting
Extension Install Failure
Cause: Extension name is not in the builtin contrib list or Pigsty catalog.
Resolution:
- Verify the extension name:
pg extension available - Check the Pigsty catalog: https://pigsty.cc/ext/list/
- Note the exact SQL extension name (e.g.,
vectornotpgvector)
CREATE EXTENSION Failure
The extension is installed but not tracked in config. You can safely ignore this, or manually add it to the config:
Shared Preload Library Conflict
If shared_preload_libraries was manually edited in postgresql.conf, pgcli’s sentinel block will overwrite it.
Resolution: Remove the manual configuration and let pgcli manage it.
Notes
- Extension count: 45 builtin (contrib) + 395 Pigsty catalog = 440 total known extensions
- Image size: Each extension adds 10-50MB to the image, but Pigsty packages are optimized
- Build time: First extension install takes 1-3 minutes (download + build); subsequent installs are faster (cache hits)
- Replica behavior: Replicas can install extensions, but
CREATE EXTENSIONwill be rejected (read-only). Install on the primary; replicas sync via physical replication - Extension upgrades:
ALTER EXTENSION ... UPDATE TO ...is not yet supported; run manually viapg exec
11.1 - Extension Catalog
This page lists all built-in extensions available in the base image. For usage examples, see Popular Extensions.
Built-in Extensions (45)
These extensions ship in the PostgreSQL base image — no image build or pg extension install needed. Just run CREATE EXTENSION to enable them.
Requires shared_preload_libraries
These extensions need a PostgreSQL restart after enabling. pgcli handles this automatically when installed via pg extension install.
| Extension | Description |
|---|---|
pg_stat_statements |
SQL performance analysis — tracks query execution counts, timing, and resource usage |
pg_cron |
Cron-based job scheduler inside the database |
pg_hint_plan |
Query hints to influence the planner |
pg_stat_monitor |
Advanced performance monitoring with query plans |
pg_qualstats |
Query predicate statistics for index recommendations |
pg_stat_kcache |
Kernel-level CPU/IO performance stats |
pg_wait_sampling |
Wait event sampling for performance analysis |
pg_track_settings |
Track configuration changes over time |
timescaledb |
Time-series database extension with hypertables |
pg_prewarm |
Preload table data into shared buffers after restart |
No restart needed
These extensions can be enabled instantly with CREATE EXTENSION — no restart required.
| Extension | Description |
|---|---|
uuid-ossp |
UUID generation functions (v1/v3/v4/v5) |
hstore |
Key-value pair storage in a single column |
pgcrypto |
Hashing (bcrypt/sha256), encryption/decryption, random generation |
pg_trgm |
Trigram similarity matching for fuzzy search |
unaccent |
Strip accents from characters for international text search |
fuzzystrmatch |
Fuzzy string matching (Levenshtein, Soundex, Metaphone) |
intarray |
Integer array operations (unique, sort, intersection) |
isn |
ISBN/ISSN/EAN standard number types with validation |
pg_repack |
Online table reorganization without exclusive locks |
pg_squeeze |
Table space reclamation |
pg_partman |
Automatic partition management |
pgvector (Pigsty) |
Vector similarity search for AI applications |
postgis (Pigsty) |
Geospatial data support |
pgmq (Pigsty) |
Lightweight message queue |
tablefunc |
Crosstab / pivot table functions |
btree_gist |
B-tree index support for GiST indexes |
btree_gin |
B-tree index support for GIN indexes |
citext |
Case-insensitive text type |
cube |
Multi-dimensional cube data type |
ltree |
Hierarchical label tree data type |
seg |
Floating-point interval data type |
earthdistance |
Great-circle distance calculations on Earth’s surface |
postgres_fdw |
Query external PostgreSQL servers as local tables |
file_fdw |
Read server files as foreign tables |
dblink |
Connect to other PostgreSQL databases |
amcheck |
Verify B-tree index integrity |
pageinspect |
Low-level page inspection for debugging |
pg_buffercache |
View shared buffer cache contents |
pg_freespacemap |
View free space map for tables |
pg_visibility |
View visibility map for tables |
pg_walinspect |
Inspect WAL records |
pgstattuple |
Table-level tuple statistics |
pgrowlocks |
Show row-level locking information |
pg_surgery |
Low-level heap tuple manipulation |
xml2 |
XML parsing and XPath functions |
dict_int |
Integer text search dictionary |
dict_xsyn |
Extended synonym text search dictionary |
bloom |
Bloom filter index access method |
autoinc |
Auto-increment trigger function |
insert_username |
Insert username trigger function |
moddatetime |
Auto-update timestamp trigger |
refint |
Referential integrity trigger functions |
tcn |
Triggered change notification |
sslinfo |
SSL connection information functions |
lo |
Large object type support |
intagg |
Integer array aggregate/enumeration |
tsm_system_rows |
Table sampling by row count |
tsm_system_time |
Table sampling by time |
pg_logicalinspect |
Logical replication slot inspection |
11.2 - Popular Extensions
Usage examples for the most commonly used PostgreSQL extensions, tested on PG 18 with pgcli.
Performance & Monitoring
| Extension | Install | Description |
|---|---|---|
pg_stat_statements |
pg extension install pg_stat_statements |
SQL performance analysis. Must-have for production. |
pg_repack |
pg extension install pg_repack |
Online table reorganization. Reclaims bloat. |
pg_prewarm |
pg extension install pg_prewarm |
Preload table data after restart. |
Data Types & Identifiers
| Extension | Install | Description |
|---|---|---|
uuid-ossp |
builtin | UUID generation (v1/v3/v5). PG 18 has built-in gen_random_uuid() (v4) and uuidv7() (v7) — no extension needed. |
hstore |
builtin | Key-value pair storage. |
citext |
builtin | Case-insensitive text. Foo = foo. |
Search & Text
| Extension | Install | Description |
|---|---|---|
pg_trgm |
builtin | Trigram similarity for fuzzy search and autocomplete. |
unaccent |
builtin | Strip accents for flexible international text search. |
Security & Encryption
| Extension | Install | Description |
|---|---|---|
pgcrypto |
builtin | Hashing, encryption/decryption, random generation. |
pgaudit |
pg extension install pgaudit |
Audit logging. Required for compliance (GDPR, HIPAA, SOX). |
Geospatial
| Extension | Install | Description |
|---|---|---|
postgis |
pg extension install postgis |
Full spatial database: geometry, spatial indexes, distance/area. |
AI & Vector Search
| Extension | Install | Description |
|---|---|---|
pgvector |
pg extension install vector |
Store and search embedding vectors. Standard for AI apps. |
Time Series
| Extension | Install | Description |
|---|---|---|
timescaledb |
pg extension install timescaledb |
Optimized time-series storage with hypertables. |
Scheduling & Distributed
| Extension | Install | Description |
|---|---|---|
pg_cron |
pg extension install pg_cron |
Cron-based job scheduler. Auto-configured by pgcli. |
citus |
pg extension install citus |
Distributed PostgreSQL with sharding. |
Foreign Data
| Extension | Install | Description |
|---|---|---|
postgres_fdw |
builtin | Query external PostgreSQL servers as local tables. |
pg_duckdb |
pg extension install pg_duckdb |
Embedded DuckDB analytics engine. Read Parquet/CSV, offload OLAP queries. |
Compatibility Notes
| Extension | PG 18 Status | Notes |
|---|---|---|
pgml |
Not available | Only supports PG 14-17 (no PG 18 package) |
uuid-ossp |
Partially needed | PG 18 built-in: gen_random_uuid() (v4), uuidv7() (v7). Extension only needed for v1/v3/v5 |
12 - Addons
pgcli supports extending PostgreSQL capabilities through an addon system. Addons are standalone containers that provide additional functionality for PostgreSQL instances without modifying the database itself.
Supported Addons
The following addons are currently supported:
| Addon | Description |
|---|---|
pgbouncer |
Connection pool manager with transaction-level pooling |
etcd |
Distributed key-value store — standalone, clusterable, for HA / DCS use |
pgdog |
Postgres proxy — connection pooling, load balancing and sharding |
postgrest |
Expose a PostgreSQL schema as a REST API — single stateless container, local or remote (any PG endpoint) |
haproxy |
TCP load balancer in front of a Patroni cluster — unified or read/write split, or any native proxying via your own haproxy.cfg (Linux only) |
minio |
S3-compatible object storage with web console — standalone or distributed erasure-coded cluster across hosts (Linux and macOS) |
silo |
S3-compatible object storage (Pigsty’s MinIO fork) — same feature surface as the minio addon, shares one port pool; ships its own mcli client (Linux and macOS) |
rustfs |
S3-compatible object storage (Rust reimplementation) — SNSD/SNMD/MNMD erasure-coded layouts, fixed container uid handled inside pgcli’s wrapper image, shares the one port pool (Linux only) |
garage |
S3-compatible object storage (Deuxfleurs’ Rust store) — single-node v1, rendered garage.toml, two data dirs, four ports from its own pool, plaintext HTTP only (front with nginx for TLS) (Linux; macOS code-complete, untested) |
seaweedfs |
S3-compatible object storage (Go, volume-managed disk) — single-node weed mini or cross-host weed server cluster with filer metadata in a shared PostgreSQL, one credential pair shared by the whole cluster, native S3 TLS; own port pool (Linux; macOS code-complete, untested) |
redis |
Standalone key-value store for cache, session, ranking and counter data — selectable majors 7/8, auto-generated requirepass, RDB persistence, read replicas, native cluster sharding via --cluster, own port pool (Linux; macOS code-complete, untested) |
predixy |
Redis protocol proxy — turns a native cluster into one plain redis:// endpoint, so clients need no cluster awareness at all; own port pool (Linux only) |
pgadmin |
pgAdmin 4 — the official PostgreSQL web administration UI, one container from the upstream image; web login auto-generated, optional one-time server seed via --dsn/--pg-name, persistent session store; own port pool (Linux; macOS code-complete, untested) |
nginx |
HTTP reverse proxy fronting multiple web services — path-based routing to declared backends, optional TLS termination, rendered nginx.conf or your own --conf-file, pg addon nginx reload on config change; own port pool (Linux and macOS) |
grafana |
Grafana — the open-source observability dashboard, one container from the official upstream image; web login auto-generated, Listen defaults to 0.0.0.0 (signup closed), env-only (GF_*) config with no provisioning in v1; own port pool (Linux; macOS code-complete, untested) |
prometheus |
Prometheus — the metrics collection backend Grafana reads from, one container from the official docker.io/prom/prometheus image; rendered prometheus.yml (self job + one job per --scrape JOB, folding that job’s targets; --scrape-append merges incrementally; --alertmanager-url forwards fired alerts and adds the matching scrape job; --rule-file copies and loads alerting rules) or your own --conf-file, SIGHUP reload via pg addon prometheus reload; no web login — Listen defaults to 0.0.0.0 guarded only by the network (use --listen 127.0.0.1 for loopback); own port pool (Linux; macOS code-complete, untested) |
alertmanager |
Alertmanager — the alert dedup/grouping/routing frontend of the Prometheus stack, one container from the official docker.io/prom/alertmanager image; rendered alertmanager.yml (a minimal route to a no-op or --webhook-url web.hook receiver) or your own --conf-file, SIGHUP reload via pg addon alertmanager reload; HA cluster gossip disabled (9094 never opens); no web login — Listen defaults to 0.0.0.0 guarded only by the network, and reaching it means anyone can create silences (mute on-call), so use --listen 127.0.0.1 for loopback; wired to Prometheus from the Prometheus side via pg addon install prometheus --alertmanager-url; own port pool (Linux; macOS code-complete, untested) |
victoriametrics |
VictoriaMetrics — fast, Prometheus-compatible time-series database, one container from the official docker.io/victoriametrics/victoria-metrics image; no config file (everything is CLI flags: -storageDataPath / -httpListenAddr / -retentionPeriod / --extra-arg), no hot reload (changes recreate the container), image runs as root (no pre-chown); no web login — Listen defaults to 0.0.0.0 guarded only by the network (use --listen 127.0.0.1 for loopback); own port pool (Linux; macOS code-complete, untested) |
nodeexporter |
node_exporter — the official Prometheus host-metrics exporter, one stateless container from the official docker.io/prom/node-exporter image; the one addon that must hard-wire three read-only host mounts (/proc→/host/proc, /sys→/host/sys, /→/rootfs) plus --path.* redirection or it would silently export the container’s own numbers instead of the host’s; no config file (everything is CLI flags: --web.listen-address / --path.* / --collector.filesystem.mount-points-exclude / --extra-arg), no hot reload (changes recreate the container), no data dir (remove never deletes anything); no auth, no TLS — Listen defaults to 0.0.0.0 because a Prometheus scraping it is usually on another host, so a remote-exposed instance is guarded only by the network; scrape it from a pgcli Prometheus with pg addon install prometheus --scrape node-exporter=<host>:9100; own port pool, default 9100 (Linux; macOS code-complete, untested) |
pgexporter |
pg_exporter — Pigsty’s declarative PostgreSQL database-metrics exporter, one stateless container from the official docker.io/pgsty/pg_exporter image; the companion to node_exporter (which describes the host) that exports ~460 pg_* metrics (activity, locks, replication, bloat, WAL, per-DB stats) and its distinguishing feature is unified collection: the target is EITHER a pgcli-managed instance (--pg-name, admin DSN re-derived each start) OR any reachable PostgreSQL pgcli does not manage — given as ONE mutually-exclusive URL: --admin-dsn (an admin URL used once to create the monitor role, then discarded — only its credential-free address is stored, the exporter dials as the monitor role) or --dsn (dial-as-is: pgcli runs no SQL, the exporter uses this URL’s own credentials — for a role that already exists or a database pgcli must not touch) — the point being one exporter shape collects databases inside and outside pgcli alike; by default pgcli creates a low-privilege pg_monitor monitor role on the target so the exporter NEVER dials as admin — local admin creds are derived automatically, a remote target takes an --admin-dsn used once for the CREATE ROLE/GRANT batch and never stored, and the generated monitor password is retrievable via pg addon password pgexporter; no config file needed (the default collector set is baked into the binary, planned at runtime by version/role/extensions; an optional --config-file collector YAML mounts read-only and SIGHUP-hot-reloads via pg addon pgexporter reload); /up /primary /replica health probes double as HAProxy-style routing checks; no auth, no TLS, and admin endpoints /reload /explain /stat on the same port — Listen defaults to 0.0.0.0 guarded only by the network; scrape it from a pgcli Prometheus with pg addon install prometheus --scrape pg=<host>:9630; own port pool, default 9630 (Linux host-network is tested; macOS bridge can’t reach a local instance’s loopback DSN — use a remote URL there (--dsn or --admin-dsn)) |
victorialogs |
VictoriaLogs — the log database of the VictoriaMetrics family, one container from the official docker.io/victoriametrics/victoria-logs image; single HTTP port serves ingestion (JSON Lines / Elasticsearch bulk / syslog / OTLP), LogsQL queries and the built-in UI; no config file (everything is CLI flags: -storageDataPath / -httpListenAddr / -retentionPeriod / --extra-arg), --retention takes a duration (30d, 3months, …) and is omitted by default so the binary’s own default stands; no web login — Listen defaults to 0.0.0.0 guarded only by the network (use --listen 127.0.0.1 for loopback); own port pool, default 9428 (Linux; macOS code-complete, untested) |
influxdb |
InfluxDB — the classic 1.x time-series database (TSM storage, InfluxQL), one container from pgcli’s own ghcr.io/mars-base/pgcli/pgcli-influxdb image packaging the official 1.13.1 linux/amd64 release on Alpine; authentication always on (generated admin password, pg addon password influxdb), minimal rendered influxdb.conf that only overrides paths/listen/auth, every other upstream default kept, arbitrary persistent overrides via --config-option or pg addon influxdb config set/unset; one container plus a unique loopback-only backup/restore RPC port per instance (upstream RPC has no auth); online portable backup/restore (--newdb to restore alongside), offline export via influx_inspect (stops the container, restarts unless --no-start), import with # CONTEXT-DATABASE: rewrite; top-level pg influx passthrough runs the influx CLI in a throwaway container against the local addon or any remote InfluxDB 1.x server (--host/--dsn, TLS flags, INFLUX_USERNAME/INFLUX_PASSWORD, -import -path FILE mounted into the container); no HTTPS listener (front with nginx), no HA, v1 is in maintenance mode; own port pool (Linux/amd64 only) |
influx2tsdb |
influx2tsdb — InfluxDB 1.x HTTP API adapter (Line Protocol + InfluxQL) in front of a PostgreSQL database with TimescaleDB, one stateless container from pgcli’s own ghcr.io/mars-base/pgcli/pgcli-influx2tsdb image (upstream 1.3.2 on Debian); each InfluxDB database maps to a PostgreSQL schema and each measurement to a hypertable; target a local instance with --pg-name (DSN re-derived on every start) or any remote PostgreSQL/TimescaleDB with --dsn; no authentication and no TLS — front with nginx or isolate the port; retention and compression are applied as TimescaleDB background jobs (add_retention_policy / add_compression_policy, re-synced every 5 minutes) — no pg_cron needed, and cleanup continues while the proxy is down; data lives entirely in PostgreSQL, so remove never deletes it and backup/PITR/replication/HA are PostgreSQL’s; InfluxQL subset, nanosecond timestamps, below-native-InfluxDB performance; own port pool (Linux/amd64 only) |
vlagent |
vlagent — the official VictoriaLogs log agent, one container from the official docker.io/victoriametrics/vlagent image; tracks host log files (each --log-path mounted read-only at the same absolute path) selected by vlagent’s native globs (--include/--exclude; no regex), and forwards to VictoriaLogs via one or more --remote-write-url values ending in /insert/native; relay-only when no paths are given; no config file, no auth; own port pool, default 9429 (Linux; macOS code-complete, untested) |
vmcluster |
VMCluster — the VictoriaMetrics cluster edition, three components (vminsert + vmselect + vmstorage) each in its own container from the official docker.io/victoriametrics/*:*-cluster images; --roles selects which components THIS host runs (default all three = converged single host) and --storage-node lists every vmstorage address for cross-host sharding/replication; only vmstorage has persistent data; no config file, no auth; 5 ports per instance from vmcluster_start_port (default 8480-8484) (Linux only) |
vlcluster |
VLCluster — the VictoriaLogs cluster edition, three components (vlinsert + vlselect + vlstorage) all from the same docker.io/victoriametrics/victoria-logs image, distinguished only by flags; --roles selects which components THIS host runs (default all three = converged single host) and --storage-node host:storagePort lists every vlstorage address for cross-host sharding (no replication — a down vlstorage makes queries return 502); only vlstorage has persistent data; no config file, no auth; 3 ports per instance from vlcluster_start_port (default 9430-9432) (Linux only) |
gitea |
Gitea — self-hosted Git service, one container from the official docker.io/gitea/gitea image (rootful build, runs as uid 1000); no-wizard install (INSTALL_LOCK) plus an auto-created admin whose web login is generated, printed once, and retrievable via pg addon password gitea; embedded SQLite by default or an external PostgreSQL via --dsn (Gitea creates its schema, pgcli never creates or drops the database); optional ssh:// clones via --ssh-port (own second port pool; off by default — HTTP clone only, since the image’s OpenSSH sshd runs unconditionally on every instance and --ssh-port is what promotes it from a parked listener to the live clone endpoint); crypto secrets (SECRET_KEY/INTERNAL_TOKEN) deliberately never injected — they belong to the persisted volume so reinstalls cannot silently re-key a surviving database; Listen defaults to 0.0.0.0 (signup closed by default, open it with --allow-registration; accounts via pg addon gitea user create), env-only (GITEA__*) config; own port pool (Linux; macOS code-complete, untested) |
beegfs |
BeeGFS — parallel filesystem, pgcli’s first multi-container addon: one config entry = one converged single-host filesystem = three containers (mgmtd + meta + storage) from the official ghcr.io/thinkparq/beegfs-* images, started in dependency order and managed as a unit (cluster-granular install reuse, stop/start, autostart, pg logs addon beegfs --service); host networking only — Linux only, no port pool: the protocol ports are stock/fixed (8008/8010 movable via the two --beegfs-mgmtd-* flags, meta 8005 + storage 8003 have no 8.4 override), which is exactly why v1 is one filesystem per host (guarded); shared connection-auth secret generated on install (pg addon password beegfs), mgmtd TLS disabled (--tls-disable, so clients must match); the client is a kernel module — pgcli never installs or mounts it, pg addon beegfs mount-hint prints the exact client config; licenses never injected; pg addon beegfs check reads daemon log lines for health; v1 single-host, v2 = cross-host cluster |
juicefs |
JuiceFS — S3-backed POSIX filesystem, pgcli’s first host-binary addon (no container): the binary is downloaded from GitHub releases with a pinned sha256 (a juicefs on PATH is reused), both sides fully explicit with no same-host auto-detection — the object store is an EXTERNAL S3 service (--s3-endpoint http(s)://host[:port] + --access-key/--secret-key, probed before format) and the metadata engine is an explicit --dsn URL (postgres:// / redis:// / sqlite3://, which pgcli never creates or drops); buckets stay manual pg mc work — pgcli prints the exact command. Mounts a plain host directory (no propagation into PG containers); `pg addon juicefs mount |
zerofs |
ZeroFS — encrypted, self-maintaining S3 filesystem (its own LSM on the bucket, no external metadata engine) as a host-binary addon: pgcli writes config.toml and runs two host processes — a loopback-only 9P server (9P has no auth; non-loopback --listen is rejected, one port per instance) plus the FUSE client; the object store is an EXTERNAL S3 service given explicitly (--s3-endpoint + --access-key/--secret-key, optional --s3-region — no same-host addon lookup); a generated encryption password wraps the bucket’s data key (pg addon password zerofs; after remove keeps the bucket, re-attach with --encryption-password '<original>' or the server fails Invalid password or corrupted key); fencing needs conditional put — --conditional-put auto (MinIO 2022-08+ / real S3) or redis via an explicit --redis-dsn for stores lacking it, none single-writer only; `pg addon zerofs start |
duckdb |
DuckDB — embedded OLAP database (“SQLite for analytics”) as a host-binary addon (no container, no daemon): C++ columnar engine with vectorised execution, queries Parquet/CSV/JSON/Arrow directly; downloaded from GitHub releases with a pinned sha256 (a duckdb on PATH is reused); common extensions (httpfs, parquet, json) auto-installed; --version pins the release (1.5.6 LTS default, 2.0.0 supported); `pg addon duckdb status |
clickhouse |
ClickHouse — single-node columnar OLAP database from the official docker.io/clickhouse/clickhouse-server image (pinned 26.8 LTS 26.8.36.3, amd64/arm64; host networking, data/log/config/backup dirs bind-mounted); authentication always on with a generated admin account (pg addon password clickhouse); HTTP 8123 and native TCP 9000 by default from separate port pools (TCP shares the S3 API pool); Listen defaults to 127.0.0.1; no TLS listener (front with nginx), single node with no replication/HA; `pg addon clickhouse status |
kafka |
Kafka — KRaft broker (no ZooKeeper) from the official docker.io/apache/kafka image (pinned 4.4.0, amd64/arm64; host networking, rendered server.properties, data dir bind-mounted); single-node combined broker+controller or one node of a multi-host static controller quorum installed on every host with --cluster/--node-id/--peer (derived cluster id, immutable membership); broker 9092 and controller 19093 by default (own port pools; controller avoids Alertmanager’s 9093); Listen defaults to 127.0.0.1 (clusters require 0.0.0.0); PLAINTEXT by default with an optional TLS listener (--tls, SSL port from the 9093 pool; encryption only, no client authentication; cluster clients need a concatenated CA bundle); RF=min(3, voters) / minISR=min(2, RF); pg addon kafka status|shell|topic create/list/describe/delete/retention|group list/describe|cluster status; --config-option overrides persisted in pg.yaml; Linux only |
rabbitmq |
RabbitMQ — message broker from the official docker.io/library/rabbitmq management image (pinned 4.4.0, amd64/arm64; host networking, rendered rabbitmq.conf, data dir bind-mounted, enabled_plugins fixed to management, plus etcd discovery in cluster mode); single node or one member of a multi-host cluster formed by etcd peer discovery (--cluster, --node-name, --etcd-endpoints or --etcd, shared secrets via pg addon rabbitmq secret generate + --secrets-from-etcd, or --erlang-cookie-file/--admin-password-file); AMQP 5672 and management web UI/API 15672 by default, plus EPMD 4369 and Erlang distribution 25672 (pool-assigned and loopback-only standalone, fixed and network-reachable for cluster members, one clustered instance per host); Listen defaults to 127.0.0.1 standalone and 0.0.0.0 clustered; authentication always on with a generated admin password (pg addon password rabbitmq), reconciled on every start; optional additive TLS (--tls: AMQPS 5671 and management HTTPS 15671, generated CA or bring-your-own --tls-cert/--tls-key; encryption only, no client authentication; distribution traffic remains unencrypted); pg addon rabbitmq status|shell|queue ... — declare/info/publish/get/bind/unbind/bindings go through the management API, list/purge/delete through rabbitmqctl; cluster declare defaults to quorum queues (--type classic for non-replicated); pg addon rabbitmq cluster status|forget NODE; no Prometheus plugin; Linux only |
rocketmq |
RocketMQ — Apache RocketMQ 5.x messaging from the official docker.io/apache/rocketmq image (host networking, rendered namesrv.conf/broker.conf/rmq-proxy.json, broker/controller stores and logs bind-mounted); three containers per instance (NameServer + broker + proxy, started namesrv → broker → proxy); standalone one async master with no replicas, or one member of a multi-host cluster with an embedded DLedger controller in every NameServer and controller-mode brokers that fail over automatically (--cluster/--controller-id/--advertise-host/--peer ID@HOST, full peer list on every host, one member per host, fixed ports 9876/9877/10911/10912/8080/8081); NameServer 9876, controller 9877, broker 10911 (HA 10912, VIP 10909), proxy remoting 8080 and gRPC 8081; no authentication, no TLS, no web UI; pg addon rocketmq status|shell|topic create/list/describe/delete|send|cluster status; --config-option broker.conf overrides persisted in pg.yaml; controller/broker stores are stateful, 3+ members for quorum, identity immutable after install; Linux only |
Each addon has its own page with commands, parameters, and troubleshooting.
Patroni HA (
pg ha) is not on this list — it is not installed viapg addonbut is its own top-level command, and its documentation now lives in a dedicated HA Cluster section. etcd and HAProxy here are addons apg hacluster can use.
How It Works
Addons run as standalone containers managed through pg.yaml:
pg addon installgenerates configuration and starts the addon container- Config and data live under
<base-dir>/addon/<addon-name>/ - Addon containers communicate over the host network
- Containers automatically restart when configuration is updated
Namespace isolation: Addons respect the config’s namespace setting — container names include the namespace prefix, so different config files can manage independent addons without conflicting.
Common Commands
See each addon’s page for its specific flags and examples: Pgbouncer · etcd · PgDog · PostgREST · HAProxy · MinIO · Silo · rustfs · Garage · SeaweedFS · Redis · Predixy · pgAdmin 4 · nginx · Grafana · Prometheus · Alertmanager · VictoriaMetrics · node_exporter · pg_exporter · VictoriaLogs · vlagent · InfluxDB · influx2tsdb · ClickHouse · Kafka · RabbitMQ · RocketMQ · VMCluster · VLCluster · Gitea · BeeGFS · JuiceFS · ZeroFS · DuckDB. Patroni pages live in HA Cluster.
12.1 - PgBouncer
PgBouncer is a lightweight connection pooler for PostgreSQL. As a pgcli addon it runs as a container in front of one or more PostgreSQL instances, reusing server connections across many short-lived client sessions — transaction-level pooling by default.
PgBouncer supports two deployment modes:
- Local:
pg addon install pgbouncer -i <instance>— stored underinstances.<name>.addons - Remote:
pg addon install pgbouncer --dsn <dsn> --pg-name <name>— stored in top-leveladdons.pgbouncer
Platform support
PgBouncer works on Linux (host networking, including cross-host pools) and
on macOS for single-host dev/test (the container joins the pgcli-net
bridge and publishes its port, the same way a PG instance does under podman
machine). On macOS:
- Local mode just works: PgBouncer reaches the managed instance by its container name automatically — no address to configure.
- Remote mode: the
--dsnhost must be reachable from the Mac — do not point it at127.0.0.1, which is the Mac itself, not the podman machine VM. - Client connections still target
127.0.0.1:<port>; gvproxy forwards the published port to the Mac’s loopback, exactly as for a PG instance.
How It Works
pg addon install pgbouncergenerates configuration files and starts the container- Config files live in
<base-dir>/addon/pgbouncer/<instance>/ - The container reaches PostgreSQL over the host network (Linux) or the
pgcli-netbridge by container name (macOS) - Config file updates restart the container automatically
Namespace isolation: PgBouncer respects the config’s namespace setting.
Container names and auth users include the namespace prefix (e.g.
pgb_<namespace>_<instance>), so different configs with different namespaces
can create independent poolers for the same PostgreSQL instance without
conflicting.
Commands
Install
Parameters:
| Parameter | Description | Default |
|---|---|---|
--dsn |
PG instance connection string (remote mode) | — |
--pg-name |
Name to identify a remote PgBouncer (required with –dsn) | — |
--max-client-conn |
Maximum client connections | 100 |
--default-pool-size |
Default pool size | 20 |
--min-pool-size |
Minimum pool size (warmup) | 0 |
--reserve-pool-size |
Reserve pool size (burst) | 0 |
--max-db-connections |
Max connections per database | 50 |
--max-user-connections |
Max connections per user | 0 (unlimited) |
--server-idle-timeout |
Idle server connection timeout (seconds) | 600 |
--server-lifetime |
Max server connection lifetime (seconds) | 3600 |
--server-connect-timeout |
PostgreSQL connection timeout (seconds) | 15 |
--query-timeout |
Query timeout (seconds) | 0 (unlimited) |
--query-wait-timeout |
Wait for connection timeout (seconds) | 120 |
--idle-transaction-timeout |
Idle transaction timeout (seconds) | 0 |
--transaction-timeout |
Transaction timeout (seconds) | 0 |
--admin-users |
Admin users list | (empty) |
--stats-users |
Read-only stats users list | (empty) |
--log-connections |
Log connections | 0 |
--log-disconnections |
Log disconnections | 0 |
List
PgBouncer appears under Local add-ons / Remote add-ons:
Start and stop
After a reboot or manual stop, bring the pooler back up without re-running install (config and auth users are untouched):
start is idempotent — a running pooler is left alone. stop keeps the
container and its config; use pg addon remove to tear it down.
Remove
Workflow:
- Stop and remove the addon container
- Delete the
<base-dir>/addon/pgbouncer/<instance>/directory and files - Remove the addon entry from
pg.yaml
Configuration
Local mode (under instances.<name>.addons):
Remote mode (under top-level addons):
Generated files, per instance, under <base-dir>/addon/pgbouncer/:
Authentication
PgBouncer uses the auth_query method for dynamic password lookup:
- A per-pooler auth user is created on PostgreSQL with a random password, named
pgb_<namespace>_<instance>(e.g.pgb_default_mypg,pgb_test-ns_my-remote) - A shared
SECURITY DEFINERfunctionpgbouncer_lookup()is installed to querypg_authid - When a client connects, PgBouncer uses its own auth user to run the auth_query and fetch the real user’s password hash
- The password is cached in PgBouncer’s memory for subsequent connections
userlist.txt only contains the pooler’s auth user (plaintext password). All other users are authenticated dynamically via auth_query — no password sync needed.
Each pooler gets its own PG auth user, so multiple poolers (local or cross-host) targeting the same PG instance do not conflict.
After changing a PostgreSQL user’s password, re-run pg addon install pgbouncer to reset the auth cache, or connect to the admin console and run RECONNECT.
Connecting
Clients connect through the addon port:
Port allocation: PgBouncer defaults to port 56432; if occupied, pgcli
assigns the next free port. View the current port with pg addon list.
Use Cases
High Concurrency
Short-Lived Connections
Long-Lived Connections
Read Replicas
Monitoring
PgBouncer exposes an admin console for pool and runtime status.
Connecting to the Admin Console
Connect to the pgbouncer virtual database as an admin user:
Example:
Note: Only users listed in admin_users can access the admin console.
Common SHOW Commands
| Command | Description |
|---|---|
SHOW pools |
Connection pool status (active/waiting client and server connections) |
SHOW clients |
All current client connections with details |
SHOW servers |
All current server (PostgreSQL) connections with details |
SHOW databases |
Configured databases and their connection parameters |
SHOW stats |
Traffic statistics (transactions, queries, bytes received/sent) |
SHOW config |
All runtime configuration parameters |
SHOW sockets |
Low-level TCP socket information |
SHOW active_sockets |
Active TCP sockets |
SHOW mem |
Memory usage statistics |
SHOW lists |
Summary of various object counts |
Examples
Other Admin Commands
| Command | Description |
|---|---|
RELOAD |
Reload configuration file |
PAUSE |
Pause connection pool (wait for transactions to complete) |
RESUME |
Resume connection pool |
RECONNECT |
Force reconnect all server connections |
SHUTDOWN |
Shutdown PgBouncer |
Troubleshooting
Connection Pool Full
Cause: Reached max_client_conn limit.
Authentication Failure
Cause: Auth cache contains a stale password hash.
Container Won’t Start
Common causes: config syntax error, malformed user list, or port already in use.
Query Timeout
Cause: Query exceeded query_timeout.
Notes
- Port: PgBouncer defaults to 56432; ensure firewall rules allow access
- Passwords: After changing a PostgreSQL user password, re-run
pg addon installto reset the auth cache - Config edits: After editing config files manually, restart to apply:
- Transaction mode:
transactionmode does not support session-level features (e.g. temporary tables); usesessionmode instead
12.2 - Etcd
etcd is a distributed key-value store. pgcli can run one or more etcd members as a standalone, top-level addon — shared infrastructure rather than a per-instance sidecar. This is useful as the DCS (Distributed Concurrent Store) backing a PostgreSQL HA stack, or as a general config/lock service.
Security caveat: pgcli-managed etcd members currently run without TLS/CA certificates and without authentication/RBAC — any client that can reach a client port has full read/write access. Plan a deployment around network isolation (loopback binds by default; keep advertised ports inside a trusted network). CA/TLS and auth/RBAC support is on the roadmap for a later release.
Members are managed one pg addon install etcd at a time: the first member
bootstraps a cluster, and later members join the same named cluster on the fly
— from the same host or from another machine.
How It Works
- Shared infrastructure: etcd lives in the top-level
addons.etcdmap inpg.yaml, keyed by member name — not under any single instance. - Host network, configurable bind: each member runs with
--network host. By default its client/peer URLs bind to127.0.0.1— etcd ships without authentication, so loopback-only exposure is the intended posture for a single-host cluster. For cross-host clusters each member advertises a reachable address (--advertise-host) while also listening on loopback, so localetcdctland remote peers both work. - Dynamic membership: the first member starts with
--initial-cluster-state new; each subsequent member is registered withetcdctl member addagainst a running peer, then started with--initial-cluster-state existing. pgcli does this automatically — same-host via a member container, cross-host via a temporary etcdctl container. - Cluster identity: members sharing the same
--clustervalue join the same etcd cluster (it maps to etcd’s--initial-cluster-token). The default ispgcli-etcd. - Baked-in tuning: every member launches with periodic compaction
(
--auto-compaction-mode periodic,--auto-compaction-retention 24h) and an 8 GiB backend quota (--quota-backend-bytes 8589934592) — sane defaults for a small HA metadata store.
Deployment Topologies
pgcli supports two cluster shapes. Both use the same install commands — the difference is whether members share a host.
Single host — all members on one machine (testing / dev)
The classic layout for a dev box or CI: every member listens on loopback with a
different auto-assigned port. No --advertise-host needed (default
127.0.0.1), no firewall changes, nothing reachable from outside the host.
All three belong to cluster pgcli-etcd; m1 bootstraps, m2/m3 join on the fly.
This gives you real 3-node raft semantics but zero host isolation — the whole
cluster dies with one machine, which is exactly why it is a testing topology.
Cross host — one member per machine (production HA)
The production shape: spread members across machines (ideally 3 or 5, an odd
count — see Topology & Quorum). Each member advertises its host’s LAN address;
the first member must bootstrap with --advertise-host so remote peers can
dial it. Ports may repeat on every host since each binds its own interface.
Requirements: every member’s --advertise-host is a mutual-LAN-reachable
address, the firewall on each host lets the other members through on every
member’s client and peer ports (bidirectional peer traffic — e.g. open
2379-2386/tcp for a 4-member cluster on default ports), and all members share
the same --cluster token. Each host survives losing any one member; the
cluster only needs a majority of machines.
| Single host | Cross host | |
|---|---|---|
| Use for | dev, CI, functional testing | production HA |
--advertise-host |
omit (loopback default) | required, every member |
--join |
not used | every member after the first |
| Ports | unique per member on one host | may repeat, one host each |
| Firewall | none (loopback only) | open every member’s client+peer ports, both ways |
| Survives machine loss | no | yes (with quorum) |
Commands
Create the first member
The client port starts at 2379 and the peer port takes the next free port
(2380), auto-allocated from etcd_start_port.
Add members to the same cluster
With m1 running, installing another member of the same --cluster joins it:
pgcli finds a running peer to act as coordinator, registers m2/m3 via
etcdctl member add, and starts them with --initial-cluster-state existing.
Ports continue to auto-assign without collisions (m2 → 2381/2382, m3 →
2383/2384). A freshly-grown cluster briefly lacks quorum during leader
re-election; pgcli retries the membership operations automatically, so you do
not need to wait between installs.
Use a different --cluster to keep members in a separate etcd cluster.
Join a member on another machine
Members on different hosts form one cluster too. Each cross-host member must
advertise an address the other members can reach, set with
--advertise-host. On the machine that already hosts a member, bootstrap as
usual, passing its LAN address:
Then on the new host, point --join at any existing member’s client endpoint.
pgcli registers the member through that endpoint (from a temporary etcdctl
container — no local member container or image needed; the image is pulled on
demand), takes the authoritative ETCD_INITIAL_CLUSTER from the response, and
starts the member:
Notes for cross-host clusters:
- Every member needs a reachable
--advertise-host— including the first. Its advertised peer URL propagates into the cluster’s membership list, so a first member started on the default127.0.0.1can never be joined from another host. If you plan a cross-host cluster, pass the LAN address at bootstrap time. --clusteris required with--joinand must match the remote cluster’s token — membership is checked by that cluster, not by the local config.--advertise-hostis required with--join; without it the other members could register a peer URL they can’t dial.- Ports may repeat across hosts (
2379/2380on both) since each host binds its own interfaces. - A member’s client/peer ports must be reachable between hosts — open your
firewall for every member’s ports, in both directions. Peers dial each
other’s peer ports bidirectionally, and clients (and
--join/pg etcdctl) reach the client ports, so allow both. Auto-assignment means the exact numbers vary per member — read them from the install summary orpg addon list(or pin them with--client-port/--peer-port) and open that range, e.g.2379-2386/tcpfor a 4-member cluster on default ports. Skipping this is the usual cause of a member hanging withetcdserver: no leaderor a--jointhat times out. - Re-running the same
--joininstall for an already-registered name fails with a clear error; deregister first viapg etcdctl member remove <hex-id>against the cluster.
Inspect with pg etcdctl
pg etcdctl runs etcd’s client from a short-lived container — no need to
exec into a member (and it works even on a host with no member
installed, pulling the image on demand). The target comes from
ETCDCTL_ENDPOINTS, falling back to the first configured member:
etcdctl flags that pg’s own parser would reject (-w table, --hex, …) go
after --.
List
etcd members appear under the Infra add-ons (etcd) section:
Start and stop
After a host reboot or a manual stop, bring a member back up without
re-running install — no member add, the existing config and on-disk data are
reused:
start is safe to repeat: an already-running member is a no-op, and a member
whose container was removed is recreated from its data with
--initial-cluster-state existing. For a cross-host cluster, confirm the
member’s peers are reachable (quorum intact) before starting it.
Remove a member
Removal first deregisters the member from the cluster (resolving its hex
member ID — etcd v3.5 member remove takes an ID, not a name), then deletes
the container and the member’s data directory. Remaining members stay healthy
as long as quorum holds.
Cross-host members must be deregistered separately. The automatic
deregistration above only works when another running member of the same
cluster is present in the local pg.yaml — pgcli has no view of members that
live on other hosts. So on a cross-host cluster, removing a member from the
host that runs it deletes the container and data but leaves its entry in the
cluster’s membership list (a “stale” member) — the surviving members keep
trying to peer with the now-deleted host.
Remove it in two steps instead:
pg etcdctl needs a reachable peer to talk to — point ETCDCTL_ENDPOINTS at
a member of that cluster that is still running (on a host that still has a
running member, this falls back automatically; otherwise set it explicitly),
then remove by ID (see Inspect with pg etcdctl).
Verify with pg etcdctl member list afterward: the removed name should be gone.
Parameters
| Flag | Description | Default |
|---|---|---|
--name |
Member name, and the pg.yaml config key |
etcd |
--cluster |
Cluster identity (--initial-cluster-token); same value = same cluster |
pgcli-etcd |
--client-port |
Client host port (0 = auto-assign from etcd_start_port) |
auto |
--peer-port |
Peer host port (0 = auto-assign, next free port) | auto |
--image |
etcd image tag | quay.io/coreos/etcd:v3.5.30 |
--data-dir |
Data dir root — absolute, or relative to base_dir; each member uses <root>/<name>/data |
<base_dir>/addon/etcd |
--advertise-host |
Host advertised in this member’s peer/client URLs (empty = 127.0.0.1 for single-host; set a LAN IP or FQDN for cross-host) |
127.0.0.1 |
--join |
Client endpoint of an existing member to join cross-host, e.g. http://10.0.0.1:2379 (implies --initial-cluster-state existing; requires --advertise-host and --cluster) |
— |
Container name follows the namespace convention:
pgcli-etcd-<namespace>-<name> (namespace omitted when unset).
Data Directory Layout
Data is always laid out as <root>/<name>/data, so multiple members on one
host never share a directory:
--data-dir sets the root. A relative value resolves against the config’s
base_dir; the resolved root is persisted, so the config stays portable:
pg addon remove deletes only that member’s <name>/data dir and leaves the
shared root in place.
Connecting
Use pg etcdctl, which runs etcd’s v3 client from a short-lived container
against a member’s client URL — the target comes from ETCDCTL_ENDPOINTS,
falling back to the first configured member. etcdctl’s native flags go after
--:
pg etcdctl always speaks the v3 API (ETCDCTL_API=3, etcdctl’s default); v2
is not supported.
Topology & Quorum
etcd requires a quorum (majority) of members to accept writes:
| Members | Quorum | Tolerates failures |
|---|---|---|
| 1 | 1 | 0 |
| 3 | 2 | 1 |
| 5 | 3 | 2 |
- Use an odd number of members — 3 or 5 for production HA.
- Removing members can drop the cluster below quorum (e.g. 3 → 1 surviving); it will then be unable to commit writes. Keep a majority running.
Configuration
After install, pg.yaml records each member under the top-level addons.etcd:
Port base is configurable via top-level etcd_start_port (default 2379). A
cross-host member records the address it advertises:
Auto-start on Boot
Containers also carry a --restart unless-stopped policy, which a per-container
conmon monitor enforces even without a podman daemon — it covers crashes but
not host reboots. To bring members up after a reboot, enable autostart
per member:
This sets autostart: true on the member in pg.yaml and installs/refreshes
the boot service (see Auto-start on Boot). Boot is
start-only: it starts the member’s existing container and never re-runs
member add — an initialized member reloads its cluster from disk and rejoins.
On a cross-host cluster, run the command on each host for that host’s
member(s). pg autostart status lists every member’s state.
Notes
- Single-node vs cluster:
--name m1alone gives a one-member cluster; install more members with the same--clusterto grow it. - Production-ready defaults: every member already launches with periodic compaction and an 8 GiB backend quota (see How It Works), suitable for a production DCS without further tuning.
- Reinstall is idempotent: re-running
pg addon install etcd --name <m>on a running member recreates its container with updated flags; a member already registered in the cluster is not re-added.
12.3 - PgDog
PgDog is a high-performance Postgres proxy written in Rust (the successor to PgCat). It provides connection pooling, read/write load balancing across replicas, and horizontal sharding — in front of one or more PostgreSQL backends. pgcli can run PgDog as a standalone, top-level addon: shared infrastructure rather than a per-instance sidecar.
Security caveat: PgDog authenticates clients against plaintext passwords stored in
users.toml. pgcli writes that file mode0600and mounts it read-only into the container, but the passwords still sit in clear text on disk and inpg.yaml. Keep the listen port on loopback (the default) or inside a trusted network, and treat the config file as a secret.
PgDog is configured with two TOML files — pgdog.toml (the proxy, its
backends, and sharding rules) and users.toml (the client credentials).
pgcli generates both entirely from the install flags: there is no config
file to hand-edit, so pg addon install pgdog is the source of truth and is
idempotent — re-running it re-renders the files from the flags given.
Platform support
PgDog runs on Linux (host networking) and on macOS for single-host
dev/test, joining the same pgcli-net bridge a PG instance uses under podman
machine and publishing its client + openmetrics ports. On macOS:
- The listen bind is widened to
0.0.0.0inside the container so the published port is reachable (a loopback-only bind can’t be forwarded to); the client address you connect to is still127.0.0.1:<port>. - Backends must be reachable from the bridge. PgDog has no automatic
name-resolution for
--backend, so a127.0.0.1backend would point at the Mac, not the podman machine VM. Give each--backendeither the target instance’s container name (runpg status -i <instance>and read theContainer:line, e.g.app=pgcli-pg-mypg:5432:...) or an address the Mac can route to — never127.0.0.1for a managed instance.
How It Works
- Shared infrastructure: PgDog lives in the top-level
addons.pgdogmap inpg.yaml, keyed by proxy name — not under any single instance. - Host network (Linux) / bridge (macOS): the proxy listens on its client
port (auto-assigned from
pgdog_start_port, default 7432) plus a Prometheus-styleopenmetricsport on the next free number. On Linux it runs with--network host; on macOS it joinspgcli-netand publishes both ports. - Generated config:
pgdog.toml+users.tomlare written under<base-dir>/addon/pgdog/<name>/and bind-mounted into the container at/pgdog. - Image pinned:
ghcr.io/pgdogdev/pgdog:v0.1.57by default; override with--image.
Install
A minimal single-backend proxy (pooling only — see the note below):
Declare a
--sharded-tableso the example is a complete, self-consistent config (it mirrors the sharding example below). Be aware that with a single--backendthere is nothing to shard across — every row lands on that one backend regardless of the declaration, so this minimal example only demonstrates connection pooling. For real sharding you need two or more--backends with distinct shard numbers (see Sharding and replicas).
--backend NAME=HOST:PORT:DBNAME[:SHARD[:ROLE]]— one[[databases]]entry.NAMEis the logical database clients connect to;DBNAMEis the real database on the backend.SHARDdefaults to 0;ROLEto PgDog’s default (primary, or setreplicafor read routing). Repeatable.--user NAME:PASSWORD[:DBNAME]— one[[users]]entry. TheDBNAMEis the logical database (a--backendname) the user may reach; it defaults to the first backend’s name. Repeatable.- At least one
--backendand one--userare required.
Other flags:
| Flag | Meaning | Default |
|---|---|---|
--name |
proxy / addon key | pgdog |
--port |
client host port (openmetrics takes the next free) | auto-assign |
--host |
listen address | 127.0.0.1 |
--pool-mode |
transaction or session |
transaction |
--workers |
worker threads | 2 |
--default-pool-size |
server connections per user/db pair | 10 |
--image |
PgDog image tag | ghcr.io/pgdogdev/pgdog:v0.1.57 |
Sharding and replicas
Repeat --backend across shards and roles, and declare the shard key with
--sharded-table DBNAME:TABLE:COLUMN:DATA_TYPE:
This puts two shards (each with a primary + replica) behind the logical
database app and routes the users table by its id column.
Provisioning the backends
PgDog routes to databases, tables, and roles that must already exist on the
backends — installing the proxy does not create them. For a local pgcli
instance, use pg exec to create each shard database, its sharded table, and
the login role. With the default instance on a single host (shards as separate
databases on one port), the example below provisions the backends (steps 1–4),
installs the proxy (step 5), and writes through it to show the routing (steps
6–7):
pg exec -i default -- psql -d <db> runs a command against a specific database
in the named instance’s container. The id bigint column matches the
--sharded-table "app:users:id:bigint" declaration above, so PgDog can route
users rows by id. Steps 6–7 make that routing visible: the two shard queries
return different, non-overlapping subsets of the ids you inserted (the exact
split depends on PgDog’s hash), and together they account for every row — no row
lands in both shards. For remote backends, run the equivalent SQL against each
host directly.
The backend role is not optional. By default PgDog authenticates to the
backends using the client’s --user name and its users.toml password. So
every backend must have a login role named appuser (etc.), or connections fail
with password for user "..." is wrong, or the database does not exist, even on
a loopback trust setup where the password is ignored but the role must still
exist. Under password auth (the default for non-loopback scram-sha-256), the
users.toml password must match the role’s.
Decoupling client and backend users. PgDog itself supports connecting to the backends with a different user than the client uses: a
[[users]]entry can carryserver_user/server_password, and a[[databases]]entry can carryuser/password(which take priority). pgcli’s install flags do not currently emit these fields, so throughpg addon install pgdogthe backend role name is always the--username. If you need a shared backend role (a commonpostgressuperuser, or a name that differs from every client user), hand-edit the generatedusers.toml/pgdog.tomland restart the container — but note a re-install regenerates both files from the flags and will discard those edits.
Connecting
The install summary prints the endpoint. Clients connect through the proxy’s
client port, using the logical database name and a --user credential — with
pgcli, that’s a --dsn pointing at the proxy:
Prometheus metrics are served on the openmetrics port:
Writing to shards
How a write lands on the shards depends on the form of the statement. Observed with PgDog v0.1.57:
Single-row INSERT — routed by the shard key. One VALUES tuple per
statement; PgDog hashes the id and sends that row to exactly one shard:
Inserting ids 1–6 as six single-row statements split them across the two
shards (in one run: shard0 got 1,2, shard1 got 3,4,5,6 — the exact split
is PgDog’s hash, not something to rely on). No row lands in both shards.
Multi-row INSERT — broadcast, not split. A single statement carrying
several VALUES tuples is sent verbatim to every shard, so each shard
ends up with every row:
This reported INSERT 0 8 — 4 rows in each shard. The per-shard PRIMARY KEY still holds (the duplicate ids live in different shards, never colliding),
and there is no error or warning: every shard silently grows a full copy of
the batch. Reads through the proxy will then see each id multiple times.
Bulk-loading correctly: loop one statement per row through the proxy (each
routed individually), or skip the proxy and run the load against each shard
directly (COPY via pg exec -i <inst> -- psql -d <shard>).
Configuration
After install, pg.yaml records each proxy under the top-level addons.pgdog:
Port base is configurable via the top-level pgdog_start_port (default 7432);
the openmetrics port is always allocated just above it.
List
Every PgDog proxy appears under the Infra add-ons (pgdog) section with its runtime status, ports, backend/user counts, image, and container name:
Backends / Users are the counts from pgdog.toml / users.toml; Status
reflects the live container (a stopped proxy shows stopped).
Start and stop
After a reboot or a manual stop, bring the proxy up without re-running install (config and users are untouched):
start is idempotent — a running proxy is left alone; a proxy whose container
was removed is recreated from its config directory.
Auto-start on Boot
Containers carry a --restart unless-stopped policy (crashes, not reboots). To
bring a proxy up after a host reboot:
This sets autostart: true and installs/refreshes the boot service (see
Auto-start on Boot). Boot is start-only: it starts the
existing container reading the pgdog.toml/users.toml already on disk, and
never re-generates config or auth. Install the proxy first. pg autostart status lists every target’s state.
Logs
PgDog logs connection-pool events (new server connections, auth, client
connect/disconnect) as structured INFO lines. A repeatedly failing pool with
password for user "..." is wrong, or the database does not exist means step 3
or 4 above was skipped — the backend has no such login role, or its password
does not match users.toml.
Notes
- Reinstall is idempotent: re-running
pg addon install pgdog --name <p>re-renders the config from the given flags and recreates the container. The backend/user/shard lists are fully replaced by this command’s flags — an omitted--backendis removed, not kept. - Plaintext credentials: passwords live in
users.tomlandpg.yaml. Prefer a dedicated low-privilege role for proxy users and restrict network access to the client port. - Write forms differ: single-row
INSERTis routed by the shard key; a multi-rowINSERT VALUESis broadcast to every shard. See Writing to shards. - Not a per-instance sidecar: unlike PgBouncer, PgDog frontends backends by
address in
--backend— it does not have to point at a pgcli-managed instance, and one proxy can span many databases/shards.
12.4 - Garage
Garage is a Rust, AGPLv3 reimplementation of
S3-compatible object storage, built by
Deuxfleurs for small self-hosted and geo-distributed
clusters. pgcli runs it as a standalone, top-level addon with the same CLI
surface as minio, silo and
rustfs (install, list, logs, autostart, password), but its
runtime model diverges from all three in four ways that shape this page:
- A rendered TOML config file, not argv/env. Garage reads
/etc/garage.toml. pgcli renders it under the instance directory and bind-mounts it read-only; the MinIO/silo/rustfs addons pass argv or env. - Two kinds of directory — and multi-disk is native since Garage v0.9.0.
metadata_dir(LMDB) anddata_dir(blocks) are separate bind mounts, andmetadata_diris always exactly one.data_dirtakes either a single path or, natively upstream, a list of{ path = …, capacity = … }entries — one per disk, so a node spreads its blocks across several drives itself rather than needing host LVM/RAID under one big filesystem. pgcli exposes that list as--drive(repeatable); MinIO, silo, rustfs, seaweedfs and garage all take a per-node drive list now — see Multi-disk data dirs and Capacity and sizing. - Four ports, from their own pool. S3 API, node-to-node RPC, the
s3_webstatic site, and the admin/metrics API — allocated together per instance fromgarage_start_port(default 3900), a separate cursor from the shared minio/silo/rustfs pool. - No native TLS — plaintext HTTP only. pgBackRest’s S3 repository forces
HTTPS, so a Garage store meant to receive
archive-pushmust be fronted by the nginx addon — see TLS by fronting with nginx.
Like rustfs, Garage is a good successor route now that the open-source MinIO
repository is archived. pgcli runs it single-node by default
(replication_factor = 1, garage server --single-node) and, with a handful of
cluster flags, as a multi-node cross-host cluster — see Multi-node
cluster.
Platform support: tested on Linux (host networking). The macOS bridge path publishes all four ports and is code-complete, following the same shape as the other object stores, but has not been exercised on a Mac yet.
How It Works
One container per instance. pgcli pulls the upstream
docker.io/dxflrs/garage image directly (pull-only) — unlike rustfs there is no
wrapper image to build, because Garage runs as root inside its own container and
ships no uid-drop, so there is no host-side ownership dance to absorb.
The data lives in bind-mounted host directories that outlive the container:
<base-dir>/addon/garage/<name>/data→/var/lib/garage/data(blocks),<base-dir>/addon/garage/<name>/meta→/var/lib/garage/meta(LMDB),
plus the rendered <base-dir>/addon/garage/<name>/garage.toml →
/etc/garage.toml, mounted read-only (Garage never rewrites its own config).
With --drive (multi-disk), the single data dir is replaced by one host dir per
drive, each mounted at its own numbered container slot /var/lib/garage/data1..N
— the meta dir and the rendered config stay exactly one apiece; see Multi-disk
data dirs.
Credentials are handled like the other stores, but Garage’s are an access key / secret key pair rather than a root user / password:
root_useris the access key id, generated on first install with Garage’s ownGK-prefixed shape (GK+ 32 hex chars, whatopenssl rand -hex 16yields in the quick-start), or supplied via--root-user.root_passwordis the secret key, generated on first install (64 hex chars), or supplied via--root-password.- A third secret,
rpc_secret(the cluster’s RPC signing key), is generated the same way and stored alongside. On a single-node instance it never surfaces in the CLI; on a cluster bootstrap it is printed once — together with the join command — for the operator to carry to the next host (pgcli never executes anything remotely).
All three land in pg.yaml (addons.garage.<name>.*) so a recreate is stable.
On a single-node first boot the container is passed --single-node --default-bucket plus the GARAGE_DEFAULT_ACCESS_KEY /
GARAGE_DEFAULT_SECRET_KEY / GARAGE_DEFAULT_BUCKET env, so Garage itself
creates the access key and one bucket (named after the instance, owned by that
key) — no post-install exec needed. The key and bucket live in metadata_dir, so
they survive container recreation. A cluster member starts bare (no
--single-node/--default-bucket, no GARAGE_DEFAULT_* env); its key and bucket
are seeded explicitly by the install that completes quorum — see Multi-node
cluster.
Install
Supplying your own keys: these two flags only take effect on a single-node install — a cluster member never reads them, because its key comes from
garage key createat the quorum-completing install. The values go to Garage verbatim and Garage is what enforces them: an access key id shorter than 8 characters, or a secret key shorter than 16, is rejected when the container first boots (Invalid default access key: … should be at least N characters long) — and because the container starts detached,installitself reports success while the node crash-loops behind--restart unless-stopped. Checkpodman logs(or just letpg mc alias setfail) if a custom-key install comes up unable to serve. The generated shapes (GK+ 32 hex / 64 hex) clear both bounds with room;mcadditionally requires an access key of[A-Za-z0-9], so stay alphanumeric.
The output reports endpoints and the seeded credentials:
Re-running install against a live instance is a no-op: the container is not
recreated (a stopped one is simply started, with a notice), the flags are merged
into the stored config, and the existing credentials are kept. Pass --force to
recreate the container so changed ports, listen address, or credentials take
effect — without losing the data directories.
Bind address: the default
127.0.0.1keeps the store local.--listen 0.0.0.0(or thelistenkey inpg.yaml) exposes it on the network. Because Garage serves plaintext HTTP, anyone who can reach the port can sniff the credentials in transit — only bind it publicly behind a firewall, or in practice, in front of the nginx TLS front.
--tlsis rejected for garage. Garage has no TLS listener to enable; passing--tls/--tls-cert/--tls-keyerrors out and points you at the nginx front rather than silently ignoring the flag.
TLS by fronting with nginx
Because pgcli does not couple the two addons, terminating TLS in front of a Garage store is an explicit two-step: install garage, then front it with nginx.
Use
--conf-file, not the--upstreamtemplate. S3 requests are signed with SigV4 over theHostheader including the port. The template mode forwardsHost $host, which nginx renders without the port — Garage then re-derives a different canonical request and every write fails withInvalid signature(verified:pg mc mbthrough a template front is refused; through the--conf-filefront below it round-trips). The custom config also lets you setclient_max_body_size 0;(the 1 MiB nginx default otherwise rejects any real object upload) and long proxy timeouts.
--tls makes pgcli generate the self-signed CA + leaf (public.crt /
private.key, mounted at /etc/nginx/certs) and stands up the HTTPS listener.
Point S3 clients at https://<host>:8081 and hand consumers the front’s CA at
<base-dir>/addon/nginx/gtls/tls/ca.crt.
Bind the front
0.0.0.0, leave Garage on loopback. Thelistenline above binds all interfaces so a remote host (e.g. a Patroni node pushing archives) can reach the TLS endpoint — TLS is terminated at nginx, so this is safe in a way that exposing Garage’s plaintext port is not. Theproxy_passstill targets127.0.0.1:<garage-api-port>: the nginx container runs on the host network, so the loopback Garage bind is reachable from it, and the plaintext leg never leaves the machine. For a front that should stay local, bindlisten 127.0.0.1:<port> ssl;instead.
Why the
listenandproxy_passlines are pinned by hand. In--conf-filemode pgcli mounts your config verbatim, so the ports in it must match the ones the two addons were assigned — read the garageS3 API:port from its install output and the nginxHTTPS:port frompg addon list. (The$http_hostandclient_max_body_sizelines are the two that a template front gets wrong for S3.)
Getting that CA onto a remote Patroni host without a manual scp is what
pg backup fetch-ca is for — it dials the TLS front and pulls the root straight
out of the handshake:
The fetch is trust-on-first-use — compare the printed SHA-256 against the front
host’s sha256sum ~/.pgcli/addon/nginx/gtls/tls/ca.crt before trusting it.
Fronting a cluster: one node, or the local nodes
A cluster does not need all members behind the front. S3 replication is the
data layer’s job: a request to any member is complete, and that member
replicates it to replication_factor nodes itself. So the single-node front
above works for a cluster too — point proxy_pass at one member’s S3 port.
Front more members only to remove that member from the access single point of failure. Keep the upstream to the nodes reachable on the front’s host (typically the local site — see Geo-distributed topology), and let each site run its own front:
Two constraints this raises that the single-backend example does not:
- The members’ S3 API must leave loopback. Cluster mode only widens the RPC
bind automatically;
--listenstill defaults to127.0.0.1, so a remote front cannot reach another host’s member. Install the fronted members with--listen 0.0.0.0(behind a firewall — the front is what speaks TLS), or accept that only same-host members are valid upstream entries. - Ports differ per host — same gotcha as the RPC port: each member’s S3 API
port comes from its own install output /
pg addon list(S3 URL:), not necessarily 3900.
This stays a --conf-file config: the --upstream template’s fault for S3 is
unchanged — Host $host without the port breaks every SigV4 write, and there is
no client_max_body_size 0 (see the note above). With several upstream servers
the default round-robin is fine; add max_fails/fail_timeout to taste.
(Verified on a 3-node cluster: pg mc round-trips through such a front, and
stopping one upstream member leaves the front serving through the other.)
Wiring pgBackRest to a fronted Garage store
Once you have a TLS endpoint, a Garage-backed S3 repository is the same as a
MinIO one — the S3 contract is identical (path-style, repo1-s3-uri-style=path):
See Backup → S3 object storage
repository for the full flow.
pg addon password garage --name store prints the secret key for scripting.
Multi-disk data dirs
A Garage node does not need one big filesystem under it. Since v0.9.0 Garage
accepts a list of data directories and balances blocks across them itself;
pgcli exposes that as a repeatable --drive:
What that produces, verified on real disks (a 3-node cluster whose members each took two drives):
-
Each drive is bind-mounted at its own container slot —
/var/lib/garage/data1,data2, … in--driveorder — and the renderedgarage.tomllists them as Garage’s array form: -
The per-entry
capacityis mandatory — v2.4 refuses to boot an entry that carries neither a capacity norread_only = true(invalid data_dir config: data directories in data_dir should have a capacity value or be marked read_only). pgcli has no knob for it, so it fills in each drive’s own filesystem usable size in GiB (rounded down, floor 1) — which is what a “one dir per separately-mounted disk” layout wants, and is what makes Garage weight the spread toward the bigger disk.--capacitystays the node-level number, unchanged by this. -
metadata_dirnever joins the set — it stays the singlemetadir beside the instance’s data path (LMDB is one directory by upstream design). -
--driveand--data-dirare mutually exclusive: drives fully replace the resolved data dir. Re-running install without--driveon a multi-drive instance keeps the stored drives (they merge, like every other key). -
A drive sitting on the host’s root device only warns and is used anyway (as with seaweedfs; MinIO/silo refuse and rustfs hard-requires a separate device) — multi-drive only buys something on separately-mounted disks.
-
pg addon listreports it asDrives: N (multi-disk data_dir)with onecontainer-slot <- host-dirline each; the install summary prints the same mapping asDrive 1: … -> /var/lib/garage/data1lines. -
remove --clean-datagoes through every drive dir (plus meta and the rendered config), each still refusing to delete a live mount point. -
Blocks genuinely land on both dirs: a 48 MiB upload to a 2-drive node left data in both host directories, and a 64 MiB object round-tripped (
sha256sumidentical) through a 3-noderf = 3cluster read from all three hosts. Stop/start preserves the objects; a recreated container reads the same drives.
The one thing this does not get you is drive-level redundancy on a lone node — see Capacity and sizing. Garage’s own rebalancing rules when you add a drive later (existing blocks are not reshuffled; a read-only entry drains opportunistically or via an explicit repair) are upstream Multi-HDD support territory: pgcli writes the config, Garage does the moving.
Multi-node cluster
Garage’s real strength is the cross-host cluster: nodes in different zones,
CRDT-replicated layout, quorum reads/writes. pgcli drives this with the same
pg addon install garage command — no new entry point — adding a handful of
cluster flags. A cluster is assembled one host at a time (the Patroni model):
pgcli never executes anything on a remote host. The first host bootstraps,
prints a ready-made join command carrying the shared rpc_secret and this node’s
id@addr, and you run that command (edited for the next host) yourself.
Roles
garageInstallRole picks the role from the flags you pass:
| Role | When | What install does |
|---|---|---|
| single | no --cluster |
the v1 path, byte-for-byte (--single-node --default-bucket) |
| bootstrap | --cluster set, no --peer |
generates rpc_secret, starts a bare garage server, stages its own layout role, prints the join command |
| join | --cluster + --peer <id@addr> |
carries --rpc-secret, connects to that peer, stages its own role, applies when it completes quorum |
Three-host walkthrough
Host 1 — bootstrap. Give it a --zone, a --capacity, and the eventual
--replication-factor. Because only one node exists so far, Garage refuses to
apply the layout (it needs replication_factor nodes with positive capacity),
and pgcli turns that into a warning, not an error — the staged role is
cluster-replicated and survives until the last host applies it:
A cluster member is a Garage node like any other, so it can also take
--drive for its own disks (see Multi-disk data
dirs) — verified on a 3-host cluster where each member
carried two drives.
The summary prints the secret and a copy-paste join template:
Host 2 — join. Copy the printed command, change --advertise-host to this
host’s IP and --zone to a distinct zone:
Two nodes are assigned now, still fewer than rf = 3, so it warns again. Peering
is transitive: host 2 only needs one --peer (host 1); host 3 can point at
either existing node.
Host 3 — the install that completes quorum. The third host sees the full
staged role set (assign is cluster-replicated), so its layout apply commits
version 1 — and this is the install that seeds the access key and bucket:
Every install runs the seeding step idempotently, so the last host to join
is naturally the one that seeds — no back-fill on host 1 needed. The key name is
the instance name (store), the bucket too, and the key is granted read/write/
owner on it.
Seeding and the secret
In a cluster the single-node --default-bucket / GARAGE_DEFAULT_* fast path is
unavailable, so pgcli seeds explicitly with key create + bucket create +
bucket allow. Two consequences:
garage key createprints the secret exactly once and is not idempotent (a re-run mints a second key with the same name). pgcli checkskey listfirst and only creates when absent. Whichever host performs the seeding writes the access/secret pair into its ownpg.yaml; the other hosts hold only the public key id.pg addon password garageon a non-seeding host has no secret to print and says so — the secret lives in thepg.yamlof the host whose install completed quorum. Reveal it there, or rungarage key createyourself to mint a fresh key on any node.
Version conflicts and re-runs
layout apply --version N requires exactly current + 1 — Garage’s optimistic
lock. If two hosts install concurrently, one apply fails; pgcli reports it as
“layout changed concurrently — re-run the same install command”. assign is
idempotent and re-staging is harmless, so the retry just re-reads the version and
applies. A re-run against a live member is otherwise a no-op (container already
running → skipping creation; peering already done → treated as success).
Zone and replication-factor advice
--replication-factormust be ≤ the number of nodes you will join; a cluster cannot seed untilrfnodes hold a positive-capacity role.- Give each host a distinct
--zoneto survive whole-failure-domain loss; Garage keeps replica copies in different zones when it can. --capacityis GiB and is what Garage carves out for blocks; a node with capacity 0 is treated as not-yet-data-bearing.--advertise-hostis required for every member — it becomesrpc_public_addr, the address peers dial. Without it a cluster member is unreachable. (A cluster member whose--listenis loopback still widens its RPC bind to0.0.0.0; only the S3/web/admin ports stay on loopback.)
Capacity and sizing
Garage’s unit of redundancy is the node, not the drive — every block of a
replicated object is placed on a different node (preferring a different
zone), so no drive or node failure loses data as long as replication_factor
copies survive. What a node does with its own disks is a separate axis, and
Garage handles that natively: since v0.9.0 a node can be given several data
directories and spreads its blocks across them proportionally to their declared
capacities (see Multi-disk data dirs). MinIO and rustfs
aggregate drives with erasure-coding inside a node and survive drive loss that
way; Garage’s multi-drive mode is aggregation without redundancy — losing one
of a node’s drives loses that node’s share of the blocks, and the object is then
repaired from its copies on other nodes. If a store must survive a drive loss on
a single node with no other nodes to repair from, put LVM/RAID under
data_dir instead. MinIO/rustfs manage drives as a pool; Garage manages nodes,
and uses drives only for spread.
--capacity is the number you hand that model: GiB this node may store
(layout assign -c <N>GiB). Two properties worth internalising:
- It is a placement weight, not a quota. Garage never refuses a write
because capacity was reached — the real free space is what
garage statusreports asDataAvail. Setting--capacity 100on a 20 GiB filesystem would just let Garage fill (and then break) the disk. Keep it at (or under) the usable size of the backing filesystem — in multi-drive mode, at the sum of the drives’ usable sizes. - Changing it is a layout change: re-run the same
pg addon installline with the new--capacity(assign is idempotent, the apply bumps the layout version), and Garage rebalances blocks onto the enlarged space. Growing the backing filesystem first is what actually buys room.
Note there are two capacity numbers in a multi-drive node and they are not
the same knob: --capacity is the node’s role in the cluster layout (how much
this node is trusted to hold, shared across all its drives), while each
data_dir entry’s capacity tells Garage how much that drive holds so it can
weight the spread. pgcli fills the per-drive numbers automatically (see below);
only the node-level --capacity is yours to choose.
Usable capacity is the sum of all node capacities divided by the
replication factor — every object is kept in rf copies:
| Nodes | Per node (--capacity) |
rf | Raw total | Usable |
|---|---|---|---|---|
| 3 | 15 GiB | 3 | 45 GiB | 15 GiB |
| 4 | 15 GiB | 3 | 60 GiB | 20 GiB |
| 5 | 15 GiB | 3 | 75 GiB | 25 GiB |
General rule: usable = Σ capacity ÷ rf. The same math is why rf = 2 halves
the storage overhead at the cost of surviving only one node loss; rf = 3
costs 3× but a write commits once 2 of 3 copies are acked, so a 3-node
rf = 3 cluster tolerates 1 node down for writes, a 5-node one tolerates
2 (any 2 of the remaining members can ack). Adding a 4th or 5th node therefore
buys both fault tolerance and usable space, unlike simply mirroring.
Quorum behaviour on a stopped node
Stop any one member and the other two keep serving reads and writes (2/3 quorum
with rf = 3):
pg addon start rejoins the node under the same Node ID (the keypair lives
in metadata_dir, not the container), and garage status shows it healthy
again. Start/stop/remove/autostart/logs always manage only the local
container — the Patroni semantic; there is no remote orchestration.
Geo-distributed topology: 2 local + 1 remote
The production shape Garage was designed for: two members in your own site (one
per rack/zone, z1 + z2) and a third member in a remote site (z3). It
fits the write-latency maths well: an rf = 3 write commits on a 2/3
quorum, so the two local replicas acknowledging is enough — the remote copy
follows asynchronously and a slow (or briefly down) WAN link never slows a
write. The nginx TLS front points at the two local members only (see Fronting a
cluster); the remote member
carries data, not request traffic, and typically runs its own front for local
read repair / disaster use.
Two honest caveats about what the remote member is not:
- It is a replica, not a backup. CRDT replication syncs everything,
including deletions — a mistaken
mc rb --forceor a pgBackRestexpirepropagates to the remote site. For delete-immune copies you need Garage’s own bucket replication between separate clusters (a Garage CLI feature, manual here:garage bucket replication); the 2+1 cluster gives you node- and site-level survivability plus a manual rescue copy, nothing more. - A whole-site outage does not fail over automatically. If both local members
die together, the lone remote node is below quorum (1 < 2) and the cluster is
unwritable. The data is intact on it, and Garage documents a manual
recovery (re-committing a reduced layout from surviving nodes), but pgcli does
not orchestrate that — it is a
garage layoutoperation for an operator who has decided the site is really gone.
So the accurate framing is: 2 local + 1 remote = HA with site-failure rescue, writes stay local-speed, and “backup” in the delete-immune sense remains pgBackRest’s (or replication’s) job.
Getting the cluster’s AK/SK
The seeded access key is cluster-wide (any member validates it), but its
secret exists on exactly one host — whichever install completed quorum
printed it once and stored it in that host’s pg.yaml:
-
Any host can show the public id (
pg addon list→Access key:); on non-seeding hostspg addon passworderrors and names the host that holds the secret — the message is precise, the secret is simply not there to give. -
Lost secret, or want credentials of your own? Mint a fresh key on any member and grant it the bucket — keys are CRDT state, so the new pair works cluster-wide immediately:
This is the recommended day-2 practice: applications and pgBackRest get their own keys (independent rotation/revocation), the seeded one stays the break-glass admin credential.
pg garageis the general passthrough for Garage’s own control CLI — see Administering viapg garage.
v2.0 does not remove a node from the layout (
layout remove+ data migration), revert a committed layout, or orchestrate multi-zone failover. Those stay manual garage CLI operations — run them viapg garage.
Using the mc Client
pg mc runs MinIO’s own mc client from a throwaway container — no local
install, no manual podman run — and speaks Garage’s S3 API like any other
store. Use the access key in place of a user and the secret key in place
of a password:
Aliases persist on the host at ~/.mc/config.json — mc’s own default path, the
same on Linux and macOS — so alias set once and every later pg mc invocation
(and a native mc on Linux) sees the same aliases. pg mc alias list reads them
back and pg mc alias remove drops one. alias set validates the credentials
against the endpoint before writing, so a wrong key leaves ~/.mc/config.json
untouched. Under the hood pg mc mounts your ~/.mc into the container and runs
ghcr.io/mars-base/pgcli/pgcli-mc (the static upstream binary on scratch);
nothing else about it is magic.
Any mc flag that pg’s own flag parser would reject goes after --, exactly
like the other stores — --recursive, --force, --all, --json and so on:
Endpoints by platform: on Linux
pg mcruns on the host network, so a127.0.0.1alias reaches a local addon instance directly. On macOS the container sits on the bridge network, where127.0.0.1is the container’s own loopback — point an alias at a local Mac addon viahttp://GK<...>:<secret>@host.containers.internal:3900, or at a remote store via its routable address. To go over TLS, set the alias to the nginx front’shttps://endpoint instead (and-- --insecurefor a self-signed leaf).
Common commands
| Command | Purpose |
|---|---|
pg mc ls store / pg mc ls store/backups |
list buckets / objects |
pg mc mb store/backups |
create a bucket |
pg mc cp ./file store/backups/ |
upload (for a tree: pg mc mirror ./dir store/backups/, or add -- --recursive to cp) |
pg mc cp store/backups/file ./ |
download |
pg mc find store -- --name '*.pglz' |
search objects by name |
pg mc du store |
size per bucket |
pg mc rm store/backups/file |
delete one object |
pg mc rm store/prefix -- --recursive |
delete many objects |
pg mc rb store/bucket -- --force |
remove a bucket, objects and all |
The same access/secret key pair works for any S3 SDK — including pgBackRest
against a repo1-type=s3 repository (through the nginx TLS front).
Administering via pg garage
pg garage forwards any Garage control-CLI subcommand into the instance’s
running container — the replacement for hand-written
podman exec pgcli-garage-<ns>-<name> /garage …. --name selects the instance
(default: the first by name); everything after the subcommand word reaches
/garage verbatim, so Garage’s own flags need no -- separator. It works the
same on a single-node instance and on any cluster member; on a cluster, ask any
running member — status, layout and keys are cluster-replicated CRDT state.
Any member answers any
pg garagecommand — reads and writes. Every host runs its own garage node, and buckets, keys and the layout are CRDT state that does not belong to any one of them, sobucket create,key create,bucket allow,layout show— and their delete/deny counterparts — produce the same result whichever running member you point at, and the change is visible on all the others immediately. (Verified on a 3-node cluster: a bucket created on host 2 showed up inbucket liston hosts 1 and 3, and a delete issued from host 1 was gone from host 2’s list.) The one member-specific thing is secret retrieval: a seeded key’s secret exists only in thepg.yamlof the host that minted it, sopg addon passwordworks there and errors elsewhere (see Getting the cluster’s AK/SK) — that is pgcli’s local-config boundary, not a garage limitation, and it does not stop you from minting new keys from any member.
Health and status
health is the monitoring-friendly one: it prints a short summary (known /
connected / storage nodes, partition quorum) and sets the exit code, so a
cron or check script can use pg garage --name store health > /dev/null
directly. status is the wide table — watch the DataAvail column, that is the
real free space (see Capacity and sizing).
Keys and buckets
Mint-then-allow is the day-2 credential pattern: applications get their own key, the seeded one stays break-glass (see Getting the cluster’s AK/SK).
Creating a bucket is the same passthrough — bucket create names it and
bucket allow then decides who can use it (a fresh bucket is reachable by no
key until granted). Install-time seeding only makes the one bucket named after
the instance; later buckets are yours to add here:
The bucket’s global alias is its S3 name and is unique across the cluster; ask any running member — the create/delete works from any of them just the same (the any-member note at the top of this section).
Layout changes (cluster)
Adding a node is pg addon install (it assigns and applies automatically); the
raw CLI is for auditing and the manual edge cases pgcli deliberately does not
orchestrate:
layout remove, layout revert and multi-zone failover remain manual garage
operations — the v2.0 limits blockquote at the end of
Getting the cluster’s AK/SK lists them.
Maintenance
Ports
Each instance takes four consecutive ports from Garage’s own pool, starting
at garage_start_port (default 3900): the S3 API first, then RPC, s3_web,
and admin/metrics. The pool is separate from the shared minio/silo/rustfs
pool (that one starts at minio_start_port, default 9000), so a garage, a
minio, and a rustfs instance never collide:
Only the S3 API port is a knob (--api-port); the other three are assigned the
next free ports after it. The RPC port is node-to-node traffic and, for a
single-node instance, stays on loopback; the admin/metrics port (3903) serves
Garage’s status API. A cluster member’s RPC port is what peers dial
(rpc_public_addr), so pgcli widens its RPC bind to 0.0.0.0 even when
--listen is loopback — the channel is signed with rpc_secret. Note the port
number also rides inside --advertise-host/--peer, so on a host that already
runs another garage instance the cluster’s RPC may land on a different port than
3901 (e.g. 10.0.0.11:3905).
No web console — administering via CLI and the admin API
Unlike MinIO (which ships a bundled web Console on a second port), Garage has no built-in web UI. Its management surface is three things:
-
The
garageCLI, viapg garage— the practical way to inspect and change state.pg garageruns Garage’s own control CLI inside the instance’s running container (it talks to the local RPC port), so you neverpodman execby hand:Everything after
--nameis forwarded to/garageverbatim, so Garage’s own flags (--read --write,-z,-c) need no--separator — and they go after the subcommand word, since pgcli only reads its own flags before it. With a single instance installed,--namecan be dropped; with several, omitting it targets the first by name (announced on stderr). This needs a running member — unlikepg mc/pg etcdctl, which spin up a throwaway client container,pg garageexecs the node’s own CLI. See Administering viapg garagefor the full operations menu. -
The admin API on the admin port (
3903) — a JSON API. From Garage v2 the endpoints are/v2/<Operation>(e.g./v2/GetClusterHealth) and require a Bearer token in theAuthorizationheader; a barecurlgets403 AccessDenied. Only a few endpoints are open without a token: -
s3_webon the web port (3902) — this is not a console. It only serves static-website content for buckets that have website hosting enabled (via per-bucket vhost); its root returns404.
Community web UIs exist (e.g. dashboards built on the admin API), but they are
not part of Garage and not managed by pgcli; wiring one up means minting an
admin API token and pointing the third-party app at the admin port. The web_port
(3902) that pgcli assigns is reserved for bucket website hosting, so a third-
party console should talk to 3903 (admin API) or use pg garage
for the CLI — not to 3902.
Configuration
Instances live under the top-level addons.garage map in pg.yaml:
replication_factor defaults to 1. Setting it > 1 without a cluster is
rejected at install — a lone node cannot meet a multi-node quorum. The cluster
keys above are written by the cluster install path; you can also set them by hand
and re-install. Zone and capacity are layout state, not TOML, so they appear only
here.
Edits to listen, ports, root_user, root_password, image_tag, data_dir,
drives, or config_file take effect after the next pg addon install garage --name store --force.
List
Cluster members gain the Cluster / Advertise / Zone/Cap / Node ID /
Nodes lines; Nodes: is a live garage status read (best-effort — it is
omitted when the member is unreachable). A multi-drive instance replaces the
Data: line with Drives: plus one indented container-slot/host-dir line per
drive (see Multi-disk data dirs). On a cluster the
Access key on the hosts that did not perform the seeding is the public id
only (no secret); see Seeding and the secret.
pg addon list never prints the secret key. Reveal it deliberately with
pg addon list --show-password (appends a Secret key: line), or print just the
secret for scripting: pg addon password garage --name store (the secret key,
paired with the Access key: above; --file <path> writes it mode 0600).
Start and stop
install skips a still-present container (starting it if stopped); start only
starts an existing one (and self-heals an improper state by recreating it from
the config, re-rendering garage.toml).
Auto-start on Boot
Containers carry a --restart unless-stopped policy (crashes, not reboots). To
bring instances up after a host reboot:
garage autostart re-renders garage.toml and starts the existing container.
Garage runs as root in the upstream image, so a boot-time start needs no special
host privileges beyond running podman itself. Boot is start-only; garage is
independent of the PostgreSQL stack, so it is started alongside the other object
stores.
Remove
The data directories are the object storage — losing them means losing every
bucket in them — so remove keeps them by default. --clean-data deletes the
data dir(s) (in multi-drive mode, every drive dir), the meta dir, and the
pgcli-rendered garage.toml (a config_file you supplied yourself is never
touched), and prunes the now-empty default-layout parent directory. Each dir is
skipped — with a refusal, not a silent pass — while it is still a live mount
point. Unlike rustfs, Garage writes as root and pgcli runs podman as root on the
managed hosts, so plain removal suffices — no podman unshare fallback.
Logs
Troubleshooting
pulling garage image ... : ...on install. The tagdocker.io/dxflrs/garage:v2.4.1is not reachable — check network access to Docker Hub, or pre-pull it withpodman pull.pg mc alias setfails with a connection error. The default--listen 127.0.0.1binds the store loopback-only; on macOS, usehost.containers.internal, not127.0.0.1. Confirm the live API port withpg addon list.- Writes through the nginx front fail with
Invalid signature. The front rewroteHostwithout the port; useproxy_set_header Host $http_host;(the conf-file recipe does) — the--upstreamtemplate mode hard-codes$hostand does not fit S3. - PUTs through the nginx front fail at ~1 MiB. nginx defaults
client_max_body_sizeto 1m; the conf-file recipe sets it to0(unlimited). - pgBackRest refuses the endpoint (HTTP / cert). pgBackRest forces HTTPS for
S3 repos. Front Garage with the nginx addon (
--tls) and pointbackup.repo.s3.ca_fileat the front’sca.crt. replication_factormust be 1 on a single-node instance. A hand-editedreplication_factor: 2without aclusteris rejected at install — a lone node cannot meet a multi-node quorum. For multi-node, setcluster(or install with--cluster); see Multi-node cluster.
12.5 - PostgREST
PostgREST is a single-process, stateless web server that turns a PostgreSQL
schema into a RESTful API. As a pgcli addon it runs as one container in front
of any PG endpoint — no data directory, no rendered config file, everything
configured through PGRST_* environment variables.
PostgREST is a proxy-type addon like PgBouncer and PgDog: it holds no state of its own. Two deployment modes mirror PgBouncer’s:
- Local:
pg addon install postgrest -i <instance>— stored as a sidecar underinstances.<name>.addons.postgrest, DSN built from the instance. - Remote:
pg addon install postgrest --dsn <dsn> --pg-name <name>— stored in top-leveladdons.postgrest.
The --dsn in either mode is passed verbatim into the container’s
PGRST_DB_URI, so it works against any PG endpoint: a direct managed
instance, a PgBouncer pool, or a Patroni cluster behind its
HAProxy listener.
Platform support
PostgREST works on Linux (host networking, --network host) and
macOS (container joins the pgcli-net bridge and publishes its port, the
same shape as a PG instance under podman machine).
- Local mode on macOS just works — the container reaches the managed instance over the bridge.
- Remote mode on macOS:
--dsnmust be reachable from the Mac itself, not from the podman machine VM. Point it at an address the Mac can resolve (a real host IP, orhost.containers.internalfor a service on the VM). - Clients always reach the REST API at
127.0.0.1:<port>(or--listen); gvproxy forwards the published port to the Mac’s loopback.
How it works
pg addon install postgrestpulls the image (if missing) and starts a container wired entirely fromPGRST_*env vars.- The container reaches the backend PG over the host network (Linux) or the
pgcli-netbridge (macOS). - PostgREST introspects the exposed schema on startup, serves REST requests,
and listens on the
pgrstchannel for a schema-cache reload signal.
PostgREST holds no data on the host — pg addon remove postgrest only
stops and removes the container.
Container name: pgcli-postgrest<ns>-<name> (local mode uses the instance
name as <name>; remote mode uses --pg-name). Namespace isolation applies:
two configs with different namespaces can run independent APIs without
colliding.
Commands
Install
Re-running install is idempotent: an existing container is reused (a stopped
one is started). Pass --force to recreate it after changing ports, listen
address, DSN, db-pool, schema, anon-role, or jwt-secret.
Parameters:
| Parameter | Description | Default |
|---|---|---|
-i, --instance |
Managed instance (local mode) | default |
--dsn |
Backend PG URI (remote mode); → PGRST_DB_URI verbatim |
— |
--pg-name |
Name to identify a remote PostgREST (required with --dsn) |
— |
--schema |
Exposed schema(s), comma-separated; → PGRST_DB_SCHEMAS |
PostgREST default (public) |
--db-pool |
Connections in PostgREST’s pool toward the backend; → PGRST_DB_POOL |
PostgREST default (10) |
--anon-role |
Role unauthenticated requests run as; → PGRST_DB_ANON_ROLE |
— (anonymous access off) |
--jwt-secret |
Secret used to verify Authorization: Bearer JWTs; → PGRST_JWT_SECRET |
— (JWT auth off) |
--port |
HTTP host port | auto, from postgrest_start_port (base 3500) |
--listen |
Bind address | 127.0.0.1 |
--image |
Container image | docker.io/postgrest/postgrest:v16.3 |
--force |
Recreate an existing container to apply changed flags | off |
--db-pooland the backend’smax_connections. Total PG connections a PostgREST deployment opens is roughly number of PostgREST instances ×--db-pool. If you run several replicas behind the same endpoint, budgetmax_connectionsaccordingly.
List
PostgREST appears under Local add-ons / Remote add-ons with Status, REST URL, Backend, Schema, DB pool, Anon role, JWT auth, and Container.
Start / stop / remove / logs
Autostart
Autostart is start-only: at boot the container is brought up reading the
PGRST_* env already in the config (recreated from config if it was removed).
Install the addon first.
Configuration
PostgREST settings live under addons.postgrest (remote) or as an
instances.<name>.addons.postgrest sidecar (local):
Ports are auto-assigned from the postgrest_start_port pool (base 3500) when
--port is omitted; the pool is independent of the PgBouncer / etcd / pgdog /
minio pools.
Recommended: front a Patroni cluster with HAProxy
For a Patroni cluster the recommended shape is PostgREST → HAProxy rw
listener → cluster leader, not PostgREST pointed straight at a member. Pass
the listener as --dsn (remote mode):
Why the listener, not a member’s direct port: a pinned member loses writes after a failover (the old leader stops accepting them, but the DSN keeps pointing there). pgcli detects a member’s direct port at install time and warns, suggesting the HAProxy listener instead.
- Failover self-heal, no restart. When the leader changes, PostgREST just
reconnects through the LB and re-selects the new leader — service resumes
without restarting the container or re-running install. Verified: after a
pg ha switchover, the one in-flight write that hit the demoted connection failed503(SQLSTATE 57P01, connection terminated), and the very next retried request succeeded201against the new leader. Treat that single transient503/57P01during a leader change as expected and retry it. - HAProxy routes by health check, decoupled from who is leader. The rw
listener only admits the member passing the
/primarycheck and the ro listener only the replicas passing/replica, so a failover needs no haproxy reconfiguration — the traffic moves on its own. - Scale-out. Run several PostgREST replicas behind a load balancer; each
adds its own
--db-poolworth of backend connections.
Database-side setup (not managed by pgcli)
pgcli installs and runs the PostgREST container; it does not touch your
database. Before the API serves anything, the database needs the unauthenticated
role PostgREST SET ROLEs to, plus grants on the exposed schema — usually owned
by your migrations, not pgcli. This block is re-runnable:
The role view’s column is
rolname, notrolename— a typo fails the whole batch.ALTER DEFAULT PRIVILEGEScovers only tables created after it runs; existing ones are handled by theGRANT ... ON ALL TABLESline.
PostgREST connects as the DSN user and SET ROLEs to web_anon per request, so
the API carries exactly that role’s privileges: web_anon sees SELECT-only
until you GRANT INSERT/UPDATE/DELETE on the tables you want writable. The
connecting login role itself needs nothing beyond membership of the roles
requests will assume. Then install with --anon-role web_anon. The two ways to
authenticate a request — anonymous (--anon-role) and JWT (--jwt-secret) —
are covered next; without either, every request is refused with 401.
PostgREST caches the schema it introspected. After a schema change, reload the cache:
LISTENand transaction pooling. PostgREST relies on a persistentLISTEN pgrstsession to receive reload notifications. Behind a pooler in transaction pooling mode (PgBouncer’s default) that session-level LISTEN is broken — the notification is never delivered, and neither isNOTIFY pgrstreaching PostgREST via a pooled connection. Verified behaviour: a reload sent through a transaction-pooled PgBouncer does not refresh PostgREST’s cache (a fresh table stays 404), and even aNOTIFYsent directly to the backend is lost because PostgREST’s own listener has no stable connection. Either point PostgREST at a session-pooled or direct connection, or restart the PostgREST container (it re-introspects on boot) to pick up schema changes.
Verifying the API
The install prints the REST URL (http://127.0.0.1:<port>). Create a table in
the exposed schema, then hit the API to confirm the server is live and serves
its rows:
A few things to expect on a fresh install:
- The root can take a moment to answer — schema introspection runs after boot,
so the first requests may
503until PostgREST has connected. - A
404on a just-created table or a401on unauthenticated requests means the schema cache is stale or--anon-roleis unset — see the Troubleshooting table below for the fix.
JWT authentication
--anon-role is the simplest model: every unauthenticated request runs as one
fixed role. --jwt-secret turns on per-request identity. Pass it at install
time (a HS256 shared secret, or a JSON Web Key for RS256); pgcli passes it to
the container as PGRST_JWT_SECRET.
The secret is not written to your shell history if you inline a command substitution as shown. It does land in
pg.yaml(so pgcli can recreate the container) — treat that file as secret-bearing, and re-run install with--forceafter changing it.
Length: for HS256 the secret must be at least 32 characters (256-bit key material; 48 for HS384, 64 for HS512). PostgREST refuses to start with a shorter one —
openssl rand -hex 32(64 chars) is a safe default. A JWK (for RS256/ECDSA) has no such minimum; the key strength comes from the JWK itself.
With a secret set, PostgREST verifies any request that carries
Authorization: Bearer <token> and runs it as the role named in the token’s
role claim:
- The token must be signed with the same secret; a tampered one is rejected 401.
- The
roleclaim must name a database role with grants on the exposed schema (create it likeweb_anonabove,NOINHERIT). - The login role in the DSN must be a member of that role, because serving the
request means
SET ROLEto it. So for a JWT role likeweb_useryou also needGRANT web_user TO <dsn-user>;— same requirement as the anon role above, and likewise skippable when the DSN user is a superuser. Without the membership, requests fail withpermission denied to set role. - Change the claim key from
roleviaPGRST_JWT_ROLE_CLAIM_KEYif your issuer uses another field — pgcli does not surface that flag; set it on the container directly if needed.
--anon-role and --jwt-secret compose: requests with a valid JWT run as
their role claim, requests without one fall back to --anon-role. With
neither flag, every request is refused (401). A typical progression is anon for
public reads plus JWT roles for authenticated writes.
In production your application’s auth service signs the tokens. To hand-craft a test token with the same HS256 secret:
Troubleshooting
| Symptom | Likely cause / fix |
|---|---|
Install fails with cannot connect to source database |
The DSN host:port is unreachable from the container (on macOS, remote 127.0.0.1 points at the Mac, not the VM). Verify with pg exec --dsn <dsn> "SELECT 1". |
Every request returns HTTP 401 Anonymous access is disabled |
No --anon-role was given and the request carried no valid JWT — add --anon-role, or install with --jwt-secret and send a signed token. A JWT 401 with --jwt-secret set means the signature/role claim is wrong. |
A write returns HTTP 401 whose JSON body says 42501 / permission denied for table |
The role the request ran as (anon or the JWT role claim) lacks that privilege — PostgREST surfaces insufficient privilege as 401 at the HTTP layer, with the real SQLSTATE only in the body. Grant the privilege to the role the request assumes. |
A brand-new table/relationship still returns 404/PGRST205 after NOTIFY pgrst |
Reload is not reaching PostgREST (see transaction pooling above). Restart the container, or use a session/direct connection. |
| Writes fail right after a Patroni failover | The DSN pointed at a member’s direct port, not the HAProxy rw listener — install warned about this. Re-point the DSN at the LB. |
--db-pool change didn’t take effect |
Existing containers are reused; re-run install with --force to recreate. |
Related
- PgBouncer — connection pooler (watch the transaction-pooling caveat above when pooling PostgREST’s backend).
- PgDog — pooling / sharding proxy, same dual-mode shape.
- HAProxy — the listener PostgREST’s DSN should target for a Patroni cluster.
- HA Cluster — Patroni topology that
--dsnpoints at.
12.6 - SeaweedFS
SeaweedFS is a Go, Apache-2.0
S3-compatible object store whose defining trait is volume-granularity disk
management: data is split into fixed-size volumes (.vol files) that the
master places across volumeservers, giving O(1) reads and cheap small-file
storage. pgcli runs it as a standalone, top-level addon with the same CLI
surface as garage, minio, silo and
rustfs (install, list, logs, autostart, password, pg mc), but
its runtime model diverges in four ways that shape this page:
- One process, all roles. A single node runs
weed mini— master, volume server, filer and S3 gateway in one process (the officially endorsed single-binary deployment). A cluster member runsweed server -filer -s3, one per host, all roles together — never four containers. - Three rendered files, argv for everything else. pgcli renders
s3.json(the S3 identity) and, for a cluster,filer.toml(the PostgreSQL metadata store) under the instance directory and bind-mounts them read-only; the rest of the configuration isweedcommand-line flags. - Cluster metadata lives in a shared PostgreSQL. Every cluster node fronts
filer + S3, so the filer namespace must be one database, not per-node
leveldb2 — pgcli points all members at one
--dsnPostgreSQL. This is the natural home ground of pgcli: the store can sit next to (or in) a pgcli-managed instance. - Native S3 TLS —
--tls(self-signed) or--tls-cert/--tls-key(BYO, e.g.pg certoutput) — the MinIO posture, not Garage’s nginx-front one. With TLS on, the pooled S3 port serves HTTPS only; the cleartext listener is not bound at all.
Platform support: tested on Linux (host networking). The macOS bridge path is code-complete and follows the same shape as the other object stores, but has not been exercised on a Mac yet.
How It Works
One container per instance, from the upstream
docker.io/chrislusf/seaweedfs:4.48_large_disk image (pull-only). pgcli
overrides the image’s ENTRYPOINT and runs /usr/bin/weed -logtostderr=true <subcommand> … directly: the stock entrypoint would chown -R /data and drop
to uid 1000 on every start — on a large data directory that is exactly the
silent slowdown shape pgcli avoids, and without -logtostderr weed logs to
files inside the container where pg logs cannot see them.
Host state lives under <base-dir>/addon/seaweedfs/<name>/:
data/→/data(volumes and, single-node, the filer’s leveldb2 metadata) — replaced by one bind mount per drive (/data1…/dataN) in multi-disk mode, see Multi-disk,s3.json→/etc/seaweedfs/s3.json:ro(the S3 identity; 0600 on the host),filer.toml→/etc/seaweedfs/filer.toml:ro(cluster /--dsnonly; the[postgres2]section rendering the metadata DSN),cert.pem/key.pem→ mounted under/etc/seaweedfs/when TLS is on.
Credentials are an access key / secret key pair rendered into s3.json
(SeaweedFS’s static-identity mechanism — AWS_* env is lower priority and gets
overridden by the local store, so pgcli never uses it):
root_useris the access key id — on a single-node install it is generated on first install (SW+ 28 hex chars) or supplied via--root-user.root_passwordis the secret key (64 hex chars), generated or supplied via--root-password.- Under
--clusterboth are required and must be identical on every node — see Multi-node cluster.
The identity is rendered with the bare Admin action, which in SeaweedFS’s
permission model short-circuits to full access. All values land in pg.yaml
(addons.seaweedfs.<name>.*) so a recreate is stable. The install also renders
one bucket — named after the instance (--bucket overrides): single-node weed mini -bucket creates it at boot; a cluster seeds it through the S3 API with
mc mb --ignore-existing once the node’s health gate is green.
Install
The single-node output reports endpoints and the minted credentials:
The install ends with a health gate: pgcli polls the master’s JSON
/cluster/status API for up to 30 s before declaring success — a detached
container with --restart unless-stopped would otherwise swallow a bad argv
into a crash loop behind a green-looking install. Only after the gate is green
does the bucket seeding run.
Re-running install against a live instance is a no-op: the container is not
recreated (a stopped one is simply started, with a notice), flags are merged
into the stored config, and existing credentials are kept. Pass --force to
recreate the container so changed ports, listen address, or credentials take
effect — without losing the data directory or the metadata database.
Bind address: the default
127.0.0.1keeps the store local;--listen 0.0.0.0(or thelistenkey inpg.yaml) exposes it on the network. A cluster member always binds0.0.0.0regardless of--listen— peers must reach its gRPC twin (port + 10000) for Raft — while-ipstill registers the single address peers dial.
-ipis never left to auto-detection. weed registers its volumes under whatever address it guesses; the filer/S3 data path then dials that guess and uploads 500 on an otherwise healthy-looking container. pgcli passes-ipexplicitly (--listenvalue single-node,--advertise-hostin a cluster) — verified the hard way on a test host.
TLS
SeaweedFS serves HTTPS natively on the S3 port; there is no nginx front and no web-console leg to proxy. Two shapes, same as minio/rustfs:
What TLS changes under the hood (verified): the cleartext listener is off
(-s3.port=0 — the pooled port simply refuses TCP connections), and the same
pooled S3 port becomes -s3.port.https. So the endpoint printed at install is
https://…:8403, not an extra port. Point pg mc at it as usual — the loopback
alias gets --insecure automatically; for a real or generated CA, pass its
ca.crt to your S3 client.
Multi-disk
A single node can use several disks: weed’s own -dir is a comma list
(dir[,dir]…) that one volume server stores its volumes across, and pgcli
exposes it as a repeatable --drive — one host directory per disk, each
bind-mounted at its own slot:
The summary and pg addon list then show the mapping instead of a single
Data: line:
and the container runs with -dir=/data1,/data2,/data3 (verified: the volume
server’s /status reports each host directory as its own DiskStatuses entry
with independent disk accounting, and remove --clean-data walks the drives
one by one, refusing any that is still a live mount point).
Two semantics worth knowing:
- Placement, not RAID. This is one volume server spreading
.volfiles across its directories — no striping or mirroring at the OS level, and no erasure coding between the drives either (the cluster replication story is orthogonal — see Multi-node cluster). A drive that disappears takes the volumes on it; whether that loses data depends on the replication code, not on the local layout. - Advisory device check.
installwarns about any drive sitting on the same device as the host root filesystem — multi-disk only pays off on separately mounted disks. Unlike MinIO (which refuses such a drive at startup), weed happily uses them, so pgcli warns and continues. Format and mount each disk first; pgcli never partitions or formats anything.
Drives are a per-node knob, and they compose with the cluster: pass
--drive on each member’s install command and every node runs its own
multi-disk volume server behind the shared cluster (verified on a 3-host
weed server cluster where each member mounted two drives — every member’s
process showed -dir=/data1,/data2 with two independent DiskStatuses
entries, and the 001 replica of one 8 MB volume landed on the real disk of one
node and on the other drive of the replica node, i.e. each node’s volume
server picked among its own dirs while placement chose across nodes). The
nodes do not need the same drive count; each account only sees its own -dir
list. Stop/start and --force recreate handle the drive set like any other
config change — the mounts and -dir are rebuilt from pg.yaml.
Multi-node cluster
The cluster is MinIO-MNMD shaped, not Garage’s bootstrap/join: there is no
first node, no join command with a carried cluster secret, no layout to
assign. You run the same install command on every host — Raft membership
is the full --peer list every node passes, and a node that starts before
quorum simply waits (its health gate warns instead of failing; the install that
completes quorum finishes the bucket seeding).
Two things differ from a bigger single node, and both are shared by definition — every member’s command carries them identically:
- One credential pair for the whole cluster. Each gateway authenticates
against only its own rendered
s3.json; SeaweedFS has no shared-credential backend without a standalone IAM server (out of pgcli’s v1 scope), so per-node auto-generation would leave the three gateways accepting three different identities. pgcli therefore refuses to auto-generate under--cluster: pass the same--root-userand--root-passwordon every node. Run the first node’s install with them, copy the pair from its printed summary, and carry it verbatim to the rest (pgcli never executes anything remotely). A half pair (only one of the two) is an error; so is re-running a single-node config’s install with--cluster. - The shared filer metadata database.
--dsn postgres://user:pass@host:port/dbis required, must be dialable from every host — so use the host-published port / LAN address, never a container-local127.0.0.1:5432— and the database itself must already exist: pgcli only probes reachability, it never creates (or drops) databases it does not own. The metadata tables need no setup: weed’spostgres2store creates.system,filemetaand one table per bucket inside it on first use (verified).sslmode=disableis rendered intofiler.toml, so reachability +pg_hbais all that must line up.
Three-host walkthrough
Prepare the metadata database once, anywhere all nodes can reach (here a pgcli-managed instance on host 1, host port 35432):
Then, on each host, the same command with that host’s own
--advertise-host (keys below are placeholders — use one pair everywhere):
Add --drive to that same command to give each member its own multi-disk
volume server (--drive /mnt/sw-disk1 --drive /mnt/sw-disk2 — see
Multi-disk); it is a per-node flag, so hosts may carry
different drive sets. Verified on a 3-host cluster.
The summary ends with a copy-paste block for the remaining hosts with exactly that shape (credentials and DSN filled in from this node’s config).
What the first installs say while the cluster is still assembling is normal:
- Host 1 comes up with a leader already visible (a lone weed master with an
odd peer count can still win its own election here — the observed behaviour
with a 3-node list is host 1 leading immediately), but the bucket seed may
fail with a connection refused: the cluster’s S3 side is not serving yet.
pgcli prints
[!] bucket seeding incomplete … re-run this install command once the cluster has quorumand exits 0. - Host 2 (or 3) completes quorum; the same idempotent seed succeeds there
(
Bucket created successfully). Re-running host 1’s command afterwards re-checks quietly andmb --ignore-existingstays a no-op.
After all three, any member shows the whole cluster:
Reading and writing through any node
Because credentials are shared and the filer namespace is one database, any
gateway serves any object: write through host 2’s S3 port and read back
through host 3’s with the same key — verified end-to-end on a 3-host test
cluster, including a wrong-secret attempt coming back as
The request signature we calculated does not match (it is not a
per-node local trust store). --replication 001 keeps one remote copy of every
volume (volume.list shows ReplicaPlacement:001 and each volume on two
data nodes).
Stop one member and the cluster keeps serving (Raft quorum is a majority; with 3 masters, 2 keep electing and writing):
pg addon start rejoins the node; its volumes re-register under the same
-ip. Changing cluster membership is not orchestrated: the --peer list
is static Raft configuration — add or remove a member by rebuilding the
cluster (v1 limitation, see Limitations).
Getting the cluster’s AK/SK
The pair is by design present in every node’s pg.yaml (you carried it
there), so unlike garage’s CRDT key — whose secret lives on exactly one host —
pg addon password seaweedfs --name store works on any member. If the printed
secret is lost everywhere, pick a new pair and re-install each node with
--force (the rendered s3.json is rewritten; data and metadata are
untouched).
Using the mc Client
pg mc runs MinIO’s mc from a throwaway container — no local install — and
speaks SeaweedFS’s S3 API like any other store. Use the access key in place
of a user and the secret key in place of a password:
Aliases persist in ~/.mc/config.json; flags pg’s parser would claim go after
-- (pg mc rm store/scratch -- --force). On macOS point the alias at
host.containers.internal:<s3-port> instead of 127.0.0.1. Under TLS, use the
https:// endpoint (loopback aliases are auto---insecure).
Administering via pg weed
pg weed runs SeaweedFS’s own weed control tool inside a running member’s
container — the replacement for hand-written podman exec pgcli-seaweedfs-<ns>-<name> …. --name selects the instance (default: the
first by name); pgcli’s own flags must come before the subcommand word, and
weed’s flags pass through untouched. Two shapes:
The weed shell menu is the day-2 surface (command names verified against 4.48
— the shell help lists far more):
weed CLIs can print their result and then linger with open gRPC channels;
pgcli cuts such a command off after its output has been quiet for 3 s (10-minute
overall cap), so a hang surfaces as a loud timeout instead — re-run it.
Ports
Each instance takes four consecutive ports from SeaweedFS’s own pool,
starting at seaweedfs_start_port (default 8400): master, volume, filer,
s3 — and every role also opens a gRPC twin at http port + 10000 (8400 →
18400, …), which pgcli reserves in the pool too. A second instance therefore
lands on 8404-8407 / 18404-18407, and a seaweedfs, a garage and a minio
instance never collide (separate pools: 8400 / 3900 / 9000):
--port sets only the master anchor; the rest follow. Only the S3 port is a
client endpoint; master 8400 also serves the /cluster/status JSON API and the
operator UI pages (the admin UI and webdav are switched off in the argv, and
iceberg/lance side listeners are pinned to 0 so weed opens no extra holes).
Configuration
Instances live under the top-level addons.seaweedfs map in pg.yaml:
Edits to listen, ports, root_user, root_password, image_tag,
data_dir, drives, filer_dsn, or the TLS material take effect after the
next pg addon install seaweedfs --name store --force. In a cluster the force-recreate
must be run on every member (each host owns its own pg.yaml and container).
List
Cluster members gain the Cluster / Peers / Leader / Filer store lines;
Leader: is a live /cluster/status read (best-effort — omitted when the
member is not running, and printed as (none yet — still electing / no quorum)
while a still-assembling cluster has no leader). Single-node members show none
of those four lines. A multi-disk instance shows the Drives block (with one
- /dataN <- host line per drive) in place of the single Data: line — see
Multi-disk. With TLS on, a final TLS: native S3 HTTPS on 8403 (cleartext listener off) line is appended.
pg addon list never prints the secret key; reveal it deliberately with
pg addon list --show-password (appends a Secret key: line), or
pg addon password seaweedfs --name store (--file <path> writes it mode
0600). The Filer store line is host:port/db with the password always
hidden.
Start and stop
install skips a still-present container (starting it if stopped); start only
starts an existing one — and re-renders s3.json/filer.toml first, so
hand-edits to the stored config take effect on restart.
Auto-start on Boot
Containers carry a --restart unless-stopped policy (crashes, not reboots). To
bring instances up after a host reboot:
Autostart is start-only — the rendered files and the container already exist. A cluster member that boots before its peers just waits for quorum like any other cold start. SeaweedFS runs as root in its upstream image, so a boot-time start needs no special host privileges beyond running podman itself.
Remove
The data directory is the object storage — losing it means losing every
volume in it — so remove keeps it by default. --clean-data deletes the data
dir, s3.json, and the filer.toml / TLS directory when present (each only
after a stat — absent optionals print nothing), and refuses to touch anything
that is a live mountpoint. The filer metadata database is never dropped, on
any host, ever: the removal prints
so you know exactly what to DROP DATABASE if the data is truly done with
(every member’s remove prints the same notice; the database outlives all of them).
Logs
Limitations
What v1 deliberately does not do:
- Cluster membership changes (add/remove/replacement nodes) are not
orchestrated —
--peeris a static full list; changing it means rebuilding the cluster. - EC erasure coding, tiering, cloud-sync (replication to other S3s), FUSE
mounts, and the standalone IAM server (dynamic
s3.accesskey.*users) are not managed — all remain manualpg weedterritory, and IAM in particular is the only way SeaweedFS itself shares credentials dynamically, out of scope here. - Credential rotation is a re-install with
--forceon every member; pgcli never edits a lives3.jsonin place. - One bucket is seeded (instance name); further buckets are plain
pg mc mbaway — they land in the same filer database automatically. - The
filer.tomlDSN renderssslmode=disable; if your PostgreSQL demands TLS on that leg, pgcli’s render does not fit it (BYO--dsnwith TLS is not a v1 surface).
Troubleshooting
requires explicit shared credentialson a--clusterinstall. Expected by design — run the first node with an explicit--root-user/--root-passwordpair and carry the same values to every remaining host (the install summary prints the ready-made command with them filled in). Single-node installs may omit them and get auto-generated keys.requires a shared filer metadata database: pass --dsn …. A cluster without--dsncannot keep one namespace across gateways. Pre-create the database and pass a host-reachable URI (published port / LAN address — a container-local127.0.0.1:5432is not).[!] no leader yet — normal while the cluster is still assembling. The health gate’s quorum warning, not a failure: finish the remaining members' installs (or re-run this one once they are up) and it goes green.[!] bucket seeding incomplete … re-run this install command. Same posture one step later: the node reached a leader but its gateway was not serving yet. Re-run the identical install after the last member joins;mb --ignore-existingmakes it a clean no-op if the bucket already landed.- Uploads 500 on a healthy-looking single node. Historically the missing
-ipsignature (volumes register an auto-guessed address the data path then cannot dial); pgcli always passes-ip, so if you see it, check for a hand-edited argv or a very old binary. mc: … you already own it. A baremc mbagainst an existing bucket; pgcli’s own seed uses--ignore-existing, so see this only from manualmcuse — add the flag.- Wrong-secret confusion:
The request signature we calculated does not match. The pair does not match the node’s rendereds3.json— in a cluster, that means the nodes were installed with different credentials; re-run the install with the same pair +--forceeverywhere. pulling seaweedfs image …fails on install.docker.io/chrislusf/seaweedfs:4.48_large_diskunreachable — check Docker Hub access orpodman pullit first.
12.7 - RustFS
rustfs is a Rust reimplementation of
S3-compatible object storage: the same S3 API, a web console, erasure-coded
multi-drive and multi-node layouts — and a completely different runtime model
from MinIO/silo. pgcli runs it as a standalone, top-level addon with the
same CLI surface as minio and silo (install, TLS,
BYO certs, drives, logs, autostart), sharing the one port pool, but with three
differences that shape this page:
- A fixed container user. Upstream rustfs bakes
User=rustfs(uid/gid 10001) into the image. pgcli works around this entirely inside its own wrapper image — see Privileges and ownership below — so a rustfs store needs no host-side ownership dance. - Three topologies, no multi-node single-drive. rustfs speaks SNSD / SNMD / MNMD only — see Deployment modes.
- Its own TLS filenames. rustfs reads
rustfs_cert.pem/rustfs_key.pemfromRUSTFS_TLS_PATH, not MinIO’spublic.crt/private.key.
Pick rustfs when you want a lean, Rust-native S3 endpoint; it can coexist with minio and silo on one host (all three draw from the same port pool).
Platform support: the rustfs addon is Linux only, in practice. The runtime model (a fixed container uid, drives on distinct block devices, host networking) is a Linux container story and is only exercised on Linux; the macOS bridge path is code-complete and the wrapper image is dual-arch, but it has not been tested on a Mac yet.
How It Works
One container per instance. The data lives in a bind-mounted host directory
(default <base-dir>/addon/rustfs/<name>/data, or one directory per --drive),
so it outlives the container. Unlike the minio/silo addons, pgcli does not
drive the rustfs binary directly — it runs the image’s own /entrypoint.sh
(via the wrapper below) because that entrypoint is what expands the multi-drive
brace range in RUSTFS_VOLUMES, creates the per-drive directories, and assembles
the server argv.
The image pgcli pulls is not the bare upstream one — it is
ghcr.io/mars-base/pgcli/pgcli-rustfs:1.0.0, a thin pgcli wrapper built on top
of docker.io/rustfs/rustfs:1.0.0 (the first GA release). pg addon install rustfs just pulls the wrapper; pgcli never builds it at run time. The tag tracks
the pinned upstream version.
Credentials are handled like minio/silo:
root_userdefaults toadmin(rustfs imposes no minimum key length; anything non-empty works, andadminsidesteps therustfsadminwarning);root_passwordis generated on first install (or supplied via--root-password), stored inpg.yaml(addons.rustfs.<name>.root_password) and printed once in the install summary.
They are rustfs’s root access key / secret key, passed via the
RUSTFS_ACCESS_KEY / RUSTFS_SECRET_KEY env vars — what every S3 client
(including pg mc alias set) calls the access key and secret key.
Privileges and ownership
This is the one place rustfs genuinely differs from minio/silo, and pgcli has absorbed it into the image so it is invisible in practice.
Upstream rustfs runs as a fixed, non-configurable uid/gid (10001). Under
rootless podman a host user has no claim on that uid, so the naive way to make a
bind-mounted data directory writable by the process is a host-side chown dance
(podman unshare chown 10001:10001 …) — fragile, and it fights with the fact
that the operator may not be root. pgcli sidesteps all of it:
- The wrapper image’s entrypoint starts as container root,
chowns its own bind-mounted data directories to 10001, thensu-drops to therustfsuser andexecs the unmodified upstream/entrypoint.sh. pgcli passes no--userflag and does no host-side ownership work at all. - The result is the same on both daemon modes; only the host-visible numeric
uid differs, because that is a property of podman’s user namespace, not of
pgcli:
- rootful podman → the directories land on the real host uid
10001; - rootless podman → they land on a subordinate host uid
(
subuid_start + 10001, e.g.110001), with10001only visible inside the container.
- rootful podman → the directories land on the real host uid
Neither case needs the operator to be root or to run chown by hand. The one
convention still worth knowing is that when you mount drives yourself
(--drive), the mount point should be owned by the user who runs pg — under
rootless podman a root-owned mount point is outside the namespace’s uid range and
cannot be claimed by the in-container chown, exactly as with any bind mount.
TLS is copied, never re-owned. rustfs must read its key+cert as 10001, but
the cert directory pgcli generated (0700, pgcli-owned) has to stay pgcli-owned
so pg cert, CA refresh and pg backup fetch-ca keep working on it. So the
wrapper does not chown that directory: it mounts it read-only and, as
container root, copies the two required files into a fresh container-local
directory (owned by 10001) that RUSTFS_TLS_PATH points at. No host file is
ever mutated, and the BYO path uses the same copy mechanism, so BYO key/cert are
mounted read-only at the required names and never chowned either.
Install
The output reports endpoints and the root credentials:
Sign in to the console at the Console: URL with the printed root user and
password. Point S3 clients (including pgBackRest) at the S3 API: URL, or
drive them from the terminal with pg mc.
Re-running install against a live instance is a no-op: the container is not
recreated (a stopped one is simply started, with a notice), the flags are merged
into the stored config, and the existing root password is kept. Pass --force
to recreate the container so changed ports, listen address, endpoint list, or
credentials take effect — without losing the data directory.
Bind address: the default
127.0.0.1keeps the store local.--listen 0.0.0.0(or thelistenkey inpg.yaml) exposes it on the network. Anyone who can reach the port can then attempt the root credentials, so only do this behind a firewall or with TLS (--tls).
TLS (--tls)
--tls makes rustfs serve HTTPS. pgcli generates a self-signed CA and a leaf
cert with the stdlib — SANs cover the loopback names, localhost, and every NIC
IP of the host — into <base_dir>/tls/rustfs/<name>/: rustfs_cert.pem /
rustfs_key.pem (rustfs’s required names, also mirrored as public.crt /
private.key for the CA-friendly tooling) and ca.crt for distribution. The
cert dir is mounted read-only and copied into the container as described in
Privileges and ownership; the endpoint URL becomes
https://.
Why you need it: pgBackRest forces HTTPS for S3 repositories, so a rustfs
meant to receive Patroni archive-push must speak TLS. Point
backup.repo.s3.ca_file at ca.crt and pgBackRest connects with full
certificate verification — see Backup → S3 object storage
repository. Everything that page
says about a MinIO repo applies to a rustfs one: the S3 contract is identical
(path-style, repo1-s3-uri-style=path).
Getting the CA onto a remote host — no scp needed. The server cert is served as a leaf+CA chain, so a consumer on another machine can pull the root straight out of a TLS handshake:
The fetch is trust-on-first-use — compare the printed SHA-256 against the store
host’s sha256sum ~/.pgcli/tls/rustfs/<name>/ca.crt before trusting it.
Bring your own certificate (--tls-cert / --tls-key)
--tls only ever serves pgcli’s own self-signed pair. To serve a certificate you
already hold, pass --tls-cert <leaf(+chain).pem> --tls-key <key.pem>. Together
with --tls (implied), this replaces the generated certs: the two files are
mounted read-only and copied into the container at the names rustfs requires
(/opt/rustfs/certs/rustfs_cert.pem, …/rustfs_key.pem); pgcli never re-owns
them and never writes to their source directory.
No certificate yet? pg cert mints a self-signed leaf with whatever SANs you
name, and rustfs serves it as-is (verified on a real install: the leaf rustfs
presents matches the pg cert output off the live handshake, and pg mc does a
full put/get round-trip against it over TLS). See Self-Cert
for the mint flags, client trust-anchor, and renewal.
Everything else about BYO mode — why renewal needs --force (a single-file
mount pins the source inode), how clients pick their trust anchor, and pg cert
as a test-cert mint — is identical to the MinIO addon; see MinIO → Bring your
own certificate. Turning BYO off: remove
cert_file/key_file under this addon in pg.yaml and recreate with
pg addon install rustfs --name store --force.
Deployment modes
rustfs has three layouts — there is no multi-node single-drive mode:
| Mode | Shape | Use it for |
|---|---|---|
| SNSD (single-node, single-drive) | one node, one data directory — the default when no --endpoint or --drive is given |
dev, test, demos |
| SNMD (single-node, multi-drive) | one node, several drives — --drive per drive, see SNMD |
surviving a disk loss on a single host |
| MNMD (multi-node, multi-drive) | several nodes, several drives each — --drive for this node’s drives + the --endpoint list |
surviving a disk loss and a node loss |
There is deliberately no MNSD (multi-node single-drive) in rustfs. Passing
--endpoint without --drive is rejected at install time: rustfs derives its
per-drive volume range itself, so a distributed node must say how many drives it
has.
rustfs hard-requires distinct physical disks. Under SNMD/MNMD, every
--drivemust sit on its own block device. If two drives share a device (st_dev), the rustfs process[FATAL]s at startup. pgcli launches detached, sopg addon installstill exits 0 and the failure surfaces as a container that never becomes healthy — checkpg logs addon rustfs --name storeif a multi-drive install won’t come up. (This is stricter than MinIO/silo, which only warn.)
Single-Node Multi-Drive (SNMD)
One rustfs process, several host directories, erasure-coded across them. Pass
--drive once per drive instead of --data-dir:
Each --drive is a host directory on its own device; drive N (0-indexed) is
bind-mounted at container path /data/rustfsN, and RUSTFS_VOLUMES is set to
the brace range /data/rustfs{0...3} that the image’s /entrypoint.sh expands
into the individual directories. --drive is mutually exclusive with
--data-dir; combining --drive with --endpoint is MNMD.
pg addon remove rustfs --name store --clean-data deletes each drive directory
— but refuses any drive that is still a mount point, so an accidental
--clean-data can never rm -rf through a live mount into the disk below.
Unmount first if the data below is really disposable.
Distributed / Cluster Mode (MNMD)
Every node runs its own pgcli with its own pg.yaml; each pg.yaml carries the
same full endpoint list and the same root credentials. For rustfs the
--endpoint list is one scheme://host:port per node (no path — rustfs
derives the /data/rustfsN volume range from --drive), and each node passes
its own drives:
Two things matter beyond the identical config: --listen must be 0.0.0.0 (or
the node’s own reachable address), not the loopback default — under podman host
networking the endpoints advertise each node’s real IP, and a node listening
only on 127.0.0.1 can never be joined. And every node must carry the same
endpoint list and root credentials: rustfs derives one shared erasure set from
them, so a mismatch splits the ring into independent standalones.
--endpoint values are scheme://host:port with no path, repeated once per
node (a comma-joined --endpoint a,b,c is equivalent — it is a string-slice
flag). pgcli appends the per-node /data/rustfs{0...N-1} drive range and joins
the four URLs with spaces into a single RUSTFS_VOLUMES. (The
RUSTFS_VOLUMES value itself must be space-joined, not comma-joined: the rustfs
binary mis-splits it, collapsing http:// to http:/ so a node can’t
resolve its own disks — the first-listed node aborts with VolumeNotFound and
the peers hang at waiting for storage_quorum, never forming a writable
cluster. Space-separated literals parse exactly like the documented compact
http://node{1...4}:9000/data/rustfs{0...3} brace form, but let you use plain
per-node IPs with no /etc/hosts or DNS.)
Cross-host MNMD is verified on a real 4-node × 4-drive cluster (Linux, rootful
podman): all four serve /health 200, and an object written once reads back
byte-identical from every node — the erasure shards spread across all 16
drives.
Using the mc Client
pg mc runs MinIO’s mc client in a throwaway container and speaks rustfs’s S3
API like any other store:
The S3 data plane (PUT/GET) is proven end-to-end against rustfs via pgBackRest
and a basic mc round-trip, including a full PITR sequence — base backup,
archive-push of the WAL, and a --time restore that replayed the archived WAL
out of rustfs and stopped exactly at the target (Linux, rootless podman). Note
honestly: the administrative mc surface (alias-set validation, bucket
policies, admin commands) has not been exhaustively exercised against rustfs.
Use it for plain object put/get with the understanding that some
MinIO-specific admin commands may not map onto rustfs.
Ports
Each instance takes two consecutive ports from one pool, starting at
minio_start_port (default 9000): the S3 API first, the console second.
The pool is shared across minio, silo, and rustfs — all three are assigned
from one cursor, so they coexist on a host without collision (minio first by
name, then silo, then rustfs):
Configuration
Instances live under the top-level addons.rustfs map in pg.yaml:
Edits to listen, ports, root_user, root_password, image_tag, data_dir,
tls/cert_file/key_file, or endpoints take effect after the next
pg addon install rustfs --name store --force.
List
pg addon list never prints the password. Reveal it deliberately with
pg addon list --show-password (appends a Root password: line), or print just
the secret for scripting: pg addon password rustfs --name store (the access-key
secret, paired with the Root user: above; --file <path> writes it mode 0600).
Health is on
GET /health (returns 200), unlike MinIO’s /minio/health/live.
Start and stop
install skips a still-present container (starting it if stopped); start only
starts an existing one (and self-heals an improper state by recreating it from
the config). In TLS generated mode start also re-validates and, if needed,
re-signs the leaf.
Auto-start on Boot
Containers carry a --restart unless-stopped policy (crashes, not reboots). To
bring instances up after a host reboot:
rustfs’s fixed container uid is handled inside pgcli’s wrapper image, so a boot-time start needs no special host privileges beyond running podman itself. Boot is start-only; rustfs is independent of the PostgreSQL stack, so it is started last.
Remove
The data directory is the object storage — losing it means losing every
bucket in it — so remove keeps it by default. --clean-data deletes it (and
prunes the now-empty default-layout parent directory). Under rootless podman the
objects are owned by a subordinate uid that plain rm cannot unlink, so
--clean-data transparently falls back to podman unshare rm to reclaim them.
Logs
Troubleshooting
pulling rustfs image ... : ...on install. The wrapper tagghcr.io/mars-base/pgcli/pgcli-rustfs:1.0.0is not reachable — check network access to ghcr.io, or pre-pull it withpodman pull.- A multi-drive install won’t become healthy. rustfs
[FATAL]s when two drives share a physical device. Checkpg logs addon rustfs --name storefor the disk check firing, and make sure each--driveis on its own device (a separate disk or its own loop mount). - Rootless podman can’t write a drive. A drive’s mount point is owned by
root, outside the namespace’s uid range, so the container can’t claim it.
chownthe mount point to the user who runspg. - Port collision on manual
--api-port. The pool is shared across minio/silo/rustfs; an explicit port must be dodged by the auto-assigner for all three. Each store needs a consecutive pair. --clean-dataleaves a directory behind. Under rootless podman a subordinate-uid tree is removed via thepodman unshare rmfallback; if that path is unavailable, remove it yourself withpodman unshare rm -rf <dir>.
12.8 - HAProxy
HAProxy is the load balancer the Patroni docs
recommend putting in front of a cluster: clients connect to one stable address,
HAProxy health-checks each member’s REST API, and routes writes to the current
leader (and, in read/write-split mode, reads to the replicas). pgcli runs it as
a standalone, top-level addon — shared routing infrastructure, not a
per-instance sidecar — pinned to the official haproxy:3.2.23-alpine image.
Platform support: the HAProxy addon is Linux-only for now. It reaches Patroni members over the host network, which the macOS
podman machinedoes not expose to containers. On macOSNewHAProxyManagerfails fast with a clear message;pg addon liststill shows installed instances without live status.
How It Works
Routing follows the official Patroni haproxy.cfg
pattern. Each backend is a Patroni member; two ports matter per member:
- the PostgreSQL port — the TCP port HAProxy actually forwards connections to;
- the REST API port — where HAProxy sends its
httpchkhealth check.
Patroni’s REST API answers these GETs (unauthenticated by design):
| Endpoint | Returns 200 on |
|---|---|
GET / |
the leader only |
GET /replica (optionally ?lag=<max>) |
replicas only, and only if their lag is within the limit |
So the leader is the sole UP server for a write listener, and the replicas are
the sole UP servers for a read listener — failover is picked up automatically
as the health checks flip. pgcli renders this into
<base-dir>/addon/haproxy/<name>/haproxy.cfg and bind-mounts it read-only into
the container.
Two routing modes
The mode is chosen with --mode:
unified(default) — a single listener sends all traffic to the current leader. Simple; every connection can read and write. On leader change the write listener useson-marked-down shutdown-sessionsto drop stale connections so clients reconnect to the new leader.split— read/write separation: a_<name>_rwlistener routes writes to the leader, and a separate_<name>_rolistener spreads reads across the replicas (round-robin, lag-filtered). Two client ports instead of one.
Two configuration modes
Above those two routing modes sits a choice of who writes haproxy.cfg:
- Rendered (default) — pgcli generates the config from
--node/--haplus--mode, ports and--max-lag, and rewrites it on every install. This is the Patroni read/write proxy described above. - Custom (
--conf-file <path>) — you provide the fullhaproxy.cfgand pgcli runs it verbatim, for any native HAProxy proxying (plain TCP relays, HTTP frontends, extra listeners — anything HAProxy does), not just a Patroni cluster. pgcli copies the file into its config directory, mounts that copy read-only, validates it withhaproxy -cbefore (re)starting the container, and never rewrites or deletes your original. After an edit,pg addon haproxy reloadre-validates the file and hot-reloads the running instance without dropping connections (conf-file instances only — see Hot reload). All listeners come from the file;--mode,--rw-port/--ro-port/--stats-port,--max-lag,--listenand--node/--hahave no meaning there and are rejected or ignored — see Custom config file.
Stats page
Every instance also gets an HTTP stats listener so you can watch server
up/down state in a browser: http://<listen>:<stats-port>/.
Install
Backend members are supplied one of two ways (mutually exclusive), or bypassed
entirely with --conf-file (see Custom config file):
--node NAME=HOST:PGPORT:RESTPORT(repeatable) — list them explicitly;--ha <scope>— auto-derive every local member of a Patroni scope thatpg hamanages, using each member’s PostgreSQL and REST API ports.
What each --node field means — for node1=10.0.0.11:35532:8008:
| Field | Meaning |
|---|---|
node1 |
member name — becomes the server name in haproxy.cfg and the label in the stats page |
10.0.0.11 |
address where this member is reachable from this host (the member’s advertise-host, or 127.0.0.1 on the same host) |
35532 |
the member’s PostgreSQL port that HAProxy forwards connections to |
8008 |
the member’s Patroni REST API port, used for the health check |
With --ha, all four come from pg.yaml: the Patroni member name, its
advertise-host (default 127.0.0.1), and its host_port / restapi_port.
The output reports the assigned ports and a ready-to-use connect URL:
Adding or removing members later
The backend list follows the scope’s local members recorded in pg.yaml, so
any topology change — or removal — is picked up by re-running the same
install command: it re-derives the full backend list and recreates the
container.
Added with pg ha create <scope> --member <m>, the new member is not in the
running HAProxy config yet:
Symmetrically, after pg ha remove <scope> --member <m> the removed member
would otherwise linger in the config as a stale backend — re-running install
drops it:
For an explicit --node install there is no auto-derivation: edit the --node
list itself (add or drop a spec) and re-run — the target list is replaced
wholesale each time, so the command always reflects the full set you want.
Cross-host clusters
--ha derives backends from this host’s pg.yaml, which lists only the
members this host’s pg ha create manages. A member living on another host was
never a backend here — adding or removing one there requires no re-sync on
this host (and leaves nothing stale). To front the whole cluster through one
HAProxy, use an explicit --node list, with each member’s advertised host:
Caveat: an instance installed with --ha on one host routes writes only to a
local leader — if failover promotes a remote member, the rw listener has no
UP server until the leader moves back. For cross-host clusters where the leader
can move, prefer the explicit all-members --node list (or run one HAProxy per
host and front it with a higher-level VIP/DNS).
Custom config file
The rendered mode above is pgcli’s opinion about Patroni routing. With
--conf-file you skip the render entirely and run your own haproxy.cfg —
arbitrary native HAProxy: plain TCP relays, HTTP frontends, any number of
frontend/backend/listen sections, anything the upstream software
supports. pgcli keeps only the container lifecycle (pull, create, start, stop,
remove, autostart) and the config gate:
--conf-file is mutually exclusive with --node/--ha (the install is
rejected with an error), and the file must exist and be non-empty. A typical
file mixes a raw TCP relay with an unrelated HTTP listener:
The summary then reports the custom mode instead of ports (there are none to report — they live in your file):
How the mode behaves, point by point (all of this is what an install on a real host does):
- Your file is the source of truth; pgcli’s copy is what runs. Install
copies it to
<base-dir>/addon/haproxy/<name>/haproxy.cfgand bind-mounts that copy read-only. Your original is never rewritten, andpg addon removedeletes only pgcli’s copy — your file stays where you put it. - Edits apply via
reload(seamless) or re-install (lossy). Change the file, then eitherpg addon haproxy reloadto hot-apply it to the running container without dropping connections, or re-run the same install to recreate the container. Both first re-copy your file and validate it withhaproxy -c. Editing the rendered-mode knobs does nothing here —--mode,--rw-port/--ro-port/--stats-port,--max-lagand--listendescribe the generator, not your file. See Hot reload. - Everything is validated before the container is touched. Each install
runs the mounted copy through
haproxy -c(check-only) in a throwaway container first. A bad file fails the install with HAProxy’s own diagnostics, exits non-zero, and restores the previous good copy — a running container and the on-disk config are left exactly as they were, so a later reboot orpg addon startcannot resurrect a rejected config. (Warnings — missing timeouts and the like — pass; only real errors are refused.) - Ports are yours to choose. The pool allocator skips conf-file instances;
it neither assigns nor learns the ports in your file. Pick listener ports
yourself and keep them clear of rendered-mode instances’ auto-assigned ports
and everything else on the host — HAProxy binds fail at start with
cannot bind socketif you collide. - The container runs with host networking, like the rendered mode. A
frontend bound to
127.0.0.1is reachable from the host itself; to let other containers/hosts in, bind the host’s address (aspg_relayabove does). - Health of the proxies is your config’s business.
pg logs <name>shows HAProxy’s own stdout (if your file logs there, as the sample does), and any stats page is whateverstatssection you wrote — pgcli does not add one. - Switching back is just an install without
--conf-file: pass--node/--haagain and the instance returns to rendered mode (the conf-file copy on disk is overwritten by the generated config).
Example: HTTP load balancer with health checks
A conf-file instance is a full HAProxy, so the Patroni pattern is not the only
thing it can do — here is an HTTP round-robin balancer over several app servers
with active health checks (this exact config was run and the DOWN/UP behaviour
observed in pg logs):
What each piece does, and what was observed live:
- Multiple
serverlines — HAProxy round-robins new connections across theUPservers (add a line per backend; the list order is the starting rotation). option httpchk GET /+http-check expect status 200— the health probe is an HTTPGET /and only 200 counts as UP. Match the method and the expected code to what the app actually answers: a server that returns405toHEADbut200toGETneeds theGETform, not theHEAD /default.default-server inter 3s fall 3 rise 2— probe every 3s, mark DOWN after 3 consecutive failures, return to service after 2 successes.checkon each server turns the probe on for that server.
When a backend is unreachable its probe fails and, after fall 3, HAProxy drops
it from rotation and logs it — pg logs <name> showed:
and all frontend traffic kept returning 200 by flowing only to the surviving
app1. Once app2’s port answers again it clears rise 2 and rejoins the
rotation — no reload needed. To add or remove a server line, edit the file
and run pg addon haproxy reload (see Hot reload): it
re-validates with haproxy -c and hot-swaps the worker without dropping
in-flight connections. Backend membership changes are cleanest still through the
health-check path above (a server you stop is pulled from rotation with no
reload at all); reload is for when you change the server list itself.
Tuning concurrency (threads)
HAProxy’s model is one process, many threads — there is no nginx-style
worker_processes. In conf-file mode you tune it from the global section of
your own file (the rendered mode does not set any of these):
nbthread N— the number of worker threads. Validated against the image:haproxy -cacceptsnbthread 4.- default when unset — HAProxy spreads threads across the machine’s CPUs
automatically on a multi-processor host (its own wording: “Threads will
automatically be used on multi-processor machines if available”), so you only
need
nbthreadto pin a specific count. cpu-map auto:1-N 0-M— optionally bind thread 1…N to CPU cores 0…M (validated).nbprocno longer exists. Since HAProxy 2.5 it is refused outright (haproxy -cfails with “nbproc is not supported any more since HAProxy 2.5”). Use threads, not processes.
Changing it means editing the file and running pg addon haproxy reload — the
master re-reads the config and starts a fresh worker with the new thread count
(verified: nbthread 2 → 4 applied with no dropped connections), so unlike a
re-install it doesn’t cut in-flight traffic. This is a config knob in your file,
not a pgcli flag.
Hot reload
--conf-file instances support seamless config reload — the HAProxy equivalent
of pg addon nginx reload:
The official image already runs HAProxy in master-worker mode (-W): pid 1
is the master, a child is the worker. reload sends the master SIGUSR2, so it
starts a new worker from the config on disk, hands the listening sockets over,
and lets the old worker drain its in-flight connections before exiting —
no dropped connections, unlike the lossy container recreate that install
performs. (This is verified behaviour, not just theory: a running instance’s
worker was swapped end-to-end while it kept serving 200s.)
Before it signals anything, reload does exactly what install’s gate does — it
is not a blind signal:
- re-copies your
--conf-fileto the mountedhaproxy.cfg(verbatim); - runs the copy through
haproxy -c(check-only) in a throwaway container; - only if it validates does it send
SIGUSR2. A file that fails the check aborts the reload with HAProxy’s own diagnostics (non-zero exit), restores the previous good copy on disk, and leaves the running container untouched — so a rejected edit can never be picked up by a later restart either.
Requirements and scope:
--conf-fileinstances only. A rendered instance rebuilds its config from--node/--haon every install and takes no hand-edits, so its update path stayspg addon install haproxy. Runningreloadon it is refused with that advice.- The container must be running.
reloadsignals the live master; if the instance is stopped, start it (pg addon start haproxy --name custom) — a freshinstallalso picks up the edited file. reloadis for changing the running config in place. Turning a backend off for maintenance still needs no reload at all — the health check pulls it from rotation; reload is for editing theserverlist / global knobs themselves.
The same reload is available on install via --reload: for a running
conf-file instance it validates and hot-reloads instead of recreating the
container, so one command both (re)configures and applies without dropped
connections:
Without --reload, install keeps its default recreate-the-container behaviour
(lossy). --reload on a stopped or rendered instance is a no-op warning — it
just installs normally.
Read/Write Split in Practice
After installing with --mode split, two client ports are exposed:
| Port | Traffic | Backend |
|---|---|---|
write_port (default 5000) |
Read + write | Leader only |
read_port (default 5001) |
Read only | Replicas (round-robin, lag ≤ max-lag) |
stats_port (default 5002) |
HTTP stats page | — |
After a failover, HAProxy picks up the new leader automatically via health
checks — no connection string change needed. A browser at
http://127.0.0.1:5002/ shows real-time backend state.
Ports
Ports are auto-assigned from a per-host pool, three consecutive free ports per
instance (rw, then ro if split, then stats), starting at haproxy_start_port
(default 5000). Override any of them explicitly:
Because the pool scans for free ports and tracks what every HAProxy instance
has already taken, you can run many instances on one machine without
collisions. A split instance consumes 3 ports (rw / ro / stats), a unified
instance 2 (rw / stats):
The bind address defaults to 127.0.0.1; set --listen 0.0.0.0 — or the
listen key in pg.yaml — to expose the listeners on the network. Keep it on
loopback unless the backends require otherwise; there is no authentication in
front of PostgreSQL here.
Configuration
Everything lives under the top-level addons.haproxy map in pg.yaml, keyed by
instance name:
targets is rendered from and re-derived by install; edit mode, ports,
replica_max_lag, or image_tag here for fine control, then
pg addon install haproxy --name lb ... to apply. The rendered haproxy.cfg
carries no secrets — only hosts, ports, and health-check URIs.
List
A conf-file instance shows its source instead of ports and backends:
Start and stop
After a host reboot, bring an instance back without re-rendering config:
install always recreates the container so config changes take effect;
start only starts an existing one (and self-heals an improper state by
recreating from the haproxy.cfg on disk). For a --conf-file instance,
pg addon haproxy reload applies an edited file to a running container
without that recreate — see Hot reload.
Auto-start on Boot
Containers carry a --restart unless-stopped policy (crashes, not reboots). To
bring an instance up after a host reboot:
This sets autostart: true and installs/refreshes the boot service (see
Auto-start on Boot). Boot is start-only: it starts the
existing container reading the haproxy.cfg already on disk, and never
re-renders config — install the instance first. The boot service starts
HAProxy after the Patroni members, so its backends are already coming up.
pg autostart status lists every target’s state.
Remove
Stops and removes the container and deletes its config directory.
Logs
The container logs to stdout (log stdout format raw local0 info), so health-check
transitions and connection events show up here.
Troubleshooting
- All backends DOWN in the stats page. The health check hits each member’s
REST API port. Confirm it is reachable —
curl http://<host>:<rest_port>/returns 200 on the leader, 503 elsewhere;/replicais the mirror image. A000means the REST API is down or the port is wrong. --hafinds no members.--haonly derives local members thatpg hamanages inpg.yaml. For remote members, or a scope pgcli doesn’t manage, list them with--node.- Port collision on manual
--rw-port. Pick a port outside the auto pool (haproxy_start_portand up) or the auto-assigner will treat it as taken. - A conf-file install fails
haproxy -cbut the old config keeps running. That is the designed behaviour: the rejected copy is rolled back and the container untouched. Fix the file (HAProxy’s error names the line) and re-install. To see why a started container died anyway (cannot bind socket, missing files your config references), checkpg logs <name>. reloadsays “container is not running”. Reload signals the live master, so the instance must be up first —pg addon start haproxy --name <name>(or a freshpg addon install, which also picks up the edited file).reloadrejects the file withhaproxy -cerrors. Same gate as install: the reload is aborted, the previous good copy is restored, and nothing was reloaded — fix the line HAProxy names and reload again.- macOS. Not supported yet — HAProxy needs host networking to reach Patroni members, which the podman machine VM does not provide to containers.
12.9 - Silo
silo is Pigsty’s maintained fork of MinIO: S3-compatible
object storage with the web console bundled in, keeping MinIO’s wire contract
end to end — the S3 API, the MINIO_* environment variables, the
server /data --address :9000 --console-address :9001 command line, the
--certs-dir layout, and erasure-coded cluster mode. pgcli runs it as a
standalone, top-level addon with exactly the same surface as the
minio addon — install, TLS, BYO certs, distributed mode, logs,
autostart. Pick silo over MinIO if you follow Pigsty’s releases; the two can
coexist on one host (they share one port pool, assigned without collision).
Platform support: the silo addon works on both platforms. Linux serves over host networking; macOS joins the
pgcli-netbridge with its two ports published, so the Mac reaches the API and console on127.0.0.1:<port>like any other addon. The public image is dual-arch (amd64 + arm64). Both clients —pg mcli(silo’s own) andpg mc(MinIO’s) — work on both platforms and against a silo store alike.
How It Works
One container, one directory: silo server /data --address <listen>:<api-port> --console-address <listen>:<console-port>, where /data is a bind mount of the
instance’s host data directory (default <base-dir>/addon/silo/<name>/data).
Everything silo stores lives there, so it outlives the container.
The image is the public upstream tag docker.io/pgsty/silo:RELEASE.2026-09-16T00-00-00Z
— dual-arch, console included, and it ships the mcli client too — so pg addon install silo just pulls it; pgcli never builds the image at run time
(unlike pgcli-minio, which is a self-maintained tag because MinIO’s official
image dropped the console). pgcli drives the silo binary directly via an
explicit --entrypoint silo, bypassing the image’s entrypoint wrapper, exactly
as it does for MinIO.
Credentials are handled like Patroni’s:
root_userdefaults toadmin;root_passwordis generated on first install (or supplied explicitly via--root-password) and stored inpg.yaml(addons.silo.<name>.root_password) and printed once in the install summary for convenience.
They are silo’s root access key / secret key — passed through the
MINIO_ROOT_USER / MINIO_ROOT_PASSWORD env vars silo inherits from MinIO —
what every S3 client (and pg mcli alias set <name> <url> <root_user> <root_password>) calls the access key and secret key.
The container runs --ulimit nofile=1048576:1048576 and --stop-timeout 60 as
MinIO’s deployment docs recommend (the contract carries over). On macOS the
podman machine VM caps RLIMIT_NOFILE lower, so the value used there is
65536 — plenty for a single-host dev/test store. Root credentials passed as
-e are visible in podman inspect — the same exposure as any hand-run
container; fine for a rootless single-host deployment, which is what this is.
Install
The output reports endpoints and the root credentials:
Sign in to the console at the Console: URL with the printed root user and
password. Point S3 clients (including pgBackRest) at the S3 API: URL, or
drive them from the terminal with pg mcli below.
Re-running install against a live instance is a no-op: the container is not
recreated (a stopped one is simply started, with a notice), the flags are
merged into the stored config, and the existing root password is kept. Pass
--force to recreate the container so changed ports, listen address, endpoint
list, or credentials take effect — without losing the data directory.
Bind address: the default
127.0.0.1keeps the store local.--listen 0.0.0.0(or thelistenkey inpg.yaml) exposes it on the network. Anyone who can reach the port can then attempt the root credentials, so only do this behind a firewall or with TLS (--tls, below).
TLS (--tls)
--tls makes silo serve HTTPS. pgcli generates a self-signed CA and a leaf
cert with the stdlib — SANs cover the loopback names, localhost, and every NIC
IP of the host — into <base_dir>/tls/silo/<name>/: public.crt /
private.key for silo’s --certs-dir, and ca.crt for distribution. The
cert dir is mounted read-only and the endpoint URL becomes https://.
Why you need it: pgBackRest forces HTTPS for S3 repositories (plaintext is
an upstream-rejected option), so a silo meant to receive Patroni archive-push
must speak TLS. Point backup.repo.s3.ca_file at ca.crt and pgBackRest
connects with full certificate verification — see
Backup → S3 object storage repository.
Everything that page says about a MinIO repo applies verbatim to a silo one:
the client contract is identical.
One note on pairing stores with clusters: backup.repo.s3 is a single, global
repo per config file, so the first TLS store you wire up — minio or silo —
receives every stanza in that environment. If you truly need two destinations,
run a second config (see Backup → Shared Backup
Container, and the worked
Example: HA Cluster with a Self-CA MinIO).
pg mcli adds --insecure automatically when a command targets a TLS store
via a loopback alias (mcli persists no CA trust per alias; same-host loopback
makes this acceptable). An alias to a LAN-IP endpoint is not loopback — append
-- --insecure yourself, or set up the CA properly. External https://
endpoints keep full verification. Toggling --tls on an existing instance
needs --force to take effect.
Lifetimes and access from other hosts. CA and leaf are both long-lived;
pgcli re-signs the leaf on --force, or whenever a host address changes (silo
watches its cert files and hot-reloads a re-signed pair, which is how a
deployment picks up the new chain without a recreate), so handing ca.crt to
clients is a one-time act per store. A TLS client validates the address it
dials against the leaf’s SANs — the client’s own address never matters.
Remote hosts therefore point backup.repo.s3.endpoint at one of the store
host’s IPs (loopback names and every NIC IP, virtual bridges included, are in
the SAN; raw hostnames are not — unless you set MINIO_SERVER_URL, whose host
is added).
Getting the CA onto a remote host — no scp needed. The server cert is served as a leaf+CA chain, so a consumer on another machine can pull the root straight out of a TLS handshake:
The fetch is trust-on-first-use — compare the printed SHA-256 against the
store host’s sha256sum ~/.pgcli/tls/silo/<name>/ca.crt before trusting it.
One setup then republishes the CA into the Patroni cluster’s etcd registry,
so every other cluster host gets it with no manual step at all (see
Backup → S3 object storage repository).
Bring your own certificate (--tls-cert / --tls-key)
--tls only ever serves pgcli’s own self-signed pair. To serve a certificate
you already hold — one signed by a public CA for a real domain, or one issued
by your private CA — pass --tls-cert <leaf(+chain).pem> --tls-key <key.pem>
instead. Together with --tls (which it implies, so you don’t need to also
pass it), this replaces the generated certs: the two files are mounted
read-only straight at the names silo’s --certs-dir requires
(/opt/silo/certs/public.crt, /opt/silo/certs/private.key), and pgcli never
copies or re-signs them — the private key stays in exactly the one place you
put it.
Everything else about BYO mode — what install checks, why renewal needs
--force (a single-file mount pins the source inode), how clients pick their
trust anchor, and pg cert as a test-cert mint — is identical to the MinIO
addon; see MinIO → Bring your own certificate. The
pg cert example, adapted:
Turning BYO off. There is no off flag — the config merge across re-runs is
one-way, matching how --tls itself works. To go back to generated certs,
remove cert_file/key_file under this addon in pg.yaml and recreate with
pg addon install silo --name store --force.
Deployment modes
silo/MinIO classifies its layouts; the addon supports all four:
| Mode | Shape | Use it for |
|---|---|---|
| SNSD (single-node, single-drive) | one node, one data directory — the default when no --endpoint or --drive is given |
dev, test, demos |
| SNMD (single-node, multi-drive) | one node, several drives — --drive per drive, see SNMD below |
surviving a disk loss on a single host without a filesystem layer |
| MNSD (multi-node, single-drive) | several nodes, one data disk per node — the distributed mode below | compact high-availability deployments |
| MNMD (multi-node, multi-drive) | several nodes, several drives each — --drive for this node’s drives + the full --endpoint matrix |
surviving a disk loss and a node loss, without a filesystem layer |
SNSD is what pg addon install silo gives you out of the box. To get
MNSD, pass the cluster’s endpoint list (at least four nodes) — see
Distributed / Cluster Mode below.
MNMD combines both flags: give this node’s drives with --drive (as under
SNMD) and the whole cluster’s host×drive endpoint matrix with --endpoint
(one URL per drive on every node) — see
Multi-Node Multi-Drive (MNMD) below. Disk
redundancy under SNSD/MNSD is also available the other way — a ZFS pool
under --data-dir — which keeps the layout changeable underneath and can be
more space-efficient. S3 Storage High
Availability compares native MNMD with the
ZFS approach for the 4-hosts-each-with-several-disks hybrid that survives
both a disk and a node.
Single-Node Multi-Drive (SNMD)
One silo process, several host directories, erasure-coded across them. Pass
--drive once per drive instead of --data-dir:
Each --drive is a host directory on its own device; drive N is
bind-mounted at container path /dataN and the server starts as
silo server /data1 /data2 ... /dataN. --drive is mutually exclusive with
--data-dir — multi-drive mode takes its data locations from --drive only.
Combining --drive with --endpoint is MNMD, the multi-node version of this
mode — see Multi-Node Multi-Drive (MNMD)
below. Like every other silo/MinIO drive, one that shares the host’s root
device is rejected at startup; pgcli lists the offending drives and warns at
install time.
What EC buys you (measured on a live 4-drive silo set): a 4-drive set defaults to 2 parity shards — it tolerates 2 drive failures. With 1 drive down, reads and writes both continue; with 2 down, reads still succeed and writes are refused — that is the quorum boundary, the same arithmetic MNSD uses, just over drives instead of nodes. Usable capacity is roughly half the raw total. A drive that returns is healed by silo itself; pgcli does not need to do anything. The parity default scales with drive count — only the 4-drive shape is tested here.
pg addon remove silo --name store --clean-data deletes each drive
directory — but refuses any drive that is still a mount point, so an
accidental --clean-data can never rm -rf through a live mount into the
disk below. Unmount first if the data below is really disposable.
Distributed / Cluster Mode
silo’s erasure-coded (EC) cluster mode works exactly like MinIO’s — same
command shape, same rules. It requires at least four distinct host:port
endpoints and, unlike the other addons, no central coordinator: every node
runs its own pgcli with its own pg.yaml, and every one of those pg.yaml
files carries the same full endpoint list and the same root credentials.
pgcli only ever starts the container for the node it is running on — silo
itself does the handshake to form the ring across hosts.
The three strict rules (routable distinct hosts, a data directory on a disk
separate from root — pgcli warns when it isn’t — and no per-node
MINIO_SERVER_URL in cluster mode) and the quorum arithmetic are the same as
MinIO → Distributed / Cluster Mode;
silo enforces the same checks because it inherited them.
Multi-Node Multi-Drive (MNMD)
MNMD works exactly like it does under MinIO: give this node’s drives with
--drive and the whole cluster’s host×drive endpoint matrix with
--endpoint, one URL per drive on every node. Each endpoint must address one
of that node’s /data1../dataN drive slots, the matrix must be a multiple of
the per-node drive count and must name at least one remote node, and a --tls
node’s endpoints must all be https:// — pgcli checks all four before starting
the container. See
MinIO → Multi-Node Multi-Drive (MNMD)
for the full walkthrough.
On a live 4-node × 4-drive silo set (16 × 2 GiB) the measured shape matches
MinIO’s: 16 drives online, EC:4 in a single erasure set of stripe size 16;
losing one whole node (12/16 online) keeps reads and writes working, a
64 MiB round-trip byte-identical; losing a second node (8/16 online)
refuses writes (Resource requested is unwritable) and fails reads too, and
the set self-heals back to 16/16 once the nodes restart. EC:4 over 16 drives
keeps 12/16 of raw bytes.
For a TLS grid the same recommendation applies as under MinIO — one
pg cert leaf whose SANs cover every node address, the identical
--tls-cert/--tls-key pair on every node — and it was verified on silo
exactly as on minio: the ring forms with no per-node CA to reconcile. See
MinIO → MNMD for the setup.
Using the mcli Client
pg mcli runs silo’s own mcli client from the pgsty/silo image in a
throwaway container (selected via --entrypoint mcli — the image’s default
entrypoint runs the server) — no local install:
mcli speaks the same alias contract as mc, so it interoperates freely —
against a silo store, a MinIO store, or any S3 endpoint. pgcli points both
clients at one file: aliases persist on the host at ~/.mc/config.json
(mc’s native default path), so an alias registered with pg mc is visible to
pg mcli and vice versa — set it once, not once per client. (The stateless
MC_HOST_<name> form works for both too.) A native mcli install left to its
own defaults reads ~/.mcli/config.json instead, so it will not see the
shared file unless MC_CONFIG_DIR is pointed at it.
alias set validates the credentials against the endpoint before writing, so a
wrong password leaves the config untouched. Local file operands of cp /
mirror / diff are resolved and mounted at their real absolute paths, exactly
as pg mc does (on macOS, keep them under your home directory). Any mcli
flag that pg’s own parser would reject goes after --:
Both clients share the same command table as documented on the MinIO
page — Common commands — with pg mcli in place
of pg mc.
Ports
Each instance takes two consecutive ports from one pool, starting at
minio_start_port (default 9000): the S3 API first, the console second.
The pool is shared with the minio addon — minio and silo instances are
assigned from one cursor, so they coexist on a host without collision,
minio instances first (by name), then silo:
Configuration
Instances live under the top-level addons.silo map in pg.yaml, keyed by
instance name:
Edits to listen, ports, root_user, root_password, image_tag, data_dir,
tls/cert_file/key_file, or endpoints take effect after the next
pg addon install silo --name store --force — the plain install skips a
still-present container, --force recreates it (the data directory is never
touched).
List
pg addon list never prints the password. Reveal it deliberately with
pg addon list --show-password (appends a Root password: line), or print just
the secret for scripting:
The value is the access-key secret; pair it with the Root user: above.
--file <path> writes it mode 0600 instead of to the terminal, keeping the
secret out of shell history and scrollback.
Start and stop
After a host reboot, bring an instance back without re-applying config:
install skips a still-present container (starting it if stopped); start only
starts an existing one (and self-heals an improper state by recreating it from
the config). In TLS generated mode start also re-validates and, if needed,
re-signs the leaf (silo hot-reloads its mounted cert).
Auto-start on Boot
Containers carry a --restart unless-stopped policy (crashes, not reboots). To
bring instances up after a host reboot:
This sets autostart: true and installs/refreshes the boot service (see
Auto-start on Boot). Boot is start-only. silo is
independent of the PostgreSQL stack, so it is started last; there is no
ordering constraint. pg autostart status lists every target’s state.
Remove
The data directory is the object storage — losing it means losing every
bucket in it — so remove keeps it by default and says where it is.
--clean-data deletes it (and prunes the now-empty default-layout parent
directory; a data_dir override and its parent are never touched).
Logs
silo logs to stdout: startup lines (API:/Console: addresses,
Documentation:) and request errors. An API: http://... block confirms the
listeners came up.
Troubleshooting
pulling silo image ... : ...on install. The public tagdocker.io/pgsty/silo:RELEASE.2026-09-16T00-00-00Zis not reachable — check network/registry access to docker.io, or pre-pull it withpodman pull.- Port collision on manual
--api-port. Pick ports outside the auto pool (minio_start_portand up) or the auto-assigner will treat them as taken; the pool is shared with the minio addon, so a silo instance must also dodge the minio instances’ explicit ports and vice versa. Remember each store needs a consecutive pair. pg addon startafter host reboot does nothing / fails. Checkpg logs addon silo --name store -f— most often the data directory was deleted (with--clean-dataor by hand) and silo refuses to start on an empty dir it previously formatted, or the bind port moved.- Console reachable but S3 clients time out. The
MINIO_SERVER_URLis built fromlisten+ API port; if you serve on127.0.0.1but access from another host, clients get redirected to the loopback URL. Setlistento the address clients can actually reach. (Cluster mode does not setMINIO_SERVER_URLat all.) - An alias set with
pg mcisn’t visible topg mcli(or vice versa). They share~/.mc/config.jsonby design, so this means the alias really isn’t there — checkpg mc alias list(same file), or passMC_HOST_<name>in the environment. A native mcli reads~/.mcliinstead and won’t see the shared file unlessMC_CONFIG_DIRpoints there. - macOS. Supported: the addon serves on the
pgcli-netbridge with both ports published, so the Mac’s127.0.0.1:<port>reaches them (the container binds0.0.0.0internally andMINIO_SERVER_URLadvertises the loopback the Mac uses). After changing ports or credentials,--forcerecreate the container. Forpg mcli, the container cannot use a127.0.0.1alias — see the endpoint note on the MinIO mc page.
12.10 - MinIO
MinIO is S3-compatible object storage. pgcli runs it as a standalone, top-level addon — shared infrastructure, not a per-instance sidecar — with the web console included. It defaults to single-node mode and supports a genuinely distributed cluster mode across hosts (see below). Either way it is a general-purpose object store. silo — Pigsty’s MinIO fork, kept wire-compatible — is available as a sibling addon with the identical feature surface; the two share one port pool and can coexist on a host.
Platform support: the MinIO addon works on both platforms. Linux serves over host networking; macOS joins the
pgcli-netbridge with its two ports published, so the Mac reaches the API and console on127.0.0.1:<port>like any other addon. The public image is dual-arch (amd64 + arm64), so any host architecture works.pg mc, the client, works on both platforms too.
How It Works
One container, one directory: minio server /data --address <listen>:<api-port> --console-address <listen>:<console-port>, where /data is a bind mount of the
instance’s host data directory (default <base-dir>/addon/minio/<name>/data).
Everything MinIO stores lives there, so it outlives the container.
The image is the public pre-built tag
ghcr.io/mars-base/pgcli/pgcli-minio:20250422221226 — the upstream static
binaries (dual-arch amd64 + arm64, from the minio/minio GitHub releases) on
Alpine, because MinIO’s official image dropped the bundled web console. pg addon install pulls it; pgcli never builds the image at run time.
Credentials are handled like Patroni’s:
root_userdefaults toadmin;root_passwordis generated on first install (or supplied explicitly via--root-password) and stored inpg.yaml(addons.minio.<name>.root_password) and printed once in the install summary for convenience.
They are exactly MinIO’s root access key / secret key — what every S3
client (and pg mc alias set <name> <url> <root_user> <root_password>) calls
the access key and secret key.
The container runs --ulimit nofile=1048576:1048576 and --stop-timeout 60 as
MinIO’s deployment docs recommend. On macOS the podman machine VM caps
RLIMIT_NOFILE lower, so the value used there is 65536 — plenty for a
single-host dev/test store. Note that root credentials passed as -e are
visible in podman inspect — the same exposure as any hand-run container; fine
for a rootless single-host deployment, which is what this is.
Install
The output reports endpoints and the root credentials:
Sign in to the console at the Console: URL with the printed root user and
password. Point S3 clients (including pgBackRest) at the S3 API: URL, or
drive them from the terminal with pg mc below.
Re-running install against a live instance is a no-op: the container is not
recreated (a stopped one is simply started, with a notice), the flags are
merged into the stored config, and the existing root password is kept. Pass
--force to recreate the container so changed ports, listen address, endpoint
list, or credentials take effect — without losing the data directory.
Bind address: the default
127.0.0.1keeps the store local.--listen 0.0.0.0(or thelistenkey inpg.yaml) exposes it on the network. Anyone who can reach the port can then attempt the root credentials, so only do this behind a firewall or with TLS (--tls, below).
TLS (--tls)
--tls makes MinIO serve HTTPS. pgcli generates a self-signed CA and a leaf
cert with the stdlib — SANs cover the loopback names, localhost, and every NIC
IP of the host — into <base_dir>/tls/minio/<name>/: public.crt /
private.key for MinIO’s --certs-dir, and ca.crt for distribution. The cert
dir is mounted read-only and the endpoint URL becomes https://.
Why you need it: pgBackRest forces HTTPS for S3 repositories (plaintext is
an upstream-rejected option), so a MinIO meant to receive Patroni archive-push
must speak TLS. Point backup.repo.s3.ca_file at ca.crt and pgBackRest
connects with full certificate verification — see
Backup → S3 object storage repository.
One note on pairing stores with clusters: backup.repo.s3 is a single, global
repo per config file, so the first TLS MinIO you wire up receives every
stanza in that environment — installing a second MinIO does not give clusters a
per-cluster store to choose. If you truly need two destinations, run a second
config (see Backup → Shared Backup Container,
and the worked
Example: HA Cluster with a Self-CA MinIO).
pg mc adds --insecure automatically when a command targets a TLS store via
a loopback alias (mc persists no CA trust per alias; same-host loopback makes
this acceptable). An alias to a LAN-IP endpoint is not loopback — append
-- --insecure yourself, or set up the CA properly. External https://
endpoints keep full verification. Toggling --tls on an existing instance
needs --force to take effect.
Lifetimes and access from other hosts. CA and leaf are both valid for 100
years (the 825-day cap public CAs observe is a browser policy, not something
Go’s verifier enforces for a private root). pgcli re-signs the leaf on
--force, or whenever a host address changes, so handing ca.crt to clients
is a one-time act per store. A TLS client validates the address it dials
against the leaf’s SANs — the client’s own address never matters. Remote
hosts (a VM’s Patroni member, another machine’s backup container) therefore
point backup.repo.s3.endpoint at one of the store host’s IPs (loopback
names and every NIC IP, virtual bridges included, are in the SAN; raw
hostnames are not — unless you set MINIO_SERVER_URL, whose host is added).
Getting the CA onto a remote host — no scp needed. The server cert is served as a leaf+CA chain, so a consumer on another machine can pull the root straight out of a TLS handshake:
The fetch is trust-on-first-use — the CA is the thing you cannot verify before
you hold it — so compare the printed SHA-256 against the store host’s
sha256sum ~/.pgcli/tls/minio/<name>/ca.crt (the way you would an SSH host
key) before trusting it. One setup then republishes the CA into the Patroni
cluster’s etcd registry, so every other cluster host gets it with no manual
step at all (see Backup → S3 object storage repository).
Copying ca.crt over by hand still works as the fallback, and is what you must
do if the store host runs a pgcli from before chain distribution (the fetch-ca
error says so, and re-running pg addon install minio --tls there upgrades it
without a restart). A pgBackRest stanza never requires the MinIO to share its
host.
Bring your own certificate (--tls-cert / --tls-key)
--tls only ever serves pgcli’s own self-signed pair. To serve a certificate
you already hold — one signed by a public CA for a real domain, or one issued
by your private CA — pass --tls-cert <leaf(+chain).pem> --tls-key <key.pem>
instead. Together with --tls (which it implies, so you don’t need to also
pass it), this replaces the generated certs: the two files are mounted
read-only straight at the names MinIO’s --certs-dir requires
(/opt/minio/certs/public.crt, /opt/minio/certs/private.key), and pgcli
never copies or re-signs them — the private key stays in exactly the one place
you put it.
What install checks, and what it does not. --tls-cert/--tls-key are
paired through Go’s own TLS loader before anything starts, so a key that
doesn’t match the cert, a cert that is really just a CA certificate, an
expired cert, or one restricted to a use other than server auth all fail the
install outright rather than surfacing later as a crash-looping container.
Whether the cert’s SANs cover the configured --listen address is checked
too, but only warned about — a domain cert is routinely dialed through a name
behind DNS or a load balancer that has nothing to do with the host it runs on,
so a mismatch here is informational, not fatal.
Renewing. Replace the cert/key files and recreate the container:
Recreate is required, not optional: with the files bind-mounted read-only into
the container, a running MinIO does not pick up a replaced certificate — not a
new file moved over the old one (that swaps the inode a single-file mount
pins), and not even an in-place rewrite of the same file. Verified: after
overwriting the host file the container keeps serving the old pair until
--force recreates it. pgcli never re-signs a BYO certificate — the operator
owns its lifetime — so there is no automatic refresh to rely on.
pg addon start/stop re-validate the pair on every start but never
regenerate it (there is nothing to regenerate — pgcli does not own your
certificate’s lifetime). A start-time validation failure is reported as a
warning, not a blocker, matching the existing tolerance around a cert that is
very likely still fine: the fix is always to correct the files (or swap to a
new pair) and recreate with --force.
Clients. With a certificate from a publicly-trusted CA, S3 clients (the
mc family, aws CLI, pgBackRest via repo*-s3-ca-file) need no extra
trust material at all — the chain is already rooted in a CA they trust. For
any other cert you hand the client the trust anchor as
backup.repo.s3.ca_file / --s3-ca-file: the issuing CA (or the full
leaf+intermediate bundle) for a private-CA cert, or — since a self-signed cert
is its own anchor — the served .crt itself when you minted it with pg cert.
A remote host that never saw that .crt can pull it out of the TLS handshake
automatically instead of an scp — fetch-ca recognizes a self-signed leaf and
saves it for you:
Cross-check the SHA-256 against the store host’s sha256sum of the .crt you
gave --tls-cert (trust-on-first-use, same as the generated-CA fetch above).
For a public-CA cert the command stays pointless — there is nothing to fetch
that isn’t already trusted.
Turning BYO off. There is no --tls-cert=/off flag — the config merge
across re-runs is one-way, matching how --tls itself works. To go back to
generated certs, remove cert_file/key_file under this addon in pg.yaml
and recreate with pg addon install minio --name store --force.
Generating a test certificate: pg cert
You don’t need a real CA to try BYO — pg cert mints a self-signed cert whose
SANs cover any mix of DNS names and IPs you ask for:
Nothing it generates touches pg.yaml or a container — it just writes two PEM
files wherever you point it and prints the SANs. Flags:
| Flag | Default | Meaning |
|---|---|---|
--host |
127.0.0.1 |
Comma-separated DNS names and/or IPs to encode as SANs (repeatable). Entries are auto-detected as one or the other, so "minio.test,10.0.0.9" needs no special syntax; *.wild.test works as a wildcard DNS entry. |
--cert-file |
cert.pem |
Path to write the PEM certificate. |
--key-file |
key.pem |
Path to write the PEM private key (PKCS8). |
--valid-duration |
825 days (19800h) |
How long the cert stays valid, e.g. --valid-duration 8760h for a year. |
--ecdsa |
P-256 |
Curve: P-224/P-256/P-384/P-521. Set to "" to disable ECDSA (and pair with --rsa). |
--rsa |
(off) | RSA key size (e.g. 2048, 4096); set only when you specifically need RSA instead of the default ECDSA key. |
--ca |
false |
Make the cert its own CA (CA:TRUE, keyCertSign) — for when you want a private root to sign further certs with, not the usual case for --tls-cert. |
The same generator is also built as a standalone binary for use outside pg —
make gencert → bin/gencert, with the identical flag set in single-dash
form (-host, -cert-file, …). Both front the one internal/certgen package,
so their output is byte-for-byte the same kind of certificate.
What pg cert writes is one self-signed leaf, not a chain. The PEM in
--cert-file holds exactly one CERTIFICATE block — the cert signs itself
(IsCA: false, serverAuth EKU, your SANs). It is deliberately not a
leaf+intermediate+root bundle: there is no issuing CA above it, so there is
nothing to chain, and ValidateBYOCert only inspects the first certificate in
the file and rejects one that is a CA (leaf.IsCA), which is precisely why
the --ca output is not something to point --tls-cert at — it is the
trust anchor itself, not a server cert.
Since the result is self-signed, treat the generated minio.crt exactly
like pgcli’s own --tls-generated ca.crt on the client side — the served
leaf is its own trust anchor, so point backup.repo.s3.ca_file /
pg backup setup --s3-ca-file at the same file (see
Backup → S3 Object Storage Repository).
This is not a workaround pgBackRest merely tolerates: OpenSSL’s trust store
treats whatever you hand it via -CAfile/SSL_CTX as an anchor, CA:TRUE
not required, and pgBackRest’s S3 TLS path (curl over OpenSSL) is that same
mechanism — verified directly: openssl verify -CAfile <pg cert's cert> <pg cert's cert> on the self-signed, CA:FALSE leaf returns OK.
Deployment modes
MinIO/silo classifies its layouts; the addon supports all four:
| Mode | Shape | Use it for |
|---|---|---|
| SNSD (single-node, single-drive) | one node, one data directory — the default when no --endpoint or --drive is given |
dev, test, demos |
| SNMD (single-node, multi-drive) | one node, several drives — --drive per drive, see SNMD below |
surviving a disk loss on a single host without a filesystem layer |
| MNSD (multi-node, single-drive) | several nodes, one data disk per node — the distributed mode below | compact high-availability deployments |
| MNMD (multi-node, multi-drive) | several nodes, several drives each — --drive for this node’s drives + the full --endpoint matrix |
surviving a disk loss and a node loss, without a filesystem layer |
SNSD is what pg addon install minio gives you out of the box. To get
MNSD, pass the cluster’s endpoint list (at least four nodes) — see
Distributed / Cluster Mode below.
MNMD combines both flags: give this node’s drives with --drive (as under
SNMD) and the whole cluster’s host×drive endpoint matrix with --endpoint
(one URL per drive on every node) — see
Multi-Node Multi-Drive (MNMD) below. Disk
redundancy under SNSD/MNSD is also available the other way — a ZFS pool
under --data-dir — which keeps the layout changeable underneath and can be
more space-efficient. S3 Storage High
Availability compares native MNMD with the
ZFS approach for the 4-hosts-each-with-several-disks hybrid that survives
both a disk and a node.
Single-Node Multi-Drive (SNMD)
One MinIO process, several host directories, erasure-coded across them. Pass
--drive once per drive instead of --data-dir:
Each --drive is a host directory on its own device; drive N is
bind-mounted at container path /dataN and the server starts as
minio server /data1 /data2 ... /dataN. --drive is mutually exclusive with
--data-dir — multi-drive mode takes its data locations from --drive only.
Combining --drive with --endpoint is MNMD, the multi-node version of this
mode — see Multi-Node Multi-Drive (MNMD)
below. Like every other MinIO drive, one that shares the host’s root device is
rejected by MinIO at startup; pgcli lists the offending drives and warns at
install time.
What EC buys you (measured on a live 4-drive set): MinIO splits each
object into data + parity shards, and a 4-drive set defaults to 2 parity
shards — it tolerates 2 drive failures. With 1 drive down, reads and writes
both continue; with 2 down, reads still succeed and writes are refused —
that is the quorum boundary, and it is the same arithmetic MNSD uses, just
over drives instead of nodes. Usable capacity is roughly half the raw total
(4 × 2 GiB drives → 3.6 GiB usable reported by mc admin info, EC:2). A
drive that returns is healed by MinIO itself; pgcli does not need to do
anything. The parity default scales with drive count — only the 4-drive
shape is tested here.
pg addon remove minio --name store --clean-data deletes each drive
directory — but refuses any drive that is still a mount point, so an
accidental --clean-data can never rm -rf through a live mount into the
disk below. Unmount first if the data below is really disposable.
Distributed / Cluster Mode
MinIO’s erasure-coded (EC) cluster mode is available too. It requires at
least four distinct host:port endpoints and, unlike the other addons,
no central coordinator: every node runs its own pgcli with its own
pg.yaml, and every one of those pg.yaml files carries the same full
endpoint list and the same root credentials. pgcli only ever starts the
container for the node it is running on — MinIO itself does the handshake to
form the ring across hosts.
The endpoint list is the mode switch: an empty list is single-node (unchanged),
a non-empty list starts minio server <ep1> <ep2> ... in distributed mode.
Each endpoint is http://<host>:<port><path>. The host:port is how the nodes
reach each other to form the ring. The trailing <path> is not an HTTP
route — clients never see it — it is the export path, the directory inside
the container where that node keeps its own slice of the erasure-coded data.
Under MNSD (no --drive) pgcli bind-mounts the --data-dir at /data, so the
path must start at /data — the simplest choice is literally /data. Keep the
same path string across all four endpoints. (Under MNMD a node also passes
--drive, and each endpoint instead names one of that node’s /data1../dataN
drive slots — see MNMD below.)
Note the endpoint path is a container path, independent of the host layout:
if /data is a shared disk and you want to keep other things on it, point
--data-dir at a subdirectory of it — --data-dir /data/minio stores this
cluster’s data under the host’s /data/minio while the endpoints stay
http://<host>:9000/data (the endpoint cannot name that subdirectory; it sees
the mount root). Only if you deliberately extend into the volume with a path
below /data (e.g. /data/mystore) does the data land one level deeper on the
host: <data-dir>/mystore — avoid pairing --data-dir /data/minio with
endpoint .../data/minio, which nests as /data/minio/minio.
--root-password is optional: omit it and the first install generates one
(printed once, stored in pg.yaml). In cluster mode, pass the same value on
every node so the stored credential is identical across the cluster without
any manual copy.
The install summary lists the members and reminds you of the consistency requirement:
Three things are strict in cluster mode, each learned the hard way:
- Endpoints must be routable, distinct hosts. Same-host folds into
single-node-multi-drive and is rejected (
use path style endpoint for single node setup); the127.0.0.0/8loopback range is rejected outright (resolves to localhost). Use each node’s real LAN address. - The data directory must be on a disk separate from the root filesystem.
MinIO refuses a drive that shares the OS disk (
drive is part of root drive, will not be used). pgcli detects this with astatof the data dir versus/and warns at install time when--data-diris on the root device — the warning is advisory; point--data-dirat a mounted data disk. MINIO_SERVER_URLis not set in cluster mode. It would differ per node (each advertises its own IP), and MinIO requires everyMINIO_*env var to be byte-identical across nodes, so a per-nodeMINIO_SERVER_URLmakes each node loop onWaiting for at least 1 remote servers with valid configuration. The endpoint list defines the addresses instead. Single-node still setsMINIO_SERVER_URLfromlisten.
Quorum (EC): writes need ⌈N/2⌉+1 nodes up, reads need ⌈N/2⌉. A 4-node
set therefore keeps serving reads with 2 nodes down but rejects writes; restart
the downed nodes and the cluster self-heals. Under MNMD the members being
counted are drives, not nodes — see MNMD for
the measured drive-level boundary on a live 4×4 set.
Cross-host only. This is a genuinely distributed deployment — you run pgcli on each host yourself. pgcli does not SSH between nodes or register members; keeping the N
pg.yamlfiles consistent is the operator’s job.
Multi-Node Multi-Drive (MNMD)
MNMD is MNSD with several drives per node instead of one: MinIO erasure-codes
across the whole host×drive matrix, so the set survives losing individual
disks and whole nodes without any filesystem layer. Give this node’s drives
with --drive (exactly as under SNMD) and the
entire cluster’s endpoint list with --endpoint — one URL per drive on
every node, not just this one.
The one difference from MNSD is the export path. Each node bind-mounts its
drives at /data1../dataN, so each endpoint must address one of those slots:
http://<host>:<port>/data<k>. pgcli does not parse the URLs — it hands the
matrix to minio server verbatim, the same way MinIO documents it — so every
node carries the identical full list. The list must be a multiple of the
per-node drive count (MinIO requires every node contribute the same number of
drives) and must name at least one remote node; a matrix that folds onto a
single host is rejected before the container starts, as is a --tls node whose
endpoints are plaintext http://.
Serving the grid over TLS — one certificate, not a CA per node. Let each
node’s bare --tls run and every host mints its own self-signed CA, so the
first cross-node handshake dies with x509: certificate signed by unknown authority and you are left seeding one CA into every node’s cert store by
hand. The clean path is a single certificate shared by the whole grid:
pg cert writes one self-signed leaf whose SANs cover every node address, you
copy that one .crt/.key pair to all nodes byte-identically, and serve it
with --tls-cert / --tls-key (there is no per-node CA to reconcile). This is
the recommended TLS setup — pgBackRest forces HTTPS for an S3 repo anyway, so a
backup store is a TLS grid. The endpoint matrix must then be all https://, and
every address a node dials must be in the leaf’s SANs. (The same shared leaf
serves an MNSD grid just as well — the handshake is between the same servers.)
The --listen 0.0.0.0 is deliberate: each node must answer on the address the
other 15 endpoints name. --root-password must be identical everywhere, exactly
as under MNSD.
On a live 4-node × 4-drive set (16 × 2 GiB) — formed over exactly this
shared-pg cert TLS setup, the ring coming up with Network: 4/4 OK and no
per-node CA to reconcile — the measured shape, via mc admin info:
- 16 drives online, EC:4 in a single erasure set of stripe size 16 — the set reports 4 parity shards, so losing 4 drives of 16 stays healthy.
- Losing one whole node (4 drives → 12/16 online) keeps reads and writes working; a 64 MiB upload/download round-trips byte-identical.
- Losing a second node (8/16 online): writes are refused (
Resource requested is unwritable) and reads fail too — the set is no longer safe to serve. Restart the nodes and it self-heals back to 16/16. - Usable capacity follows the parity ratio: EC:4 over 16 drives keeps 12/16 of
raw bytes.
--clean-datadeletes each drive dir but refuses one still mounted, and a drive that returns is healed by MinIO itself.
The pg.yaml files on all nodes carry the identical 16-line matrix and one
shared root password; keeping them consistent is, as under MNSD, the operator’s
job.
Using the mc Client
pg mc runs MinIO’s own mc client from a throwaway container — no local
install, no manual podman run invocation:
Aliases persist on the host at ~/.mc/config.json — mc’s own default path,
the same on Linux and macOS — so alias set once and every later pg mc
invocation (and a native mc install, on Linux) sees the same aliases.
pg mc alias list reads them back and pg mc alias remove drops one.
alias set validates the credentials against the endpoint before writing, so a
wrong password leaves ~/.mc/config.json untouched rather than storing a dead
alias. Under the hood pg mc mounts your ~/.mc into the container and runs
ghcr.io/mars-base/pgcli/pgcli-mc (the static upstream binary on scratch,
pulled on first use); nothing else about it is magic.
Local file operands of cp / mirror / diff work too: pg mc resolves each
one (like realpath) and mounts that exact absolute path into the container, so
just does what it looks like. A download target that does not exist yet (a new
filename, or a ./newdir/ not created) is handled too — its nearest existing
parent directory is what gets mounted, so the file mc writes lands on the host.
On macOS the path must live under your home directory — that is the only tree
the podman machine VM shares; pg mc says so and skips any mount outside it.
Any mc flag that pg’s own flag parser would reject goes after --, exactly
like pg etcdctl — this covers essentially every mc long flag, since none are
registered on pg: --all, --json, --recursive, --force, --newer-than,
and so on. Put them all after --:
Passing one before -- fails in pg, not in mc, with a message like
unknown flag: --recursive — a giveaway that the separator is missing.
One caveat from native mc is worth knowing, because it interacts with the local
mounts above: in a file command (cp / mirror / diff) an operand whose
first segment is not a known alias is treated as a local path. A typo in an
alias name therefore uploads or downloads to a local directory named after the
typo instead of erroring — pg mc faithfully mounts what mc decides to read, so
the behaviour is native, not a pg mc quirk. Confirm the alias with pg mc alias list before a file operation.
MC_HOST_<name> environment aliases — mc’s stateless form, no config file
touched — work through pg mc too, since the variable is forwarded into the
container:
Endpoints by platform: on Linux
pg mcruns on the host network, so a127.0.0.1alias reaches a local addon instance directly. On macOS the container sits on the bridge network, where127.0.0.1is the container’s own loopback — point an alias at a local Mac addon viahttp://admin:<password>@host.containers.internal:9000, or at a remote store via its routable address.pg mcitself works identically on both.A Mac browser reaches the console on
127.0.0.1:<console-port>(the addon publishes it); that is host-side and unrelated to what thepg mccontainer can address.
Common commands
| Command | Purpose |
|---|---|
pg mc ls store / pg mc ls store/backups |
list buckets / objects |
pg mc mb store/backups |
create a bucket |
pg mc cp ./file store/backups/ |
upload (for a tree: pg mc mirror ./dir store/backups/, or add -- --recursive to cp) |
pg mc cp store/backups/file ./ |
download |
pg mc find store -- --name '*.pglz' |
search objects by name |
pg mc du store |
size per bucket |
pg mc rm store/backups/file |
delete one object |
pg mc rm store/prefix -- --recursive |
delete many objects |
pg mc rb store/bucket -- --force |
remove a bucket, objects and all |
The same root credentials work for any S3 SDK — including pgBackRest against a
repo1-type=s3 repository.
Ports
Each instance takes two consecutive ports from one pool, starting at
minio_start_port (default 9000): the S3 API first, the console second.
Like every other auto-assigned pool, it skips ports in use and the explicit
ports of sibling instances:
Configuration
Instances live under the top-level addons.minio map in pg.yaml, keyed by
instance name:
Edits to listen, ports, root_user, root_password, image_tag, data_dir,
tls/cert_file/key_file, or endpoints take effect after the next
pg addon install minio --name store --force — the plain install skips a
still-present container, --force recreates it (the data directory is never
touched).
List
pg addon list never prints the password. Reveal it deliberately with
pg addon list --show-password (appends a Root password: line), or print just
the secret for scripting:
The value is the access-key secret; pair it with the Root user: above.
--file <path> writes it mode 0600 instead of to the terminal, keeping the
secret out of shell history and scrollback.
Start and stop
After a host reboot, bring an instance back without re-applying config:
install skips a still-present container (starting it if stopped); start only
starts an existing one (and self-heals an improper state by recreating it from
the config).
Auto-start on Boot
Containers carry a --restart unless-stopped policy (crashes, not reboots). To
bring instances up after a host reboot:
This sets autostart: true and installs/refreshes the boot service (see
Auto-start on Boot). Boot is start-only. MinIO is
independent of the PostgreSQL stack, so it is started last; there is no
ordering constraint. pg autostart status lists every target’s state.
Remove
The data directory is the object storage — losing it means losing every
bucket in it — so remove keeps it by default and says where it is.
--clean-data deletes it (and prunes the now-empty default-layout parent
directory; a data_dir override and its parent are never touched).
Logs
MinIO logs to stdout: startup lines (API:/Console: addresses, Documentation:)
and request errors. An API: http://... block confirms the listeners came up.
Troubleshooting
pulling minio image ... : ...on install. The public tagghcr.io/mars-base/pgcli/pgcli-minio:20250422221226is not reachable — check network/registry access, or pre-pull it withpodman pull.- Port collision on manual
--api-port. Pick ports outside the auto pool (minio_start_portand up) or the auto-assigner will treat them as taken; also remember MinIO needs a consecutive pair. pg addon startafter host reboot does nothing / fails. Checkpg logs addon minio --name store -f— most often the data directory was deleted (with--clean-dataor by hand) and MinIO refuses to start on an empty dir it previously formatted, or the bind port moved.- Console reachable but S3 clients time out. The
MINIO_SERVER_URLis built fromlisten+ API port; if you serve on127.0.0.1but access from another host, clients get redirected to the loopback URL. Setlistento the address clients can actually reach. (Cluster mode does not setMINIO_SERVER_URLat all — see Distributed / Cluster Mode.) - Cluster stuck on
Waiting for at least 1 remote servers with valid configuration. The nodes disagree. Check that everypg.yamlhas the byte-identicalendpointslist androot_password(podman inspect pgcli-minio-<ns>-<name> --format '{{json .Config.Env}}'on each node), and that no per-nodeMINIO_SERVER_URLcrept back in. - macOS. Supported: the addon serves on the
pgcli-netbridge with both ports published, so the Mac’s127.0.0.1:<port>reaches them (the container binds0.0.0.0internally andMINIO_SERVER_URLadvertises the loopback the Mac uses). After changing ports or credentials,--forcerecreate the container. Forpg mc, the container cannot use a127.0.0.1alias — see the endpoint note above.
12.11 - Redis
Redis is the in-memory key-value store most applications
reach for when they need a cache, a session backend, leaderboards (sorted sets)
or atomic counters. pgcli runs it as a standalone, top-level addon with the
same CLI surface as the object stores (install / start / stop / logs /
autostart / remove), managed entirely through pg.yaml.
It is deliberately the simplest addon in the fleet:
- One container, one port, one password.
requirepassis generated on the first install and stored inpg.yaml— an unauthenticated Redis is not a reachable state. - Version selection. It is the first version-selectable addon:
--version 7|8resolves through a built-in table to a pinneddocker.io/library/redistag (7 →7.4.11, 8 →8.10.2, the default).--imagestill bypasses the table with any tag you like. - Persistence you can dial in. By default the dataset is an RDB snapshot in
a bind-mounted host directory — it survives restarts, and survives
pg addon remove(without--clean-data) so reinstalling the same name revives the data.--aofadds the write-ahead log on top for crash-safe, no-eviction stores, and--save noturns snapshots off entirely (see Parameters).
Pick Redis for cache/session/ranking/counter workloads next to your PostgreSQL instances; it is independent of the PG stack, coexists with any other addon, and several instances (even across majors) can run on one host.
Out of scope: Sentinel (automatic failover) is not managed by pgcli. Redis’s native cluster is supported —
--clusterturns instances into cluster-enabled members that you assemble once yourself withpg redis-cli -- --cluster create(masters-only by default; add per-master followers with--cluster-replicas N; see Native cluster). Read replicas are too —--replica-ofturns an instance into a read-only follower of another (see Read replicas and Known limitations).
Parameters
pgcli keeps Redis’s parameter surface deliberately small: one flag per knob,
each mapping to a native redis-server option — argv overrides through the
image’s own entrypoint, no redis.conf file is generated, and one container
per instance runs the plain upstream image (no pgcli wrapper, unlike
rustfs). The table lists every knob and its default:
Flag / pg.yaml key |
Default | Redis option | Meaning |
|---|---|---|---|
--version / version |
8 |
— (picks the image tag) | major to install (see Version selection) |
--image / image_tag |
resolved from the version table | — | override the tag verbatim |
--port / port |
auto from redis_start_port (base 6379) |
--port |
host port |
--listen / listen |
0.0.0.0 |
--bind |
bind address (127.0.0.1 to tighten) |
--password / password |
generated, 20 chars | --requirepass |
auth; always required (a replica’s must equal the master’s) |
--maxmemory / maxmemory |
unset (no cap) | --maxmemory |
dataset cap that turns eviction on |
--maxmemory-policy / maxmemory_policy |
allkeys-lru |
--maxmemory-policy |
eviction policy, only under a cap |
--aof / aof |
off | --appendonly yes |
write-ahead log on top of the snapshot |
--appendfsync / appendfsync |
everysec (Redis’s own) |
--appendfsync |
AOF flush strength, only with --aof |
--save / save |
Redis’s default schedule | --save |
RDB snapshot plan; no disables snapshots |
--replica-of / replica_host+replica_port |
unset (master) | --replicaof + --masterauth |
read-only replica of a local master addon (its host/port/password are resolved from the config) |
--replica-of-host / replica_host |
— | --replicaof |
master address for a cross-host replica (pair with --replica-of-port + --password) |
--replica-of-port / replica_port |
— | --replicaof |
master port, paired with --replica-of-host |
--cluster / cluster |
unset (standalone) | --cluster-enabled yes + --masterauth |
native-cluster member: non-empty is the group name — same value means same cluster, and every member of a group shares the first member’s password (--cluster create authenticates all operands with one password, exactly like a replica’s password must equal its master’s). Every member also carries --masterauth so any node Redis later promotes to a follower can authenticate to its master. Mutually exclusive with --replica-of/--replica-of-host — an instance is either a cluster member or a read replica, never both. pgcli installs the node; you assemble the cluster yourself — see Native cluster |
--cluster-replicas / cluster_replicas |
0 (masters-only) |
— (echoed into the suggested --cluster create only) |
per-master followers for the assemble step: the summary counts 3*(1+N) nodes and the printed command ends with --cluster-replicas N. Which members become followers is Redis’s decision at --cluster create, not at install. A group property like the password — the first member sets it, later members inherit it, an explicit mismatch is rejected |
--advertise-host / advertise_host |
unset (127.0.0.1, single-host) |
--cluster-announce-ip/-port/-bus-port |
the address this member announces to peers — a LAN IP, required for a cross-host cluster (Redis’s cluster bus has no NAT/port remapping) |
--data-dir / data_dir |
<base-dir>/addon/redis/<name>/data |
--dir /data (host dir bind-mounted at /data) |
where the data lives |
| — | autostart: false |
— | start on boot (pg autostart enable --redis) |
How the knobs combine:
requirepassis always on — an unauthenticated Redis is not a reachable state, even if--passwordis left empty by hand inpg.yaml.- Persistence defaults to RDB snapshots — Redis’s own
saveschedule, plus a final snapshot on shutdown (--stop-timeout 30gives Redis the time to write it) and a replay fromdump.rdbat start. --aofadds the write-ahead log (--appendonly yes) alongside the snapshot — the durable, no-eviction shape.--appendfsynctunes its flush:always(durability) /everysec(the default) /no(throughput). The AOF lives in anappendonlydir/under the data dir and is replayed before the snapshot, so it is the source of truth when both are on.--save nodisables snapshots — pair it with--aoffor an AOF-only store (the summary then readsaof only); on its own it means a purely in-memory instance that loses its dataset on restart (the install warns).--maxmemoryimplies eviction, defaulting toallkeys-lru— the cap means “cache, evict to stay under”. Pass--maxmemory-policy noevictionfor a hard ceiling (writes fail past the cap instead of evicting), or omit--maxmemoryentirely for no cap. The policy flag requires--maxmemory;--appendfsyncrequires--aof— the CLI rejects those combinations before it pulls or creates anything.--replica-ofmakes a read replica — see Read replicas. The persistence/eviction knobs stay orthogonal: a replica may cap memory or enable AOF exactly like a master.--clustermakes a cluster member, and is mutually exclusive with--replica-of/--replica-of-host— an instance is one role or the other, never both. Members of the same--clustergroup share the first member’s password automatically (a later--clustermember with a different--passwordis rejected before anything is pulled or created), and the per-master follower count set by--cluster-replicas Nis inherited the same way. See Native cluster.
Not every Redis option is a flag, by design: the multi-node control plane
(Sentinel, automatic failover) is out of scope, and so is cluster orchestration
— pgcli installs cluster-enabled members but never runs --cluster create,
--cluster add-node, resharding, or scale-in for you; see Native
cluster and Known limitations. Read
replicas are supported
(Read replicas); it is the orchestration (promoting a
replica automatically when the master dies) that pgcli leaves to you.
And --image is the escape hatch for the
engine itself: pg addon install redis --image docker.io/valkey/valkey:8
runs Valkey (the Redis fork), its major reverse-parsing
as 8.
Version selection
--version picks a major; the table maps each major to a pinned patch tag:
| Major | Default image tag | Select with |
|---|---|---|
| 7 | docker.io/library/redis:7.4.11 |
pg addon install redis --name cache --version 7 |
| 8 | docker.io/library/redis:8.10.2 |
pg addon install redis --name cache (8 is the default) |
Resolution rules:
--version 7(or8) → the table’s tag is stored inimage_tag, the major inversion;--image <tag>→ your tag wins outright; the major is reverse-parsed from it for display (redis:7.2.4→7), or left empty if unparseable (the tag is then shown verbatim);- neither → major
8; - an unknown
--version→ error listing the available majors.
Both majors can run side by side on one host — different tags, different ports
from the same pool. The tags track the latest patch of each major and are
bumped together with the docs/images.md rows.
Install
The install summary prints everything a client needs:
Security model.
listendefaults to0.0.0.0— on Linux (host networking) the port is published on every interface, andrequirepassis the only gate. That is convenient for app-on-another-host setups but means the password is load-bearing: pass--listen 127.0.0.1to keep an instance loopback-only.pg addon listand the logs never print the password; reveal it on demand withpg addon password redis --name cacheorpg addon list --show-password(or readpg.yaml).
Re-running install is idempotent: a running container is left alone, a
stopped one is started. To apply changed ports/listen/password/image, add
--force (the container is recreated from the config; the data dir is
untouched). The persistence knobs (--aof/--appendfsync/--save/
--maxmemory-policy) take effect the same way.
Using Redis
pg redis-cli
pg redis-cli runs Redis’s own client from a throwaway container of the
addon’s image — no host install, no password typing:
The local target is --name <addon> or, when omitted, the first redis addon by
name (with several installed, the chosen target is announced on stderr).
pgcli’s own flags are parsed only before the command word; everything after is
forwarded to redis-cli verbatim — negative indexes and option flags (via --)
work that way.
Reaching a remote endpoint — --host/--port override where the client
connects, without registering the remote as an addon:
Use these instead of redis-cli’s own -h/-p: those sit after the command
word and would be read as command arguments (ping -h 10.0.0.7 errors), while
pgcli injects the connection -h/-p itself. REDISCLI_AUTH (redis-cli’s
native env var), if set, overrides the stored password in every mode:
Application connections
Any Redis client works; the DSN printed at install has the shape
redis://:<password>@<host>:<port>/0. For TLS you would need a stunnel or
redis’ own TLS build in front — see Known limitations.
Read replicas
--replica-of turns a new instance into a read-only replica of an existing
master addon: it streams the master’s writes continuously and serves reads,
while writes to it are rejected with READONLY. This is read scaling and data
redundancy, not high availability — pgcli does not run Sentinel, so a dead
master is not promoted automatically (see Known
limitations).
A local --replica-of resolves everything from the config: the replica adopts
the master’s version (cross-major replication is unsupported — a mismatch is a
fast error), its password, and the endpoint 127.0.0.1:<master-port>. That one
password then serves both requirepass (the replica’s own clients auth with it)
and --masterauth (the replica authenticating to the master), so
--password/--maxmemory/--aof etc. stay exactly as on a master. The stored
replica_host/replica_port mean pg addon start rebuilds the same
--replicaof argv even if the master is gone from the config.
Cross-host masters use the explicit form — pgcli cannot look up a remote master’s password, so you supply it (it must equal the master’s):
pg addon list and the install summary show a Role: line — master, or
replica of <host>:<port> (read-only) — and pg redis-cli --name cache-ro info replication reports role:replica with master_link_status:up once the
(initial, RDB-carrying) sync completes. Send reads to the replica and writes to
the master, e.g. with the DSNs:
To promote a replica to an independent master at runtime (the manual failover pgcli deliberately leaves to you), clear its replication and then, if you want it to persist across restarts, drop its role from the config:
Native cluster
--cluster turns instances into members of a Redis native cluster —
sharding by slot (16384 of them), not a read replica of one master. This is the
only multi-node topology pgcli supports; Sentinel is not.
pgcli’s role is deliberately narrow: it installs cluster-enabled nodes, it does
not assemble them. That is Redis’s own two-step flow, not a pgcli invention —
a cluster is cluster-enabled yes servers plus one redis-cli --cluster create
command run against all of them at once. Splitting those two steps means pgcli
never has to guess “is this the last member I’m waiting for, should I form the
group now” (an etcd-style auto-bootstrap state machine); assembling is always
your decision, made once, the same way you’d do it against bare redis-server.
Members of one group share the first member’s password automatically:
--cluster create authenticates every operand node with a single -a (here,
via the one REDISCLI_AUTH pg redis-cli injects), so one password has to reach
the whole group — the same rule that makes a read
replica’s password equal its master’s. A later --cluster
member installed with a different --password is rejected up front, before
anything is pulled or created; omit --password and the group’s own value is
inherited. Every member also emits --masterauth (the same shared password) in
its argv: at --cluster create time Redis may promote any node to a follower,
and a follower needs --masterauth to authenticate to its master — without it
it spins forever in a reconnect storm (master_link_status:down, CPU pinned).
--cluster and --replica-of/--replica-of-host are mutually exclusive on one
install — an instance is either a cluster member or a read replica, never both.
The three addr:port operands are every member’s PeerAddr:port — that’s
127.0.0.1:<port> for a single-host cluster, or each member’s
--advertise-host:<port> for a cross-host one (see below). The install summary
already prints this exact command, built from whatever --cluster app members
are configured at that moment (pg redis-cli --name n3 -- --cluster create 127.0.0.1:6379 127.0.0.1:6380 127.0.0.1:6381 --cluster-replicas 0), so it is
copy-pasteable rather than something to reconstruct by hand:
--cluster-replicas 0 is masters-only — every member holds its own slice of
the slots and none of them has a follower. To give each master a follower (so
one master’s slot range survives that node dying), pass --cluster-replicas N
on the first install and add enough members: each master needs itself plus its
N followers, so the total is 3*(1+N) — for N=1 that’s 6 members:
Which members Redis elects as masters vs followers is its decision at
--cluster create (it balances followers across hosts where it can), so the
pgcli instance names are deliberately role-neutral, and a follower re-links
from its own nodes.conf after a restart — role and masterauth pairing
survive, no re-create. The install summary switches from “3 masters
configured” to counting nodes (“6 nodes configured”) once followers are in
play, and echoes the group’s --cluster-replicas N into the suggested command;
it never calls a follower-set node a master.
After assembling
A pg redis-cli pointed at a cluster member is automatically a
cluster-aware client: pgcli injects redis-cli’s own -c flag so a get/set
that lands on the “wrong” node follows its MOVED redirect to the node that
actually owns the slot — no -c to remember, and it doesn’t matter which
--name you happen to point at:
The last line is the exception, not a rule to remember: when the command is
redis-cli’s own --cluster <subcommand> (which dials the nodes named in its
operands itself), pgcli suppresses the -c injection — it’s the assembly/admin
surface from step 2, not a data command.
The auto--c above depends on pgcli reading a local cluster member from
pg.yaml (the --name path). When you reach the cluster remotely with
--host/--port from a machine that has no such addon configured, pgcli has no
way to know the endpoint is a cluster node, so it injects nothing — and an
unredirected get/set that lands on the wrong node just returns MOVED ....
Forward redis-cli’s -c yourself (it is redis-cli’s own flag, so it goes after
the -- passthrough, before the command word):
-c matters for writes as much as reads: without it a set aimed at a
non-owner returns MOVED and is not applied, so the key never exists. Put
-c on the write, and any single node serves the whole keyspace.
You can also skip the env var and pass the password with redis-cli’s own -a
— it is a connection option too, so it goes in the same passthrough slot,
before the command word:
Two caveats distinguish it from REDISCLI_AUTH: redis-cli prints a security
warning on every invocation (-a/-u on a command line is visible to other
users via ps), and placing the flag after the command word (get foo -a …)
fails with “wrong number of arguments” — it would be passed as a command
argument. For anything beyond a one-off command, prefer the env var; they are
otherwise equivalent for authentication.
If your clients cannot be made cluster-aware at all, put a
Predixy proxy in front of the cluster: it follows the redirects
itself and exposes one plain redis:// endpoint.
The cluster bus is a second port per member: always client port + 10000
(Redis’s own fixed rule, e.g. 6379→16379) — it is not drawn from
redis_start_port, not stored in pg.yaml, and pg addon list shows it
alongside the token:
Topology is self-healing: each node’s --cluster-config-file nodes.conf lives
in its own --dir /data bind mount, so pg addon stop/start (or a host
reboot) brings the whole cluster back with no re-create step — same
start-only model as autostart for everything else here.
Two commands check the cluster’s health directly, both through the passthrough
(yes, the -c one is a data-path client flag, so it goes after the --):
Cross-host members: --advertise-host
A single-host cluster dials peers at 127.0.0.1 and works with no extra flags.
Redis’s cluster bus does not support NAT or port remapping, so members on
different hosts must announce the address peers can actually reach:
--advertise-host adds --cluster-announce-ip/-port/-bus-port to the node’s
argv, and PeerAddr (used to build the --cluster create operands above)
switches from 127.0.0.1 to that address. Two things to get right on your side:
open each member’s bus port (client+10000 — 16379 at the default 6379)
on the firewall between hosts — not just the client port — and every member of
the group must carry the flag, not only the ones being joined later.
The --password "$PW" on every host after the first is not decoration: the
shared-password inheritance only works within one pg.yaml — on a single
host, later members automatically adopt the group’s password; across hosts, each
host has its own config file and generates its own secret. A cross-host cluster
must therefore pin one password explicitly on every host, read from the first
member with pg addon password.
(--cluster create authenticates every operand with that one password, so a
mismatch surfaces as a per-node auth error.)
Status: cross-host clusters are verified end-to-end across three hosts (
--advertise-host→--cluster-announce-*, cross-host--cluster create, MOVED redirects between hosts, and self-heal after a stop/start on each host). The single-host flow is additionally covered by the automated e2e (test/addon/test_redis_cluster.sh), which tests both shapes there: a three-master group and a 3-master × 1-follower group (which is how the missing---masterauthreconnect storm was caught). The multi-host case is so far a manual procedure, not yet in the suite.
Ports
Each instance takes one port from Redis’s own pool, redis_start_port
(default 6379) — a separate cursor from the object stores’ shared
minio_start_port pool, so a redis, a minio and a rustfs instance never
collide:
--port fixes an instance to a specific port; auto-assignment skips anything
already explicit or live on the host. A native-cluster
member is no different here — the cluster bus port is always Redis’s own
client+10000, derived, never drawn from the pool.
Configuration
Instances live under the top-level addons.redis map in pg.yaml:
Edits to listen, port, password, maxmemory/maxmemory_policy,
aof/appendfsync/save, replica_host/replica_port,
cluster/cluster_replicas/advertise_host, image_tag/version, or
data_dir take effect after the next pg addon install redis --name cache --force (or via the matching flags). The replica and cluster roles are normally
set by the --replica-of* / --cluster (incl. --cluster-replicas) /
--advertise-host flags rather than hand-edited — see Read
replicas and Native cluster.
List
pg addon list never prints the password. To reveal it deliberately, add
--show-password (it appends a Password: and a ready-to-paste Raw DSN:
line to each Redis instance — and a Root password: line to each object store),
or print just one instance’s value bare on stdout for scripting:
pg addon password takes the same --name as the other subcommands (defaulting
to the addon’s own name) and a --file that writes the value mode 0600 instead
of to the terminal — prefer --file so the secret stays out of shell history
and scrollback.
Start and stop
stop sends a graceful SIGTERM — with --stop-timeout 30 Redis writes its
final RDB before exiting. start only starts an existing container (and
self-heals an improper state by recreating it from the config).
Auto-start on Boot
Containers carry a --restart unless-stopped policy (crashes, not reboots). To
bring instances up after a host reboot:
Redis autostart is start-only (install the instance first) and replays the
RDB snapshot from its data dir, so the dataset survives the reboot. Redis is
independent of the PostgreSQL stack, so boot-time pg start --autostart starts
it last.
Remove
Without --clean-data the host data dir (and its dump.rdb) stays in place —
reinstalling the same name revives the dataset. --clean-data deletes it,
falling back through podman unshare when rootless podman’s subordinate uid
makes Redis’s files undeletable by the host user. An empty per-instance parent
directory is pruned; a custom data_dir parent is never touched.
Logs
Troubleshooting
NOAUTH Authentication requiredfrom a raw client — expected: every instance has arequirepass. Get it withpg addon password redis --name cache, or usepg redis-cli, which injects it.maxmemoryevicting more than you wanted — under a--maxmemorycap the default policy isallkeys-lru(evict any key to stay under: a cache, not a hard store). Pass--maxmemory-policy noevictionto make the cap a hard ceiling instead (writes fail past it rather than evicting), or omit--maxmemoryfor no cap at all.- AOF not replaying the newest writes —
--appendfsync everysec(the default) can lose the last second of writes on a hard crash; use--appendfsync alwayswhen you cannot afford that. A gracefulpg stopalways flushes, so this only bites on an unclean kill. - Image not found at install — both majors are plain
docker.io/library/redisimages pulled on demand; on an air-gapped host,podman loadthe tar first (the catalog and export steps live indocs/images.mdin the repo) and the pull is skipped. - A hand-edited
versionthat is not in the table —installerrors and suggests--imagerather than silently rewriting your value. - Port already live — auto-assignment scans host listeners and skips
them; pin
--portwhen you need a stable number. - A replica rejects writes (
READONLY) — that is the role, not a fault: send writes to the master. To make it an independent master, see Read replicas. - A freshly-created replica briefly shows
master_link_status:down— normal during the first (RDB-carrying) full sync, and redis 8 reloads the link once more when the snapshot transfers over its rdbchannel. Watchinfo replication; it settles atupandrole:replica.
Known limitations
- No automatic failover — read replicas are supported
(Read replicas) and native-cluster sharding is supported
(Native cluster), but the HA control plane is not:
Sentinel is out of scope, and so is cluster orchestration — pgcli
installs cluster members but never assembles, rescales, or rebalances them.
A replica gives read scaling and a redundant copy of the data, not a
self-healing pair — when the master dies you promote a replica yourself
(
replicaof no one, above). A single instance with no replica is a single point: it can be made durable on disk with--aof, but it cannot fail over to another node. - Native cluster: assembly and reshaping stay manual —
--clusterinstalls cluster-enabled nodes and--cluster-replicas N(echoed into the suggested command) lets each master have followers, so a follower auto-promotes when its master dies. What pgcli does NOT do is run--cluster create/add-node/ resharding/del-nodefor you, and removing a member (pg addon remove) is a plain delete with no slot reassignment — shrink or reshape the cluster at the Redis layer first (move its slots off, forget the node), then remove it. Also note a masters-only group (--cluster-replicas 0) has no follower to promote: there a dead master takes its slot range down with it until you intervene. - Cross-host cluster (
--advertise-host) is manual, not in the e2e suite — the flag, the--cluster-announce-*argv it produces, and the firewall/bus-port rules are implemented, unit-tested, and verified by hand across three hosts (assembly, cross-host MOVED redirects, and self-heal all pass). The single-host flow (masters-only and 3-master × 1-follower) has an automated e2e; cross-host-with-followers has no automated e2e but relies on the same per-member--masterauththat makes followers link up on one host. See the status note in Native cluster. - No TLS — Redis 7’s native TLS exists in some builds but is not wired
here; treat the network path as trusted and rely on
requirepass, or keep the instance loopback-only (--listen 127.0.0.1). For a cross-host cluster that means the cluster bus traffic between members is unencrypted as well. - macOS is code-complete, untested — the bridge path (published ports,
host.containers.internalforpg redis-cli) mirrors the object stores; it has not yet been exercised on a Mac. - Default persistence is RDB granularity — a crash loses writes since the
last snapshot. Enable
--aofwhen that gap matters (ateverysecyou still lose at most one second;alwayscloses it); accept snapshots-only for cache/session data, or move durable data to PostgreSQL.
12.12 - Predixy
Predixy is a high-performance Redis
protocol proxy. pgcli runs it as a standalone, top-level addon whose job is
to take a Redis native cluster — 3+ nodes, 16384
slots, MOVED redirects — and hand it back to your applications as one plain
redis:// endpoint. Clients do not need to be cluster-aware: no -c, no
redirect handling, just connect and run commands.
Platform support: like HAProxy and Redis, the Predixy addon needs Linux host networking to dial its backend nodes; it is not supported on macOS.
How It Works
Predixy speaks the Redis protocol on its client port and keeps persistent
connections to every cluster node listed in --backend. A client request is
routed by slot: if the node it lands on does not own the key’s slot, Predixy
follows the MOVED itself and re-issues the command on the right node —
transparently, in-proxy. The client never sees a redirect.
pgcli renders one config file per instance,
<base-dir>/addon/predixy/<name>/predixy.conf, and bind-mounts it
(file-level, read-only) over the image’s baked-in
/usr/local/predixy/conf/predixy.conf. The rendered file’s
Include license.conf resolves relative to that directory, so the image’s
signed license sibling stays visible — pgcli never ships or manages a license
(see Known limitations).
One password, two sides
The proxy sits between two authenticated parties — its own clients and the cluster behind it — and pgcli uses one password for both:
Authority { Auth "<pw>" { Mode admin } }— clients mustAUTHthis password to use the proxy;ClusterServerPool { Password "<pw>" }— Predixy authenticates to the cluster nodes with it.
That works because the password is the proxied cluster’s own requirepass:
clients already know it, the nodes already accept it. Read it straight out of
the redis addon:
A single cluster-member password therefore gates the proxy endpoint too — no second secret to keep in sync. (If you want a separate client-facing password, that is a different Authority design pgcli does not render; see Known limitations.)
Install
--backend and --password are both required. --backend is the cluster’s
full node list — pgcli never derives it from local redis addons, because the
cluster being fronted often lives on other hosts; you know its topology, so you
list it. Repeat the flag or comma-separate one run; duplicates are rejected.
The summary prints the one endpoint clients should be pointed at:
Guards run before anything is pulled or created: a missing --backend, a
malformed entry (no port, non-numeric or out-of-range port, empty entry), a
duplicate node, a missing --password, a password containing " or a newline
(Predixy’s quoted-string syntax has no escape for them), or --workers < 1 all
fail fast with a clear message.
Re-running install is idempotent as elsewhere: a running container is left
alone (config rewritten), a stopped one is started, and --force recreates it
so a changed port/listen/password/backend set takes effect. The backend list is
replaced wholesale each time, so growing the cluster (install more redis
members, --cluster add-node, …) is followed by re-running the same install
with the updated full list.
Workers
--workers N sets Predixy’s WorkerThreads (default 1). Each worker is a
busy-poll event thread; on a multi-core host dedicated to proxying, N ≈ the
number of cores you are willing to spend scales throughput roughly linearly.
Using the proxy
Any Redis client connects to the proxy’s port with the cluster’s password and behaves as if it were talking to a single standalone Redis — the whole keyspace is served through the one endpoint:
(DSN shape: redis://:<password>@<listen>:<port>/0.)
With pg redis-cli
pg redis-cli reaches the proxy through --host/--port — there is no
--name path to a Predixy instance (--name resolves redis addons only):
With a local redis addon configured, its image and password are reused (see
redis.md — pg redis-cli). If that addon happens to be
a cluster member, pg redis-cli injects its -c as usual — harmless through the
proxy, because the proxy absorbs every MOVED before the client could see one.
That is the point of the proxy: the same plain-client commands work whether or
not -c is in play.
Cross-host
Verified end-to-end: a proxy on one host fronting a three-host cluster
(--backend 10.10.0.15x:6379,…), with plain cluster-unaware clients connecting
from a different host through it — SET whose slot belongs to a remote node
is absorbed and applied, and reads back from that node’s own port. The only
requirement is reachability: the proxy host must be able to dial every
--backend node’s client port (the cluster bus ports are between the members,
not the proxy).
Ports
Each instance takes one port from Predixy’s own pool, predixy_start_port
(default 7617, the port the image EXPOSEs) — separate from Redis’s
redis_start_port pool, so a proxy and the cluster it fronts never collide.
--port pins an instance; auto-assignment skips what is already taken.
The bind address defaults to 0.0.0.0 (the AUTH password is the only gate);
--listen 127.0.0.1 tightens it to loopback.
Configuration
Instances live under the top-level addons.predixy map in pg.yaml:
The rendered predixy.conf carries the same information in Predixy’s own
syntax; hand-editing it is wiped by the next install — edit pg.yaml (or the
flags) and re-install instead.
List
pg addon list redacts the password; --show-password reveals it, and
pg addon password predixy --name proxy prints just that value bare for
scripting.
Start and stop
start only starts an existing container (self-healing an improper state by
recreating it from the rendered config on disk); it errors if that file is
missing — re-run install to regenerate it.
Auto-start on Boot
Boot is start-only (install first), and the boot service starts Predixy after Redis, so the proxied members are already coming up when the proxy dials them. See Auto-start on Boot.
Remove
Stops and removes the container, deletes the rendered config directory, and
drops the pg.yaml entry. The proxied cluster is untouched — the proxy is
merely a consumer of it.
Logs
The startup banner reports the effective build (Workers:2, …) — the place to
confirm --workers landed.
Known limitations
- One admin Authority user — the renderer emits a single
Auth "<pw>" { Mode admin }: every client of the proxy shares one password with full access. Per-user auth, read-only users, and keyspace restrictions are not rendered (Predixy itself supports more; pgcli’s surface stops here). - Cluster pools only —
ClusterServerPoolis the only backend type; Sentinel or standalone pools are not configured. - License lives in the image — the free-edition license baked into
ghcr.io/mars-base/pgcli/predixy:7.0.1-alpinecaps clients atClientLimit 128and expires 2026-12-31; pgcli never ships or rotates it — the image build does. When it expires, rebuild the image (the Makefile target already swaps in the current license). - No TLS on the client side in this addon (password-only gate; keep
listenloopback or a trusted network, as with Redis itself). - Linux only — host networking is required to dial the backends; macOS is not supported (fails fast).
Troubleshooting
ERR invalid passwordfrom clients — the client mustAUTHthe one password; check it againstpg addon password predixy --name proxy(it should equal the cluster’s,pg addon password redis --name n1).- Container exits at once — Predixy rejects its config on the spot: a lone
{on its own line, or a password with a"/newline that broke quoting. pgcli validates both before writing, so this points at a hand-editedpredixy.conf— re-install. - Commands hang or every key errors — a backend node is unreachable from
the proxy host (wrong address, firewalled).
pg logs addon predixyshows the dial failures;--backendmust list nodes this host can dial. - Stale backend list after cluster changes — install replaces the list wholesale; re-run it with the current full node set after adding or removing members.
12.13 - pgAdmin 4
pgAdmin 4 is PostgreSQL’s official web
administration UI — the browser console for browsing schemas, running SQL, and
watching servers. pgcli runs it as a standalone, top-level addon with the
same CLI surface as the other web/infra addons (install / start / stop / logs /
autostart / remove), managed entirely through pg.yaml.
It is the fleet’s one UI addon, which shapes its design:
- A web app, not a database front. pgAdmin dials the servers you register
in its own UI; it does not front one specific PostgreSQL instance the way
PostgREST or PgBouncer do. That is why it is
a top-level-only addon (
addons.pgadmin.<name>) with no per-instance sidecar. - Its own login. The web UI needs an email + password to sign in. That is
not a PostgreSQL password — it is pgAdmin’s own account, auto-generated on
first install and stored in
pg.yaml. Retrieve it later withpg addon password pgadmin. - Persistent session store. pgAdmin keeps its config, saved servers, and
browse history in a sqlite DB under a bind-mounted data dir. It survives
pg addon remove(without--clean-data) and is revived on reinstall. - Optional one-time seed.
--dsnor--pg-name <instance>pre-registers one server in the UI’sservers.jsonat install, so the browser opens with that server already listed. It is a convenience, not a runtime coupling — pgAdmin never blocks on it, and you can add/remove servers in the UI after.
Tip: one pgAdmin per database, spin up on demand. A pgcli-managed pgAdmin is a single lightweight container that starts in seconds and consumes very little memory — cheap enough to treat as disposable. Rather than running one shared pgAdmin with every server registered in it, create a dedicated instance for the database you are working on right now, and remove it when done:
Each instance gets its own port (auto-assigned), its own login, and its own pgpass — no cross-contamination between databases, no stale connections lingering in a shared session store. When you need it again, reinstall in seconds.
Out of scope: pgAdmin serves plain HTTP here — no TLS termination is wired in (put a reverse proxy in front if you need HTTPS).
servers.jsonitself cannot carry the target server’s password (pgAdmin refuses to import one), but when the seed DSN does carry one, pgcli pre-configures it through pgAdmin’s passfile support so the seeded server connects without prompting — see Seeding a server.
Parameters
One container from the upstream docker.io/dpage/pgadmin4 image (pull-only —
pgcli never builds it), configured entirely through -e env vars the entrypoint
reads. The table lists every knob and its default:
Flag / pg.yaml key |
Default | Env var | Meaning |
|---|---|---|---|
--image / image_tag |
docker.io/dpage/pgadmin4:9.18 |
— | override the tag verbatim |
--port / host_port |
auto from pgadmin_start_port (base 5050) |
PGADMIN_LISTEN_PORT |
web host port |
--listen / listen |
127.0.0.1 |
PGADMIN_LISTEN_ADDRESS |
bind address (0.0.0.0 to expose — see Listening and exposure) |
--email / email |
admin@pgcli.lan |
PGADMIN_DEFAULT_EMAIL |
web login account (validated — see Troubleshooting) |
--password / password |
generated, 20 chars | PGADMIN_DEFAULT_PASSWORD |
web login password — not a PG password |
--dsn / dsn |
unset | — (renders servers.json + pgpass) |
one server to pre-register, given as a URI — when it carries a password, it is pre-filled too (host is dialed from the container — see Seeded host vs. listen address) |
--pg-name |
unset | — (renders servers.json + pgpass) |
resolve that one server from a locally-managed instance instead |
--data-dir / data_dir |
<base-dir>/addon/pgadmin/<name>/data |
(/var/lib/pgadmin) |
where the session store lives |
| — | autostart: false |
— | start on boot (pg autostart enable --pgadmin) |
How the knobs combine:
email+passwordonly take effect on a fresh data dir. pgAdmin readsPGADMIN_DEFAULT_*while it initialisespgadmin4.db; once that file exists the stored account is authoritative and the env vars are ignored. A reinstall that revives a kept dir therefore prints a password that is inert against it (the install says so) — to actually reset the login, remove with--clean-datafirst, or pin--passwordto the original when reviving.--dsnand--pg-nameare mutually exclusive — both mean “seed this one server”, one by URI and the other by resolving a local instance’s endpoint.--nameis the addon’s own key and has nothing to do with--pg-name. The seed’s host is where pgAdmin connects from, not where your browser does — see Seeded host vs. listen address.listendefaults to loopback — the web UI is a login gate, so it stays on127.0.0.1unless you pass--listen 0.0.0.0to reach it from another host (the summary then warns that the login is the only gate). Full picture in Listening and exposure.emailmust survive the image’s validator — the entrypoint checks it withemail_validatorand rejects reserved TLDs (.local,.invalid,.test) outright, crash-looping on one. The defaultadmin@pgcli.lanis chosen to pass; pick your own on the same basis (.lan,.internal-style names you control are fine — a real, deliverable address is not required, the deliverability check is off).
Install
The install summary prints the URL and the (generated) web login:
The Seeded: / Seed auth: lines appear only when --dsn/--pg-name was
passed. Seed auth: says password pre-configured (pgpass) when the DSN
carries a password, and no password in the DSN — first connect will prompt
when it does not. Re-running install is idempotent: a running container is
left alone, a stopped one is started. To apply a changed
port/listen/email/seed, add --force (the container is recreated from the
config; the data dir is untouched).
Rebuilding with --force
pg addon install pgadmin without --force is a no-op on a running container
— it leaves the live config alone and just makes sure the container is up.
That is exactly the wrong behaviour when you meant to change something
(port, listen, email, image, seed DSN, --pg-name target, data_dir): the
running container is pinned to the config it was created with, so the edit
looks accepted but nothing changes until the next recreate.
--force is the knob that applies such edits. It stops the running container,
removes it, and creates a new one from the current config; the data dir is
untouched, so saved servers and browse history come back in the new
container:
Two caveats worth stating:
email/passwordstill only re-apply to an empty data dir. pgAdmin readsPGADMIN_DEFAULT_*while it initialisespgadmin4.db; once that file exists the stored account is authoritative and the env vars are ignored.--forcealone does not reset the web login — pair it with--clean-data(which removes the store first) to actually reset the account.- A running container is recreated, not left alone. If the UI is open in a
browser,
--forcekills the session; the next page load re-authenticates against the new container.
Using the web UI
Point a browser at the URL from the summary (on the host that runs podman),
sign in with the login email + password, and pgAdmin’s own server tree appears.
Add connections in the UI, or pre-register one at install with
–dsn / –pg-name.
If the host isn’t where your browser is, set --listen 0.0.0.0 (the
login is then the only gate):
Listening and exposure
--listen (config key listen) is the address pgAdmin’s HTTP server binds, and
it defaults to loopback. The three postures, each applied with --force since
the container is recreated to pick up the change:
- On Linux the container shares the host network, so
--listen 0.0.0.0answers on<host-ip>:<port>and--listen 127.0.0.1on127.0.0.1:<port>. Widening to0.0.0.0puts pgAdmin behind nothing but its email/password login — the install summary warns so — so keep loopback unless the network is trusted. - On macOS the picture inverts: the bridge network always binds
0.0.0.0inside the podman machine and pgcli publishes the port, solistenis effectively widened there regardless, and you reach the UI from the Mac at127.0.0.1:<port>via gvproxy. See Platform.
Retrieving the web login
pg addon list shows the URL and login email but never the password unless
you pass --show-password.
Seeding a server
--dsn and --pg-name both render a one-time servers.json that pgAdmin
imports on first boot, so the browser opens with that server already in its
tree. --pg-name resolves the endpoint from a locally-managed instance (its
host/port/database/user and password, read from pg.yaml); --dsn gives
it as a URI:
What the seed does and does not carry:
- The target server’s password IS pre-configured when the DSN carries one.
servers.jsonitself cannot hold a password (pgAdmin refuses to import one), so pgcli writes a libpqpgpassfile beside it and points the seeded entry at it viaConnectionParameters.passfile— an absolute path pgAdmin honours natively (documented in the pgAdmin 9.18 Import/Export Servers example). Connecting from the browser skips the password prompt entirely. A DSN without a password (e.g.postgres://app@host/db) rendersservers.jsononly, and pgAdmin prompts on first connect as before. - The pgpass file holds the password in plaintext on disk. It is mode 0600
under
<base-dir>/addon/pgadmin/<name>/pgpass, readable only by the host user and inside the container by uid 5050 — but it is on disk, unlike aservers.jsonwhich never holds a secret. The tradeoff is the same one every.pgpass-using tool makes: password-free connects cost a file on disk. Removing the pgAdmin instance (with or without--clean-data) deletes the pgpass alongsideservers.json— the plaintext secret does not outlive the instance. - It’s a one-time convenience, not a coupling. After install, the server
lives in pgAdmin’s own
pgadmin4.db; add, rename, or delete it in the UI. The addon keeps running regardless of whether the seed target is reachable. PGADMIN_REPLACE_SERVERS_ON_STARTUP=Trueis set whenever a seed exists, so a--forcereinstall that changes--dsn/--pg-namere-applies the seed declaratively instead of leaving a stale entry.
To drop the seed and stop the declarative re-load, reinstall with --force and
neither --dsn nor --pg-name.
Seeded host vs. listen address
These two knobs point at different machines and are often conflated. --listen
controls who may reach the pgAdmin web UI; the seed’s host controls where
pgAdmin dials the database from. The dial is made server-side — by the
pgAdmin container, not by your browser — so a locally-managed instance seeded
with --pg-name correctly records 127.0.0.1, because pgAdmin and that
PostgreSQL share the podman host’s loopback. Exposing the UI to the network
does not require the seeded host to be routable from the browser:
The converse pitfall: seed a remote host with --dsn and point a browser at
that same pgAdmin from elsewhere — the connection still originates inside the
pgAdmin container, so that host must be reachable from the container’s
network, not from the laptop.
Ports
Each instance takes one port from pgAdmin’s own pool, pgadmin_start_port
(default 5050) — a separate cursor from Redis’s, Predixy’s, and the object
stores’ pools, so a pgadmin, a redis, and a minio instance never collide:
--port fixes an instance to a specific port; auto-assignment skips anything
already explicit or live on the host.
Configuration
Instances live under the top-level addons.pgadmin map in pg.yaml:
Edits to listen, host_port, email, password, image_tag, dsn/
server_name, or data_dir take effect after the next
pg addon install pgadmin --name console --force (or via the matching flags).
Remember: email/password only re-apply to an empty data dir, so
changing them against an existing store needs --clean-data first (or accept
that the running account is the original one).
List
pg addon list never prints the password. Reveal it deliberately with
pg addon list --show-password (appends a Login password: line) or bare with
pg addon password pgadmin --name console — and prefer --file so the secret
stays out of shell history and scrollback.
Start and stop
start only starts an existing container (and self-heals an improper state by
recreating it from the config). The data dir is preserved across stop/start, so
pgAdmin’s saved servers and browse history come back with it.
Auto-start on Boot
Containers carry a --restart unless-stopped policy (crashes, not reboots). To
bring instances up after a host reboot:
pgAdmin autostart is start-only (install the instance first). On boot the
entrypoint re-chowns /var/lib/pgadmin and replays pgadmin4.db from the data
dir, so the saved servers survive the reboot. pgAdmin is independent of the
PostgreSQL stack — it is a browser UI that dials its servers itself — so there
is no ordering constraint and boot-time pg start --autostart starts it
alongside the other non-database addons.
Remove
Without --clean-data the host data dir (and its pgadmin4.db) stays in place
— reinstalling the same name revives the saved servers and session store (the
revived login is the original one, not a freshly generated password).
--clean-data deletes it, falling back through podman unshare rm when
rootless podman’s mapped uid makes pgAdmin’s files undeletable by the host
user. An empty per-instance parent directory is pruned; a custom data_dir
parent is never touched. servers.json (if seeded) is host-owned and removed
with the instance directory either way.
Logs
Troubleshooting
- Signed in with the printed password and it’s rejected — you reinstalled
over a kept data dir. The login that works is the one pgAdmin stored in
pgadmin4.dbon the first init; the newly generated one is inert (install warns “reviving an existing pgAdmin data dir”). Use--clean-datato start fresh, orpg addon password pgadminto confirm what’s stored, or reinstall with--passwordpinned to the original. - “pgAdmin could not start” / container exits immediately (or crash-loops)
— the entrypoint needs both
PGADMIN_DEFAULT_EMAILandPGADMIN_DEFAULT_PASSWORDon a fresh dir; a hand-editedpg.yamlwith an empty password and a wiped data dir has neither. Let install generate one, or pass--password. It also validates the email and rejects reserved TLDs (.local,.invalid,.test, …) with “does not appear to be a valid email address” — the container then exits and, under the restart policy, spins. That is exactly why the default isadmin@pgcli.lanand not.local. - Can’t reach the UI from another machine —
listendefaults to127.0.0.1. Reinstall--listen 0.0.0.0 --forceand accept that the web login is then the only gate. - The seeded server shows but won’t connect — if the seed DSN carried no
password, pgAdmin prompts for it on first connect (Seeding a
server). If it did carry one and the prompt still
appears, the pgpass file was invalidated — most often because the container
was started without the entrypoint’s
chownrunning (e.g. someone passed--user 5050directly instead of--user 0, so the file is owned by the host user and libpq silently drops it). Confirm the host/port are reachable from where pgAdmin runs. - Image not found at install —
docker.io/dpage/pgadmin4is pulled on demand; on an air-gapped host,podman loadthe tar first (catalog and export steps indocs/images.mdin the repo) and the pull is skipped. - First boot is slow to answer — pgAdmin runs its sqlite migrations before
gunicorn binds; the port answers 302 (to
/login) only once that finishes. On a loaded host this can take tens of seconds.
Known limitations
- No TLS — pgAdmin serves plain HTTP here. For HTTPS put a reverse proxy
(with
PGADMIN_URL_SCHEME/PGADMIN_DISABLE_STATIC_FILE_SERVERtuning) in front; pgcli does not wire it. The loopback default keeps the common case on127.0.0.1. - Not suitable for large data import/export — pgAdmin streams data through
HTTP (query → container → browser for export; upload → container → database
for import), which times out or exhausts memory on large tables. Use
pg export/pg importfor pgcli instances, orpg exec --dsn "..." -- pg_dumpfor arbitrary databases. See Data Import/Export for details. - Seed password lives on disk as plaintext — the pgpass file beside
servers.jsonis mode 0600 but not encrypted; removing the instance cleans it up. There is no way to pre-configure the password without a file on disk (pgAdmin has no KMS/keyring integration). - Login only applies to a fresh store — pgAdmin reads
PGADMIN_DEFAULT_*while it initialisespgadmin4.db; once that file exists the stored account is authoritative. There is no in-place password reset, only--clean-data+ reinstall. - macOS is code-complete, untested — the bridge path (published ports,
proxyBindHostwidening loopback to0.0.0.0on the bridge) mirrors redis; it has not yet been exercised on a Mac.
12.14 - Nginx
nginx is the HTTP reverse proxy that fronts multiple web
services — dashboards, APIs, admin panels — with path-based routing and
optional TLS termination. pgcli runs it as a standalone, top-level addon with
the same CLI surface as the other web/infra addons (install / start / stop / logs
/ autostart / remove), managed entirely through pg.yaml.
It is the fleet’s HTTP gateway, which shapes its design:
- A reverse proxy, not a load balancer. Unlike HAProxy — a
TCP load balancer in front of Patroni — nginx is an HTTP reverse proxy that
routes by URL path. Each
locationblock forwards to one upstream (web app, API service, etc.), so clients reach multiple services through a single port. - File-driven configuration. pgcli renders
nginx.conffrom the backends you declare (or accepts a custom file via--conf-file) and bind-mounts it read-only into the officialnginx:1.27-alpineimage. TLS is optional: when enabled without a BYO cert, pgcli generates a self-signed leaf viatlsca. - Independent log mounting. Each backend gets its own access and error log
files (
access_<name>.log,error_<name>.log) under a bind-mounted/var/log/nginx/directory, so per-service traffic is observable without parsing a shared log. - Nginx-specific subcommands. Beyond the generic addon lifecycle
(install/start/stop/remove), nginx exposes
test(validate config syntax vianginx -tusing a temporary container),reload(hot reload vianginx -s reload), andexec(run arbitrary commands inside the container).
Platform support: nginx works on both Linux (host networking) and macOS (pgcli-net bridge with published ports). The upstream image is multi-arch (amd64 + arm64), so any host architecture works.
How It Works
Routing is path-based: each location block forwards to one upstream. The
config is rendered from the backends you declare (or copied from a custom
file), bind-mounted read-only, and nginx runs in the foreground with
nginx -g 'daemon off;'.
Two configuration modes
The mode is chosen with --conf-file (custom) or --upstream (template):
- Template mode (default) — pgcli renders
nginx.conffrom--upstreamspecs. Each upstream becomes anupstreamblock and alocationblock; TLS adds an HTTPS server block alongside the HTTP one. The template covers the common case: path-based routing to a handful of web services. - Custom mode (
--conf-file) — you provide the fullnginx.confpath; pgcli copies it into the addon directory and manages only the container lifecycle. Use this when production configs grow beyond the template’s reach (limit_req_zone,resolver+ variableproxy_pass, WebSocket, SNI, multiple locations per backend, upstream keepalive, etc.).--upstreamand--conf-fileare mutually exclusive.
Per-backend logs
Each backend gets its own log files in the bind-mounted /var/log/nginx/
directory:
access_<name>.log— access log for that backend’slocationblockerror_<name>.log— error log for that backend’slocationblock
The global access.log and error.log capture server-level events. The log
directory is created on the host at <base-dir>/addon/nginx/<name>/log/ and
persists across container restarts.
TLS termination
TLS is optional. When enabled without --tls-cert/--tls-key, pgcli generates
a self-signed leaf certificate via tlsca.Generate under
<base-dir>/addon/nginx/<name>/tls/. The HTTPS listener runs alongside the
HTTP one, so existing plain-text clients keep working. With BYO certs, the
given files are bind-mounted instead.
A BYO cert is often a pg cert leaf — that command mints a self-signed
certificate with any mix of DNS and IP SANs, exactly the shape a Git/DB
endpoint needs when clients you control can be handed the CA file:
Proxying Git (and any large POST): raise client_max_body_size. The
template mode sets no request-body limit, so nginx applies its built-in default
of 1 MiB — a git push (or any upload) larger than that comes back as
HTTP 413. The template gives you no flag for this, so front a Git service
with custom mode (--conf-file); see the complete working example under
“Git HTTP push with client_max_body_size” in Common custom config patterns
below.
Install
Backends are supplied one of two ways (mutually exclusive):
--upstream name=<n>,path=<p>,backend=<h:p>(repeatable) — explicit upstream targets. Repeatbackend=within the same--upstreamspec to add multiple servers (nginx round-robin), or use the shorthandbackends=h1:p1,h2:p2,....backendandbackendsare mutually exclusive within a single--upstreamspec;--conf-file <path>— a customnginx.conffile (skips template rendering entirely).
What each --upstream field means — for
--upstream name=web,path=/app,backend=127.0.0.1:8000:
| Field | Meaning |
|---|---|
name |
upstream name in nginx.conf (defaults to a sanitized path if omitted) |
path |
location path prefix (e.g. /app → web app, /api → API service; defaults to /) |
backend |
host:port to proxy to (e.g. 127.0.0.1:8000); repeat for multiple servers (round-robin) |
backends |
shorthand for multiple servers: backends=h1:p1,h2:p2,... (mutually exclusive with backend within one --upstream spec) |
The install summary reports the assigned ports and the config path:
With --conf-file, the summary says Config: <path> (from <confFile>) and
skips the Backends: section (pgcli does not parse the user’s config).
Rebuilding with --force
pg addon install nginx without --force on a running container now
auto-reloads config changes: it writes the new nginx.conf, validates it
with nginx -t, and sends nginx -s reload on success. If validation fails,
the old config is restored and the reload is skipped. This means most edits
(backends, --conf-file, worker_connections, TLS) apply without restarting
the container.
--force is still needed when the change requires a container recreate
(image tag, listen address, ports, networking mode): it stops the running
container, removes it, and creates a new one from the current config:
Nginx subcommands
Beyond the generic addon lifecycle (install/start/stop/remove), nginx exposes three subcommands for day-to-day operations:
test — validate config syntax
Runs nginx -t inside a temporary container (podman run --rm) with the
same mounts as the real one. Does not require the nginx container to be
running — it spins up a throwaway container, validates the config, and exits.
This is the safe way to test config changes before reloading:
--conf-file validates a standalone config file before installing it —
useful for CI pipelines or pre-deploy checks:
The temporary container mounts the same nginx.conf (and TLS cert directory
if TLS is enabled), so the validation is identical to what the real container
would see on startup.
reload — hot reload configuration
Sends nginx -s reload to the running container — a graceful restart that
picks up config changes without dropping in-flight connections. Requires the
container to be running (use pg addon start nginx --name proxy first if it
is stopped).
exec — run commands inside the container
Runs an arbitrary command inside the nginx container with stdio passthrough.
Requires the container to be running. The -- separator is mandatory —
everything after it is passed verbatim to the container.
Parameters
One container from the upstream docker.io/library/nginx image (pull-only —
pgcli never builds it), configured entirely through the rendered (or custom)
nginx.conf and the podman run flags. The table lists every knob and its
default:
Flag / pg.yaml key |
Default | Meaning |
|---|---|---|
--image / image_tag |
docker.io/library/nginx:1.27-alpine |
override the tag verbatim |
--http-port / http_port |
auto from nginx_start_port (base 8080) |
HTTP listener host port |
--https-port / https_port |
auto (next free port after HTTP) | HTTPS listener host port (TLS only) |
--listen / listen |
127.0.0.1 |
bind address (0.0.0.0 to expose) |
--worker-connections / worker_connections |
1024 |
worker_connections in the events block (max simultaneous connections per worker) |
--tls / tls |
false |
enable HTTPS listener alongside HTTP |
--tls-cert / tls_cert |
unset | BYO cert path (PEM leaf + chain); without it, pgcli generates a self-signed cert |
--tls-key / tls_key |
unset | BYO private key path (PEM) |
--upstream |
unset (repeatable) | name=<n>,path=<p>,backend=<h:p>[,backend=<h:p>...] — one upstream target, may include multiple backends |
--conf-file / conf_file |
unset | path to a custom nginx.conf (mutually exclusive with --upstream) |
| — | autostart: false |
start on boot (pg autostart enable --nginx) |
How the knobs combine:
--conf-fileis mutually exclusive with--upstream. Both mean “here is the config” — one by path, the other by declaring backends. The install rejects the combination.--tlswithout--tls-cert/--tls-keygenerates a self-signed cert. The cert lives under<base-dir>/addon/nginx/<name>/tls/and is bind-mounted at/etc/nginx/certs. The HTTPS listener runs alongside HTTP on the next free port.--tls-cert/--tls-keywithout--tlsis ignored. The flags only take effect when TLS is enabled.--worker-connectionsinjects into--conf-filemode. When both are set, pgcli patches the user’seventsblock (or adds one) to set the given value. The user’sworker_connectionsdirective, if any, is replaced — not merged.--listendefaults to loopback — the proxy stays on127.0.0.1unless you pass--listen 0.0.0.0to reach it from another host. On Linux the container shares the host network, so0.0.0.0answers on<host-ip>:<port>; on macOS the bridge publishes the port andproxyBindHostwidens loopback to0.0.0.0on the bridge.
Ports
Each instance takes one or two ports from nginx’s own pool,
nginx_start_port (default 8080) — a separate cursor from pgAdmin’s,
Redis’s, Predixy’s, and the object stores’ pools, so an nginx, a redis, and a
minio instance never collide:
--http-port and --https-port fix an instance to specific ports;
auto-assignment skips anything already explicit or live on the host. A TLS
instance consumes two ports (HTTP + HTTPS); a plain instance consumes one.
Configuration
Instances live under the top-level addons.nginx map in pg.yaml:
Edits to listen, http_port, https_port, worker_connections, tls,
tls_cert/tls_key, conf_file, or backends take effect on the next
pg addon install nginx --name proxy — config-only changes are auto-reloaded
(no --force needed). Use --force when the change requires a container
recreate (image tag, listen address, ports).
Common custom config patterns
When the template mode is not enough, use --conf-file with a full
nginx.conf. The following patterns are extracted from a real 70+ server
production config and cover the most common cases.
Complete http block skeleton — the patterns below show individual
server blocks; wrap them in an http block like this:
Dynamic DNS resolution with resolver + variable proxy_pass — avoids
nginx resolving the hostname at startup and caching it forever. The variable
forces a fresh lookup on every request:
Rate limiting per URL — limit_req_zone at the http level, applied per
location. Use different zones for different rate tiers:
WebSocket proxy — requires Upgrade and Connection headers plus
HTTP/1.1:
Upstream with keepalive — reduces connection overhead for high-throughput backends:
Host-based routing with proxy_set_header Host — proxy to a backend
by IP but set the correct virtual host:
SNI passthrough with proxy_ssl_server_name — required when the upstream
is behind HTTPS and uses SNI to select the certificate. Without it, nginx does
not send the Host header during the TLS handshake and the upstream may reject
the connection:
Per-location rate limiting in a single server — when multiple paths need
different rate limits, declare separate zones and apply them per location.
A catch-all limit_req at the server level provides a baseline; specific
paths override with tighter or looser limits:
Custom log format — replace the default combined format with one that
captures request body, response timing, and upstream latency. log_format is
declared once in the http block; each server sets its own access_log path.
The log directory /var/log/nginx/ is bind-mounted by pgcli — use it for all
custom log paths:
Git HTTP push with client_max_body_size — front a Git host (Gitea,
GitLab, a bare git http-backend) with a complete custom nginx.conf that
lifts the 1 MiB body limit and keeps long git-receive-pack requests alive:
--conf-file and --upstream are mutually exclusive, but --tls /
--tls-cert / --tls-key still apply in custom mode: pgcli mounts the cert
pair at /etc/nginx/certs/public.crt and /etc/nginx/certs/private.key, which
the config above references — a custom file simply takes over writing the
listen/ssl_certificate lines itself.
Static file serving with caching — serve files from a host-mounted directory with browser caching headers, sendfile optimization, and access control:
List
Start and stop
start only starts an existing container (and self-heals an improper state by
recreating it from the nginx.conf already on disk). The config and log
directories are preserved across stop/start.
Auto-start on Boot
Containers carry a --restart unless-stopped policy (crashes, not reboots). To
bring instances up after a host reboot:
nginx autostart is start-only (install the instance first). On boot it
reads the nginx.conf already on disk, so the proxy comes up with the last
known config. nginx is independent of the PostgreSQL stack — it is a reverse
proxy that dials its backends itself — so there is no ordering constraint and
boot-time pg start --autostart starts it alongside the other non-database
addons.
Remove
Stops and removes the container and deletes its config directory (including TLS certs and logs). An empty per-instance parent directory is pruned.
Logs
The container logs to stdout, so access and error events show up here. For per-backend logs, look at the bind-mounted log directory:
Troubleshooting
- Config syntax error on startup. The container exits and the restart
policy spins it. Check with
pg addon nginx test --name proxy(works even when the container is stopped) — it runsnginx -tin a temporary container and reports the exact line number. --conf-fileand--upstreamtogether. The install rejects the combination — pick one mode.- Can’t reach the proxy from another machine.
listendefaults to127.0.0.1. Reinstall--listen 0.0.0.0 --forceand accept that the proxy is then the only gate in front of the backends. - Image not found at install.
docker.io/library/nginxis pulled on demand; on an air-gapped host,podman loadthe tar first (catalog and export steps indocs/images.mdin the repo) and the pull is skipped. - Reload fails with “container not running”.
pg addon nginx reloadrequires the container to be up. Start it first withpg addon start nginx --name proxy.
Known limitations
- No load balancing or health checks. nginx here is a reverse proxy (path-based routing to a single upstream per location), not a TCP load balancer. For load balancing in front of Patroni, use HAProxy.
- Template is simple by design. The rendered
nginx.confcovers path-based routing to a handful of backends with optional TLS. Production configs that needlimit_req_zone,resolver+ variableproxy_pass, WebSocket, SNI, multiple locations per backend, or upstream keepalive should use--conf-fileinstead. - macOS is code-complete, lightly tested. The bridge path (published ports,
proxyBindHostwidening loopback to0.0.0.0on the bridge) mirrors the other bridge addons; it has been exercised but not yet under load.
12.15 - Grafana
pg addon install grafana runs Grafana — the open-source observability dashboard — as a managed container from the official upstream image. Like pgAdmin it is a top-level addon: it is not attached to any one PostgreSQL instance and fronts none of them automatically — you add datasources and dashboards through the web UI. v1 ships the pure web UI: configuration is environment-only (the GF_* variables), with no datasource or dashboard provisioning mounted.
The web login password is generated on first install, printed once, and stored in pg.yaml; retrieve it later with pg addon password grafana. Unlike pgAdmin, Listen defaults to 0.0.0.0: a monitoring dashboard is meant to be viewed from other machines, and containers run on the host network. The exposure is guarded by the generated strong password and closed self-signup — pass --listen 127.0.0.1 for loopback-only access.
Platform: Linux is the tested platform. The design supports macOS (the image is multi-arch; the container runs under the podman machine VM on the
pgcli-netbridge with the port published), but it has not been validated there yet.
Parameters
| Flag | Default | Description |
|---|---|---|
--name |
grafana |
Instance name (map key) |
--image |
docker.io/grafana/grafana:13.2.4 |
Image override |
--port |
auto (base 3000) | Host web port; auto-allocated from grafana_start_port |
--listen |
0.0.0.0 |
Bind address — defaults to every interface; use 127.0.0.1 for loopback-only |
--admin-user |
admin |
Web login user (GF_SECURITY_ADMIN_USER) |
--password |
auto-generated | Web login password (GF_SECURITY_ADMIN_PASSWORD; generated when omitted, applied only on the first startup — see below) |
--data-dir |
auto | Data directory override (default under the base dir) |
--force |
false | Recreate even when running — to apply changed ports, credentials or listen address |
The data directory holds Grafana’s own sqlite meta DB (grafana.db): dashboards, datasources, users and settings live there. It survives pg addon remove (delete it with --clean-data), so reinstalling under the same name revives everything. The image’s entrypoint does not fix up ownership (it runs as a baked uid 472, group root, and only warns when the directory is unwritable), so pgcli chowns the data dir on the host before creating the container — via podman unshare chown 472:0 under rootless podman, or a direct recursive chown when pgcli runs as root on a rootful host.
Install
The command pulls the image if needed, creates the container, and prints the URL and login password:
Open the URL in a browser and sign in with the printed credentials. Add a PostgreSQL datasource in the UI (Connect data → PostgreSQL; the host port of each managed instance is in its pg.yaml entry or shown by pg status <instance>). Build dashboards against it.
GF_SECURITY_ADMIN_USER / GF_SECURITY_ADMIN_PASSWORD take effect only while grafana.db does not exist — they initialise the login, they don’t overwrite it. Reinstalling over a kept data dir keeps the original web login; the printed password is then inert (install says so explicitly). To change the login of an existing store, run grafana-cli admin reset-admin-password <new> inside the container (pg exec-style: podman exec -it <container> ...), or remove with --clean-data and reinstall.
Re-running install reuses a running container and starts a stopped one — config changes only apply after a remove + reinstall or --force.
Listening and exposure
Grafana’s Listen defaults to 0.0.0.0: the container shares the host’s network namespace, so the dashboard is reachable on every interface at the assigned port. This is deliberate — a monitoring panel is viewed from other machines — and is protected by the generated strong admin password plus GF_USERS_ALLOW_SIGN_UP=false (self-service registration is off). For a loopback-only deployment pass --listen 127.0.0.1 (recreate with --force); pg addon list then shows http://127.0.0.1:<port>/. --listen <addr> accepts any specific interface address.
Retrieving the web login
The login is Grafana’s web sign-in, unrelated to any PostgreSQL role. Re-read the password at any time:
It is also stored in pg.yaml (addons.grafana.<name>.password).
Ports
Each Grafana instance owns a single port: the web UI. It is allocated from a pool with base grafana_start_port (default 3000 — Grafana’s conventional web port; it starts at 3000 because that is what bookmarks and docs expect), incrementing for every additional instance and skipping ports already taken by any managed container or the used_ports list. The allocation is written into pg.yaml, so a reinstall never re-hands-out a port another addon is using:
Grafana’s own ports share no number with any other addon’s pool bases; an explicit --port still takes precedence.
Configuration
Config is env-only (GF_*): the grafana.ini inside the image is not editable from the host. The variables pgcli sets are the login pair, the listener (port + bind address), analytics-off (GF_ANALYTICS_REPORTING_ENABLED / GF_ANALYTICS_CHECK_FOR_UPDATES), and GF_USERS_ALLOW_SIGN_UP=false. Anything beyond that is v1 out of scope; override the image with --image if you need a different build.
List
Passwords are hidden by default to keep the listing paste-safe; pg addon list --show-password reveals the plaintext value, and pg addon password grafana prints a single one bare for scripting.
Start and stop
Containers run with podman’s unless-stopped restart policy, so they come back automatically with the podman service after a host reboot. start/stop manage them explicitly:
Auto-start on Boot
To have a Grafana instance started automatically on host boot, enable it:
This flags the instance in pg.yaml and installs/refreshes a boot service (a systemd --user unit on Linux, a LaunchAgent on macOS) that runs pg start --autostart. A boot-time start only brings the container up — the data dir was already chowned at create time, so it needs no host privileges beyond podman itself. See Autostart for the full mechanism, status and disabling.
Remove
Under rootless podman, --clean-data deletes the data dir through podman unshare rm — Grafana writes as its container uid (472), which maps to a subordinate uid the host user cannot rm directly.
Logs
Troubleshooting
installfails with “chowning Grafana data dir … to 472:0” — the image runs as uid 472 and never fixes ownership itself, so pgcli chowns the host data dir before creating the container: throughpodman unshare chownunder rootless podman (where the mapped subordinate uid is the only correct target), or a direct recursivechownwhen pgcli itself runs as root on a rootful host (sudo podman, orpgrun as root). The manager shells out to the plainpodmanCLI, so this only fails outside those two cases — e.g. a non-rootpgexplicitly pointed at someone else’s rootful daemon. Run the install as root, or switch to rootless podman; a manualsudo chown -R 472:0 <data-dir>alone does not clear it, since pgcli re-checks ownership on every install attempt.- Login password doesn’t work —
GF_SECURITY_ADMIN_PASSWORDonly initialises the login whilegrafana.dbis absent. On an existing store usegrafana-cli admin reset-admin-password <new>inside the container, orpg addon remove grafana --clean-data+ reinstall (wipes the dashboards). - Dashboard unreachable from another machine —
Listendefaults to0.0.0.0, so the port is open on the host’s interfaces; a failure there is host firewall, not bind address. Checkpg addon listfor the port, then open it. - Port already allocated — the assigned port is held in
pg.yamland skipped for later instances; free it withpg addon removefirst, or pick another with--port N. - Data won’t load / the container exits with a permissions warning — the data dir must stay owned by uid 472; a manual
chownon the host dir or restoring a backup with the wrong ownership breaks it. Re-run install (--force) to re-apply the ownership fix.
Known limitations
- v1 is the bare web UI: no datasource or dashboard provisioning, no reverse-proxy subpath support (no
--root-urlflag —GF_SERVER_ROOT_URLis not set, so front it with nginx at the root path), and plaintext HTTP (TLS termination is the nginx addon’s job). - The host-side ownership chown is automatic under rootless podman (
podman unshare chown) and when pgcli runs as root on a rootful host (directchown). A rootful daemon talked to by a non-root pgcli (only via an explicitly configured remote socket) has no automatic path. - On macOS the container runs inside the podman machine; it has been validated on Linux only.
12.16 - Prometheus
pg addon install prometheus runs Prometheus — the metrics collection and alerting backend — as a managed container from the official docker.io/prom/prometheus image. Like Grafana it is a top-level addon: it fronts no single PostgreSQL instance (it scrapes many endpoints), so it is not attached to any one of them. The two are natural partners — Prometheus scrapes and stores, Grafana visualises — and share the same lifecycle shape (single container, host networking on Linux, one web port from its own pool, persistent data dir, autostart, logs).
Prometheus diverges from Grafana in how it is configured. Grafana is env-only (GF_*); Prometheus reads a config file (prometheus.yml) and takes its runtime tuning as CLI flags passed after the image name (--config.file / --web.listen-address / --storage.tsdb.path / --storage.tsdb.retention.time). So pgcli renders prometheus.yml, bind-mounts it read-only, and points --config.file at the in-container path.
There is NO web login/password. Unlike Grafana (whose
0.0.0.0default is guarded by a generated strong password), Prometheus ships no authentication at all. Its HTTP API runs arbitrary PromQL and can be loaded by expensive queries, so a remote-exposed instance is guarded only by the network boundary — your firewall, or--listen 127.0.0.1for loopback. Treat exposing it like exposing an unauthenticated read/write console, not like a password-protected dashboard.
Platform: Linux is the tested platform. The design supports macOS (the image is multi-arch; the container runs under the podman machine VM on the
pgcli-netbridge with the port published), but it has not been validated there yet.
Parameters
| Flag | Default | Description |
|---|---|---|
--name |
prometheus |
Instance name (map key) |
--image |
docker.io/prom/prometheus:v3.15.0 |
Image override |
--port |
auto (base 9090) | Host web port; auto-allocated from prometheus_start_port |
--listen |
0.0.0.0 |
Bind address — defaults to every interface; use 127.0.0.1 for loopback-only (see the exposure warning above) |
--scrape |
— (self job always present) | Scrape spec JOB=HOST:PORT[,HOST:PORT...] [KEY=VALUE...] (repeatable) — the job name is required, every comma-separated target joins that one static_configs job in the generated prometheus.yml, and bare KEY=VALUE tokens after the target list become per-target labels applied to that spec’s targets (a spec with labels now contains a space — quote the whole value as one shell argument). Declares the FULL job set (replaces the stored list). Same job name across specs folds into one job; a node named by two specs merges both label sets and is scraped once, while nodes carrying different label sets split into separate static_configs blocks (Prometheus’s native shape for nodename=api1-style per-node labels). Targets are probed with GET /metrics from the host; unreachable ones only warn |
--scrape-append |
— | Same spec form (labels included), but folds the appended jobs’ targets — and their labels — into the stored --scrape list (same-named job merges, new names append) instead of replacing it. Every appended target is probed and the install ABORTS — writing nothing — if any returns no data. Mutually exclusive with --scrape and --conf-file |
--scrape-interval |
15s |
Global scrape_interval in the rendered config; mutually exclusive with --conf-file |
--retention |
15d |
TSDB retention (--storage.tsdb.retention.time); a duration like 15d, 6mo, 2y. A CLI flag, so it stays valid alongside --conf-file |
--conf-file |
— | Path to your own prometheus.yml — skip rendering entirely (mutually exclusive with --scrape/--scrape-append/--scrape-interval/--alertmanager-url/--remote-write-url/--remote-write-queue); see below |
--alertmanager-url |
— | Alertmanager instance(s) to forward fired alerts to (repeatable): bare HOST[:PORT] (portless gets 9093) or http(s)://HOST[:PORT][/path]. The rendered config gains an alerting: section pushing there and a matching alertmanager scrape job (so Alertmanager’s own /metrics is watched — not a separate opt-in). All entries validated up front and must share one scheme (mixing http/https aborts); connectivity is not probed. Mutually exclusive with --conf-file; see below |
--remote-write-url |
— | Remote-write URL(s) to push scraped samples to a long-term store (repeatable; a full http(s)://host:port/path URL). The rendered config gains a top-level remote_write: section. The local TSDB keeps its own --retention regardless — the remote is a WAL-backed copy, so a dead remote only stalls the queue and replays once it returns. Validated up front (must parse as http(s) with a host); connectivity is not probed. Mutually exclusive with --conf-file; see “Pushing samples to VictoriaMetrics” below |
--remote-write-queue |
— | A queue_config tuning applied under every --remote-write-url entry (repeatable KEY=VALUE): positive integers capacity, max_shards, min_shards, max_samples_per_send, and durations batch_send_deadline, min_backoff, max_backoff (e.g. 5s, 1m30s, 2y). VictoriaMetrics suggests bigger batches under load (max_samples_per_send=10000 capacity=20000 max_shards=30). Requires --remote-write-url; keys and values are validated up front (unknown keys, non-positive integers, and unitless durations abort before anything is written). Render-mode only, like --remote-write-url; per-remote tuning still needs --conf-file |
--rule-file |
— | Path to a rule file (*.rules.yml with a top-level groups:; repeatable) — pgcli copies each into its managed rules dir (basename only; two files sharing a basename abort), mounts that dir read-only at /etc/prometheus/rules, and the rendered config loads the copies via rule_files. Valid alongside --conf-file: the copy and mount happen either way, your own file decides whether to reference them. A missing/empty file aborts, writing nothing; see below |
--data-dir |
auto | Data directory override (default under the base dir) |
--force |
false | Recreate even when running — instead of hot-reloading a changed config, or to apply changed ports / listen / retention (install also recreates on its own when --rule-file goes from empty to non-empty or back, since that changes the container’s mount set) |
The data directory is Prometheus’s TSDB. It survives pg addon remove (delete it with --clean-data), so reinstalling under the same name revives collected metrics. The image runs as a baked nobody (uid/gid 65534) and creates no writable directory itself, so pgcli chowns the data dir on the host before creating the container — via podman unshare chown 65534:65534 under rootless podman, or a direct recursive chown when pgcli runs as root on a rootful host (exactly Grafana’s mechanism, different id).
Install
The command pulls the image if needed, renders a prometheus.yml, creates the container, and prints the URL, config path, and retention:
The rendered config always contains a self-monitoring job that scrapes this server’s own /metrics, so it is never idle; --scrape adds more jobs. Point Grafana at http://<host>:9090/ as a Prometheus datasource and you can query immediately.
Per-target labels (the nodename: api1 your Ansible-rendered config carries) are
bare KEY=VALUE tokens after the target list — one spec per labelled node:
Grouping is target-centric, so naming the same node twice never scrapes it
twice: two specs carrying the same 10.0.0.5:9100 merge into ONE target with the
union of their labels (node=a:1 nodename=api1 + node=a:1 env=prod → a:1
carrying both). The only rejection is a contradiction — the same node labelled
with the same key to two different values aborts instead of silently dropping
one. Label values may not contain whitespace (a device="eth0, eth1" needs
--conf-file), and label keys must be [A-Za-z_][A-Za-z0-9_]*; values are
emitted quoted, so yes or 2 cannot retype under YAML.
Re-running install with --scrape replaces the whole stored job set. To add to it instead, use --scrape-append — same spec form, labels included; it folds each appended target (and its labels) into the stored job of the same name and appends unseen names, then hot-reloads:
The two flags probe differently, on purpose: a freshly appended target must answer GET /metrics with 200 or the command aborts before writing anything (pg.yaml and the running container untouched); with plain --scrape an unreachable target is only a warning and the install proceeds — Prometheus will surface it as DOWN, and a full-list declaration is often pointed at endpoints that are legitimately not up yet. The probe dials from the host — on Linux (host network) that is the same namespace the container scrapes from; on the macOS bridge it differs, so the probe is skipped there.
Scrape targets are dialled from the Prometheus container. On Linux (host network) a 127.0.0.1:<port> target reaches the host’s loopback directly; on the macOS bridge it reaches the container’s own namespace instead, so a remote or VM-resident target should be given a routable address.
Re-running install on a running instance hot-reloads it (SIGHUP) when the rendered/copied config actually changed, and leaves it alone when it didn’t; --force recreates the container instead — to apply a changed --port / --listen / --retention (which are container arguments, not config-file contents).
Forwarding alerts to Alertmanager (--alertmanager-url)
Point a Prometheus instance at one or more pgcli-managed Alertmanagers and it will push fired alerts to them:
The rendered prometheus.yml then gains two things from the one flag — an alerting: section that pushes fired alerts, and a matching alertmanager scrape job that watches each instance’s own /metrics:
Accepted forms are bare host[:port] (a portless entry gets Alertmanager’s conventional 9093) or a full http(s)://host[:port][/path] URL; the scheme and path are stripped for the scrape target, and https is carried through as scheme: https on both sections (TLS knobs like CA files are out of render scope — they need --conf-file). Entries are validated before anything is written and must all share one scheme (http and https mixed together abort), but they are not connectivity-probed: Prometheus tracks dead alertmanagers on its own, and pg addon prometheus reload can’t fix a network problem anyway. A re-run replaces the stored URL list wholesale (there is no append form for it, unlike --scrape).
Verify the wiring live:
Pushing samples to VictoriaMetrics (--remote-write-url)
Prometheus’s built-in TSDB is compiled in and cannot be swapped out — the way to put VictoriaMetrics (or any remote store) behind it is remote_write: each scrape cycle pushes a copy of every sample to the remote, while the local TSDB keeps its own --retention window and keeps serving queries regardless. pgcli already runs both ends, so the pair is two installs:
The rendered prometheus.yml gains a top-level remote_write: section — with a queue_config: block under each URL when --remote-write-queue is set:
A re-run replaces the stored URL list wholesale (no append form, like --alertmanager-url), and a changed URL is applied by hot reload (SIGHUP) — no container recreate. Entries must be full http(s)://host[:port]/path URLs (unlike Alertmanager targets there is no portless/scheme default — the path is the write endpoint); they are validated up front but not connectivity-probed. --remote-write-queue rides the same path: a repeated list replaces the stored one, it is validated up front (whitelisted keys only — integer sizes/shards and duration timeouts), and the same KEY=VALUE set is emitted under every remote, so differentiated per-remote tuning still drops to --conf-file. Passing it without any --remote-write-url aborts — there is nothing to tune under.
Verify the link is alive and delivering:
Once Grafana is pointed at VictoriaMetrics instead of Prometheus (http://<host>:8428/, or http://<host>:8481/select/0/prometheus/ for the cluster edition), the Prometheus instance is a thin scrape-and-forward layer and VictoriaMetrics is the queryable store. Historical data already in the Prometheus TSDB does not backfill automatically — use the vmctl prometheus tool for a one-time migration.
Loading alerting rules (--rule-file)
Prometheus evaluates alerting/recording rules from rule_files:. Give pgcli the rule file and it becomes a managed copy:
pgcli copies each file into <base>/addon/prometheus/<name>/rules/, bind-mounts that directory read-only at /etc/prometheus/rules, and the rendered config references the copy by name:
A flat directory means the basename is what matters: two source files sharing one basename abort the install (the copy would silently clobber one).
Editing your original rule file takes effect with a plain reload: pg addon prometheus reload re-copies every --rule-file into the managed dir before signalling, so Prometheus rereads the new rules in place — no reinstall, no container restart:
A bad rule file is rejected the same way a bad config is: Prometheus keeps serving the previous rule set and logs the parse error, so check the log (or the rules API) after reloading hand-edited rules. The copies are managed artifacts, deleted by pg addon remove regardless of --clean-data, exactly like the rendered prometheus.yml. Your originals are never touched.
--rule-file differs from the other render knobs in that it stays valid alongside --conf-file: pgcli copies the rules and adds the mount either way, and your own file decides whether to reference /etc/prometheus/rules (a rule_files: ["/etc/prometheus/rules/*.rules.yml"] glob in it picks up whatever you passed). The one flag whose addition cannot be hot-reloaded is this one going from empty to non-empty (or back): the /etc/prometheus/rules mount exists only while there are rule files, and a SIGHUP cannot add a volume, so that transition recreates the container.
Custom config file (--conf-file)
Give pgcli your own prometheus.yml and it skips rendering entirely:
pgcli copies your file into its managed path (<base>/addon/prometheus/<name>/prometheus.yml) and mounts that copy read-only — never your original. This is deliberate: a single-file bind mount pins the source inode, so an in-place edit of your original file would not propagate into the running container. Because it’s a copy, editing your own file and running pg addon prometheus reload re-copies it and signals Prometheus to reread. pgcli does not validate or rewrite the file’s contents (same contract as the nginx/haproxy --conf-file). --conf-file is mutually exclusive with the render knobs --scrape/--scrape-append/--scrape-interval/--alertmanager-url/--remote-write-url/--remote-write-queue (an operator file owns its alerting: and remote_write: sections); --retention, --listen and --rule-file are not render knobs and stay valid alongside it. Omit --conf-file on a reinstall to go back to render mode.
Worked example
A real operator file wired to a pgcli Alertmanager and loading pgcli-managed rule files:
That labelled nodes shape is exactly what render mode now emits — the three
--scrape node=…:9100 nodename=… specs fold into one nodes job carrying one
labelled static_configs block each; the hand-written file remains the way to
add per-job extras (scrape timeouts, relabels) the renderer does not expose.
localhost:9090 and localhost:9093 work because Linux containers run on the host network — localhost inside Prometheus is the host, where both web ports listen. The two pieces pgcli contributes are visible in the file: --rule-file copies your rule files into <base>/addon/prometheus/<name>/rules/ and bind-mounts that directory at /etc/prometheus/rules, which is why the rule_files: glob above resolves; the alerting: block, on the other hand, is yours to write — in --conf-file mode pgcli adds nothing to the file, does not derive the alertmanager scrape job either, and rejects --alertmanager-url as a render knob. Check a hand-written file before relying on pg addon prometheus reload to surface errors in the log:
That inspects the copy pgcli mounted, so run it after an install (or a reload) rather than against your original path.
Reload (hot config change)
This rewrites the managed prometheus.yml (re-render in render mode, re-copy your file in --conf-file mode, re-copy the --rule-file copies in both) and sends SIGHUP to the running container, which makes Prometheus reread it in place — no container recreate, no dropped scrapes. It uses a signal rather than the HTTP POST /-/reload endpoint on purpose, so the unauthenticated reload route never has to be enabled (--web.enable-lifecycle is not set). Prometheus keeps serving on its last good config and logs an error if the new one is malformed, so check pg logs addon prometheus after reloading a hand-written file. The instance must already be running (pg addon start prometheus). One change a signal cannot apply: --rule-file going from empty to non-empty (or back) adds/removes the /etc/prometheus/rules mount, so a plain install recreates the container for that transition — reload is enough for edits within a non-empty rule set.
Listening and exposure
Prometheus’s Listen defaults to 0.0.0.0: the container shares the host’s network namespace, so the UI and API are reachable on every interface at the assigned port. This matches Grafana’s default for the same practical reason (Grafana on another host needs to reach it) — but there is no password behind it. Anyone who can reach the port can run arbitrary PromQL and read your collected metrics. For a loopback-only deployment pass --listen 127.0.0.1 (recreate with --force); pg addon list then shows http://127.0.0.1:<port>/. Otherwise, restrict access at the firewall.
Ports
Each Prometheus instance owns a single port: the web UI/API. It is allocated from a pool with base prometheus_start_port (default 9090 — Prometheus’s conventional web port), incrementing for every additional instance and skipping ports already taken by any managed container or the used_ports list. The allocation is written into pg.yaml, so a reinstall never re-hands-out a port another addon is using. 9090 shares no number with Grafana (3000), pgAdmin (5050), or nginx (8080); an explicit --port still wins.
Configuration
The rendered prometheus.yml carries the global scrape_interval, the always-present self job, and one static_configs job per scrape JOB — every target a job’s specs list folds into that job, bucketed by merged label set so each distinct KEY=VALUE combination becomes its own - targets:/labels: block; --alertmanager-url adds the alerting: section plus its alertmanager scrape job, --remote-write-url adds a top-level remote_write: section (--remote-write-queue fills each entry with a queue_config: block), and --rule-file adds a rule_files: section. retention and the listener bind are not in that file — they are passed to the prometheus binary as CLI flags (--storage.tsdb.retention.time, --web.listen-address), which is why they remain settable alongside --conf-file.
List
There are no credentials to reveal — Prometheus has none — so --show-password shows nothing extra for it.
Start and stop
Containers run with podman’s unless-stopped restart policy, so they come back automatically with the podman service after a host reboot. start/stop manage them explicitly:
Auto-start on Boot
To have a Prometheus instance started automatically on host boot, enable it:
This flags the instance in pg.yaml and installs/refreshes a boot service (a systemd --user unit on Linux, a LaunchAgent on macOS) that runs pg start --autostart. A boot-time start only brings the container up — reading the managed prometheus.yml already on disk, and the data dir was already chowned at create time, so it needs no host privileges beyond podman itself. Prometheus starts after Grafana and before nginx in the boot order. See Autostart for the full mechanism, status and disabling.
Remove
Under rootless podman, --clean-data deletes the data dir through podman unshare rm — Prometheus writes as its container uid (65534), which maps to a subordinate uid the host user cannot rm directly. --conf-file mode removes only pgcli’s copy; your original file wherever you put it is never touched. --rule-file originals likewise survive — only the managed rules/ copies go away, and they go away on every remove, with or without --clean-data (like the rendered prometheus.yml, they carry no state worth reviving).
Logs
Troubleshooting
installfails with “chowning Prometheus data dir … to 65534:65534” — the image runs asnobody(uid/gid 65534) and never fixes ownership itself, so pgcli chowns the host data dir before creating the container: throughpodman unshare chownunder rootless podman (where the mapped subordinate uid is the only correct target), or a direct recursivechownwhen pgcli itself runs as root on a rootful host. The manager shells out to the plainpodmanCLI, so this only fails outside those two cases — e.g. a non-rootpgexplicitly pointed at someone else’s rootful daemon. Run the install as root, or switch to rootless podman; a manualsudo chown -R 65534:65534 <data-dir>alone does not clear it, since pgcli re-checks ownership on every install attempt.- A scrape target shows
DOWN— confirm Prometheus can reach it (it dials from the container; on Linux127.0.0.1:<port>is the host loopback, but a cross-host/VM target needs a routable address, especially on the macOS bridge). Check the exporter is up and the path is/metrics.pg logs addon prometheusshows scrape failures. --scrape-appendaborted with “returned no data” — by design: an appended target must answerGET /metricswith 200 from the host before anything is written (the existing instance andpg.yamlstay untouched). Bring the exporter up, or use plain--scrape(which only warns) when declaring the full job set. The probe is skipped on macOS.- A config reload didn’t take effect —
pg addon prometheus reloadneeds the container running; if the newprometheus.ymlis malformed Prometheus keeps serving the last good config and logs the error. Checkpg logs addon prometheus, fix the file, reload again. - Port already allocated — the assigned port is held in
pg.yamland skipped for later instances; free it withpg addon removefirst, or pick another with--port N. - Remote API unreachable / unexpectedly reachable — on the default
0.0.0.0a failure to reach it is host firewall, not bind address; conversely, if you did not want it reachable,--listen 127.0.0.1 --force.
Known limitations
- v1 is a bare collection backend: it ships no PostgreSQL exporter (install one yourself and point
--scrape postgres-exporter=127.0.0.1:9187at it). Long-term storage and alerting are wired, though:--remote-write-urlpushes scraped samples to a VictoriaMetrics addon (single-node orvmcluster),--alertmanager-urlforwards fired alerts to a pgcli Alertmanager, and--rule-fileloads alerting rules (all render-mode;--conf-filecovers the rest by hand). It is the scrape/store/query layer you point Grafana at. - No authentication. Prometheus’s API runs arbitrary PromQL; the only protection for a
0.0.0.0bind is the network boundary. This is a materially weaker posture than Grafana’s password-guarded0.0.0.0— do not expose it on an untrusted network. - pgcli renders or copies
prometheus.ymlbut does not validate it (there is nopromtool check configgate at install). A bad rendered config is impossible, but a bad--conf-filesurfaces only in the container logs on reload. - The host-side ownership chown is automatic under rootless podman (
podman unshare chown) and when pgcli runs as root on a rootful host (directchown). A rootful daemon talked to by a non-root pgcli (only via an explicitly configured remote socket) has no automatic path. - On macOS the container runs inside the podman machine; it has been validated on Linux only.
12.17 - Alertmanager
pg addon install alertmanager runs Alertmanager — the alert deduplication, grouping, routing and notification layer that sits downstream of Prometheus — as a managed container from the official docker.io/prom/alertmanager image. Like Prometheus and Grafana it is a top-level addon: it fronts no single PostgreSQL instance, so it is not attached to any one of them. Prometheus and Alertmanager are the natural pair — Prometheus fires alerts, Alertmanager decides who gets notified and how — and this addon is Prometheus’s near-exact twin in shape (single container, host networking on Linux, one web port from its own pool, persistent data dir, autostart, logs, rendered config file + trailing CLI flags, SIGHUP reload, no web login).
The wiring lives on the Prometheus side.
pg addon install alertmanagerdoes not touch anyprometheus.yml— and it does not need to: Prometheus points at this instance itself, withpg addon install prometheus --alertmanager-url <host:port of this instance>, which injects thealerting:section (and a scrape job watching this instance’s/metrics). On a hand-written Prometheus config (--conf-file), write thealerting:block yourself. Either way, once pointed here, alerts flow.
There is NO web login/password — and the stakes are higher than Prometheus’s. Alertmanager ships no authentication at all, and its unauthenticated API is write-capable in a way Prometheus’s read-mostly one is not: anyone who reaches the port can create silences — i.e. silently mute every notification your on-call expects, with no trace in Prometheus’s own metrics. Its
0.0.0.0default is therefore guarded only by the network boundary, and that boundary matters more here than anywhere else in the addon family. Treat exposing it as handing out a mute switch for your alerting, not like a password-protected dashboard.
Platform: Linux is the tested platform. The design supports macOS (the image is multi-arch; the container runs under the podman machine VM on the
pgcli-netbridge with the port published), but it has not been validated there yet.
Parameters
| Flag | Default | Description |
|---|---|---|
--name |
alertmanager |
Instance name (map key) |
--image |
docker.io/prom/alertmanager:v0.34.1 |
Image override |
--port |
auto (base 9093) | Host web port; auto-allocated from alertmanager_start_port |
--listen |
0.0.0.0 |
Bind address — defaults to every interface; use 127.0.0.1 for loopback-only (see the exposure warning above) |
--webhook-url |
— | Webhook URL to notify (repeatable). Each URL is rendered as one webhook_configs entry of the SAME web.hook receiver — multiple URLs fan out to all of them. Validated with net/url (parseable, http/https, non-empty host) before anything is written; a bad URL aborts the install touching nothing. Connectivity is NOT probed (see below). Mutually exclusive with --conf-file |
--conf-file |
— | Path to your own alertmanager.yml — skip rendering entirely (mutually exclusive with --webhook-url); see below |
--data-dir |
auto | Data directory override (default under the base dir) |
--force |
false | Recreate even when running — instead of hot-reloading a changed config, or to apply changed ports / listen |
The data directory holds Alertmanager’s state: silences and notification state. It
survives pg addon remove (delete it with --clean-data), so reinstalling under the
same name revives them. The image runs as a baked nobody (uid/gid 65534) and creates
no writable directory itself, so pgcli chowns the data dir on the host before creating
the container — via podman unshare chown 65534:65534 under rootless podman, or a
direct recursive chown when pgcli runs as root on a rootful host (exactly Prometheus’s
and Grafana’s mechanism, different id).
--webhook-url is not connectivity-probed, on purpose: unlike a Prometheus scrape
(GET /metrics, reachable at install time and answerable then), a webhook is a POST
that only fires when an alert actually routes through it — dialling it at install would
tell you nothing you could rely on. The only early gate is the URL’s shape; whether
the endpoint exists, is reachable, and accepts the payload is discovered at alert time
(check pg logs addon alertmanager when notifications go missing).
Install
The command pulls the image if needed, renders an alertmanager.yml, creates the
container, and prints the URL, config path, and what the route renders to:
With no --webhook-url the rendered config is a single route pointing at a name-only
no-op receiver: a legal, fully-functional config — the web UI and API are live,
silences work, the HA cluster port is disabled — that simply sends nothing. This is
the “bring up the endpoint, decide routing later” default. To actually notify something,
add a webhook:
Re-running install on a running instance hot-reloads it (SIGHUP) when the
rendered/copied config actually changed, and leaves it alone when it didn’t;
--force recreates the container instead — to apply a changed --port / --listen
(which are container arguments, not config-file contents).
Re-running with a different --webhook-url set replaces the stored list wholesale
(passing none clears it back to no-op); the diff is what decides whether to reload.
Custom config file (--conf-file)
Give pgcli your own alertmanager.yml and it skips rendering entirely:
pgcli copies your file into its managed path
(<base>/addon/alertmanager/<name>/alertmanager.yml) and mounts that copy
read-only — never your original. This is deliberate: a single-file bind mount pins the
source inode, so an in-place edit of your original file would not propagate into the
running container. Because it’s a copy, editing your own file and running
pg addon alertmanager reload re-copies it and signals Alertmanager to reread. pgcli
does not validate or rewrite the file’s contents (same contract as the
nginx/haproxy/prometheus --conf-file). --conf-file is mutually exclusive with
--webhook-url (the flag that shapes the rendered file) — a real routing tree
(email/Slack/PagerDuty, sub-routes, inhibition) lives in your own file. Omit
--conf-file on a reinstall to go back to render mode.
The file is plain Alertmanager YAML — pgcli adds nothing to it and expects nothing from it beyond “parses”. A worked example with a sub-route tree, inhibition, and three different receiver types:
pgcli itself never validates this file (no amtool check-config gate at install — see
Known limitations), but the image ships amtool, so you can check the managed copy
inside the running container before trusting a reload (container name as printed by
pg addon list, namespace-scoped — pgcli-alertmanager-default-am1 in the transcript
above):
Reload (hot config change)
This rewrites the managed alertmanager.yml (re-render in render mode, re-copy your
file in --conf-file mode) and sends SIGHUP to the running container, which makes
Alertmanager reread it in place — no container recreate, no dropped notifications.
Unlike Prometheus, Alertmanager’s POST /-/reload endpoint exists and cannot be
disabled (there is no --web.enable-lifecycle-style gate on it). pgcli still reloads
via SIGHUP — not because the route is closed, but so it never has to depend on an
unauthenticated HTTP endpoint that is, by the image’s design, always open. Anyone who
can reach the port can already hit that route directly; pgcli simply doesn’t add to the
surface. If you reload a hand-written --conf-file whose config is rejected, Alertmanager
keeps its last good config and logs the error, so check pg logs addon alertmanager
afterwards. The instance must already be running (pg addon start alertmanager).
Listening and exposure
Alertmanager’s Listen defaults to 0.0.0.0: the container shares the host’s network
namespace, so the UI and API are reachable on every interface at the assigned port.
There is no password behind it, and — unlike Prometheus — the API lets a caller
CREATE SILENCES, i.e. mute every notification your on-call expects, and reload its
config. A loopback-only deployment is the safe default: pass --listen 127.0.0.1
(recreate with --force); pg addon list then shows http://127.0.0.1:<port>/.
Otherwise restrict access at the firewall, and treat an open 0.0.0.0 as a
reachable mute switch.
Ports
Each Alertmanager instance owns a single port: the web UI/API. It is allocated from a
pool with base alertmanager_start_port (default 9093 — Alertmanager’s conventional
web port), incrementing for every additional instance and skipping ports already taken
by any managed container or the used_ports list. The allocation is written into
pg.yaml, so a reinstall never re-hands-out a port another addon is using. 9093
shares no number with Grafana (3000), pgAdmin (5050), Prometheus (9090), or nginx
(8080); an explicit --port still wins.
9094 — the HA gossip port — is never opened, because it’s disabled by a flag. Alertmanager’s high-availability clustering listens on TCP+UDP
9094by default and will keep trying to bootstrap peers that never arrive for a single instance. pgcli passes--cluster.listen-address=(empty) to switch it off entirely — a single-instance contract, enforced at every create, so the cluster port never opens and no UDP listener lingers. (This is only about the cluster use of9094; the number is not reserved, so the web-port pool is free to hand9094out as the second instance’s web port — see the example below.) Clustering several pgcli-managed Alertmanagers into one gossip group is not wired up (see Known limitations).
Configuration
The rendered alertmanager.yml carries a global.resolve_timeout, one root route
(group_by: ['alertname'], default group_wait/interval/repeat_interval) pointing at a
single receiver, and that receiver’s webhook_configs — one entry per webhook URL
under web.hook, or a name-only no-op receiver when the list is empty. The listener
bind, storage path, and the disabled cluster address are not in that file — they
are passed to the alertmanager binary as CLI flags (--web.listen-address,
--storage.path, --cluster.listen-address=), which is why --listen remains
settable alongside --conf-file.
List
There are no credentials to reveal — Alertmanager has none — so --show-password shows
nothing extra for it, and pg addon password alertmanager resolves nothing.
Start and stop
Containers run with podman’s unless-stopped restart policy, so they come back
automatically with the podman service after a host reboot. start/stop manage them
explicitly:
Auto-start on Boot
To have an Alertmanager instance started automatically on host boot, enable it:
This flags the instance in pg.yaml and installs/refreshes a boot service (a systemd
--user unit on Linux, a LaunchAgent on macOS) that runs pg start --autostart. A
boot-time start only brings the container up — reading the managed alertmanager.yml
already on disk, and the data dir was already chowned at create time, so it needs no
host privileges beyond podman itself. In the boot order Alertmanager starts right after
Prometheus and before nginx — the natural companion to the collector above, which is
also where the two get linked (pg addon install prometheus --alertmanager-url …). See Autostart for
the full mechanism, status and disabling.
Remove
Under rootless podman, --clean-data deletes the data dir through podman unshare rm —
Alertmanager writes as its container uid (65534), which maps to a subordinate uid the
host user cannot rm directly. --conf-file mode removes only pgcli’s copy; your
original file wherever you put it is never touched.
Logs
Troubleshooting
installfails with “chowning Alertmanager data dir … to 65534:65534” — the image runs asnobody(uid/gid 65534) and never fixes ownership itself, so pgcli chowns the host data dir before creating the container: throughpodman unshare chownunder rootless podman (where the mapped subordinate uid is the only correct target), or a direct recursivechownwhen pgcli itself runs as root on a rootful host. The manager shells out to the plainpodmanCLI, so this only fails outside those two cases — e.g. a non-rootpgexplicitly pointed at someone else’s rootful daemon. Run the install as root, or switch to rootless podman; a manualsudo chown -R 65534:65534 <data-dir>alone does not clear it, since pgcli re-checks ownership on every install attempt.- No notifications arrive — the most common cause is a
no-opreceiver: an install with no--webhook-urlrenders a config that never sends anything. Check theConfig:line inpg addon list(it saysno-op receivervsrendered (N webhooks)). If a real webhook is configured, it is not connectivity-probed at install — reachability is only discovered when an alert fires, so checkpg logs addon alertmanagerfor POST failures and confirm the endpoint accepts Alertmanager’s webhook payload. --webhook-urlaborted with a parse error — by design: every URL is validated (net/urlparse, http/https scheme, non-empty host) before pg.yaml or any file is written, so a bad URL leaves the running instance and config untouched. Fix the URL and re-run.- A config reload didn’t take effect —
pg addon alertmanager reloadneeds the container running; if the newalertmanager.ymlis rejected (a route referencing an undefined receiver, bad YAML) Alertmanager keeps serving the last good config and logs the error. Checkpg logs addon alertmanager, fix the file, reload again. - Port already allocated — the assigned port is held in
pg.yamland skipped for later instances; free it withpg addon removefirst, or pick another with--port N. - Remote API unreachable / unexpectedly reachable — on the default
0.0.0.0a failure to reach it is host firewall, not bind address; conversely, if you did not want it reachable (and remember: reachability means anyone can create silences),--listen 127.0.0.1 --force.
Known limitations
- One-sided wiring. This addon neither injects an
alerting:section into aprometheus.ymlnor reads one from Prometheus — it only receives whatever is pushed to its API. The linking is done from the Prometheus side (pg addon install prometheus --alertmanager-url …, render mode; in--conf-filemode write thealerting:block yourself). Analertmanager-awareprometheus.ymlis never inspected from here. - No authentication — and its consequences are sharper than Prometheus’s. The API can
create silences (mute on-call) and reload config, and
POST /-/reloadcannot be disabled in the image (pgcli just doesn’t use it). The only protection for a0.0.0.0bind is the network boundary; do not expose it on an untrusted network. - Single instance / cluster disabled.
--cluster.listen-address=is always passed empty, so HA gossip (TCP+UDP 9094) never opens. Clustering several pgcli-managed Alertmanagers is not supported in v1. - Webhooks are not connectivity-probed at install (only their URL shape is validated). Reachability and payload acceptance surface at alert time, in the logs.
- pgcli renders or copies
alertmanager.ymlbut does not validate it (there is noamtool check-configgate at install). A bad rendered config is impossible, but a bad--conf-filesurfaces only in the container logs on reload. - The host-side ownership chown is automatic under rootless podman (
podman unshare chown) and when pgcli runs as root on a rootful host (directchown). A rootful daemon talked to by a non-root pgcli (only via an explicitly configured remote socket) has no automatic path. - On macOS the container runs inside the podman machine; it has been validated on Linux only.
12.18 - Gitea
pg addon install gitea runs Gitea — the
lightweight self-hosted Git service — as a managed container from the official
upstream image (docker.io/gitea/gitea, rootful build). Like Grafana it is a
top-level addon: it is not attached to any one PostgreSQL instance — it fronts
none, though it can use one as its metadata database (see --dsn below).
One container serves the web UI, the Git HTTP traffic, and git-over-ssh on the
image’s OpenSSH sshd (see Git SSH — that sshd runs on
every instance whether or not --ssh-port is set; the flag only makes it the
live clone endpoint).
The install is fully non-interactive: INSTALL_LOCK=true skips Gitea’s web setup wizard, and the admin account is created afterwards via the in-container gitea admin user create CLI. The web login password is generated on first install, printed once, and stored in pg.yaml; retrieve it later with pg addon password gitea. Listen defaults to 0.0.0.0 — a Git host is cloned from other machines — and the exposure is guarded by the generated strong password plus closed self-registration; pass --listen 127.0.0.1 for loopback-only access.
Platform: Linux is the tested platform. The design supports macOS (the image is multi-arch; the container runs under the podman machine VM on the
pgcli-netbridge with the web port — and, when enabled, the SSH port — published), but it has not been validated there yet.
Parameters
| Flag | Default | Description |
|---|---|---|
--name |
gitea |
Instance name (map key) |
--image |
docker.io/gitea/gitea:28.1.0 |
Image override (rootful build only — see Known limitations) |
--port |
auto (base 3080) | Host web port; auto-allocated from gitea_start_port |
--ssh-port |
unset | Make the always-running sshd the live git-over-ssh endpoint on this host port (see Git SSH). A port is allocated from gitea_ssh_start_port (base 2222) for every instance to park sshd off 22, SSH on or off; --ssh-port 0 disables live cloning but keeps the reservation |
--listen |
0.0.0.0 |
Bind address — defaults to every interface; use 127.0.0.1 for loopback-only. Also the host Gitea renders into clone URLs |
--admin-user |
gitea |
Web login user (deliberately not git — that is the container’s OS user ssh:// clones authenticate as) |
--password |
auto-generated | Web login password (applied only when the admin account does not yet exist — see below) |
--dsn |
empty | postgres://user:pass@host:port/db to keep Gitea’s metadata in an external PostgreSQL instead of the embedded SQLite; the database must already exist |
--allow-registration |
false | Open self-service signup (the web UI shows a Register entry). Closed by default — see Listening and exposure |
--data-dir |
auto | Data directory override (default under the base dir) |
--force |
false | Recreate even when running — to apply changed ports, credentials, dsn, listen address or the registration toggle |
Install
The command pulls the image if needed, creates and starts the container, waits for Gitea’s first-boot migrations to finish, creates the admin account, and prints:
Open the URL, sign in with the printed credentials, and create your first org/repo in the web UI. Clone over HTTP with the same login:
The data directory and /data
Everything Gitea owns lives in one bind-mounted directory: the repositories, the rendered /data/gitea/conf/app.ini, the SQLite metadata database (/data/gitea/gitea.db — absent with --dsn), the SSH host keys, and logs. It survives pg addon remove (delete it with --clean-data), so reinstalling under the same name revives everything.
The image’s rootful build runs as the git user — uid/gid 1000, pinned via USER_UID/USER_GID so the host-side pre-chown and the container process can never drift apart — and its entrypoint only chowns what it creates itself, so pgcli chowns the data dir on the host before creating the container: via podman unshare chown 1000:1000 under rootless podman, or a direct recursive chown when pgcli runs as root on a rootful host.
pgcli never injects SECRET_KEY or INTERNAL_TOKEN. Gitea folds every GITEA__<section>__<KEY> environment variable into the persisted app.ini on each boot; if pgcli regenerated and injected crypto secrets, a reinstall over a kept data dir would re-key the persisted app.ini while the surviving database still holds 2FA seeds and OAuth client secrets encrypted under the old key — a silent, unlogged lockout. So those keys belong to the volume: Gitea generates them itself on first boot, and pgcli owns only the admin login password.
The admin password takes effect only when the admin account does not exist in the database — it is created with gitea admin user create, never reset. Reinstalling over a kept data dir (or a kept --dsn database) keeps the original login; the printed password is then inert (install says so explicitly). To change the login of an existing store, run gitea admin user change-password inside the container (pg exec <instance> -- ... style: podman exec -it <container> gitea admin user change-password ...), or remove with --clean-data and reinstall (SQLite mode — a --dsn database also has to be dropped by hand).
Re-running install reuses a running container and starts a stopped one — config changes only apply after a remove + reinstall or --force.
Database: SQLite or --dsn
By default Gitea uses its embedded SQLite database inside /data — zero dependencies, one file, revived with the data dir. For anything shared or long-lived, point the metadata at PostgreSQL:
pgcli validates the DSN before writing anything and renders it into GITEA__database__DB_TYPE=postgres plus HOST/NAME/USER/PASSWD. The install summary and pg addon list echo it back with the credentials hidden (host:port/db). Same contract as PostgREST’s --dsn: the database must already exist (create it on a managed instance with pg exec <instance> -c 'CREATE DATABASE gitea ...' or any other way); Gitea creates its own schema on first boot. pg addon remove gitea — even with --clean-data — never touches the external database; it only holds Gitea’s tables.
Git SSH (optional)
Every Gitea instance runs an OpenSSH sshd — the official image’s s6
openssh service is unconditional (its run script execs sshd; no
GITEA__server__* env turns it off). What --ssh-port actually toggles is
whether that sshd is the live git-over-ssh clone endpoint or just an idle
listener. --ssh-port takes a host port from gitea_ssh_start_port (base
2222) for every instance — SSH on or off — precisely so the always-on
sshd is parked off 22: on the host network, leaving it on 22 would race
the host’s own sshd and the log would fill with Bind to port 22 … Address in use forever. pgcli moves it by setting the image’s bare SSH_LISTEN_PORT
/ SSH_PORT env (those are what the rendered sshd_config’s Port line
actually consumes — GITEA__server__SSH_LISTEN_PORT alone only changes
what Gitea believes the port is, not what sshd binds).
- SSH off (default, no
--ssh-port):DISABLE_SSH=trueso Gitea neither writes the git user’sauthorized_keysnor advertisesssh://clone URLs. The s6sshdis still running, parked on a pool port, and no client will ever reach it. The pool reservation stays — nothing frees it. - SSH on (
--ssh-port N, N>0): git-over-ssh rides that same s6 sshd — the image’s native route.DISABLE_SSH=falsemakes Gitea populate the git user’sauthorized_keys(which sshd reads viaAuthorizedKeysFilepointed into/data/gitea/ssh/); Gitea’s own built-in Go SSH listener is pinned off withSTART_SSH_LISTENER=falseso it does not double-bind the same port in the same netns; andSSH_DOMAIN/SSH_PORTrender thessh://git@<host>:N/<owner>/<repo>.gitclone URL.
Auth is the git account’s SSH keys added through the web UI (the OS user
is git; the web admin is gitea). Host keys live under
<data>/ssh/, so they survive reinstalls — if you remove with
--clean-data, clients see a new-host-key warning the next time.
Under the macOS bridge the SSH port is additionally published, but only when
SSH is on — a parked sshd is unreachable by design, so it gets no -p.
Not passing --ssh-port on a later install keeps the stored setting;
--ssh-port 0 disables live cloning (the port reservation stays — only the
DISABLE_SSH flip and the clone-URL change take effect, with a --force
recreate).
Listening and exposure
Gitea’s Listen defaults to 0.0.0.0: the container shares the host’s network namespace, so the web UI and Git HTTP are reachable on every interface at the assigned port. This is deliberate — a Git host is cloned from other machines — and is protected by the generated strong admin password plus DISABLE_REGISTRATION=true (self-service account creation is off; create accounts with pg addon gitea user create). For a loopback-only deployment pass --listen 127.0.0.1 (recreate with --force).
--allow-registration flips that second guard: pg addon install gitea --name <n> --allow-registration --force renders DISABLE_REGISTRATION=false and the web UI grows a Register entry. Close it again with --allow-registration=false --force — the toggle is a stored config value gated on the flag being passed, so a plain re-install (or one without the flag) keeps whatever state is stored, and only --force pushes a change into the live container. What opens up is account spam, not takeover: a field test confirmed the register form creates a NORMAL user (IsAdmin=false) — Gitea has no way to make signup produce an admin; elevation is exclusively pg addon gitea user create --admin or the admin panel, independent of how the account was born. Companion Gitea-side hardening for an open-registration site lives in the same service section (REGISTER_EMAIL_CONFIRM, CAPTCHA, REQUIRE_SIGNIN_VIEW) — outside pgcli’s rendered env set, so it stays Gitea-side configuration.
--listen is also what displayed clone URLs use: a wildcard bind is not a connectable address, so it is echoed as localhost, and any explicit host/IP appears verbatim in ROOT_URL and the ssh:// URLs. When Gitea sits behind a reverse proxy that forwards X-Forwarded-Proto and Host — which pgcli’s nginx template does by default — Gitea rewrites the displayed clone URL to match the incoming request, so the code button on the repo page reads https://<proxy-host>/<owner>/<repo>.git correctly (scheme from X-Forwarded-Proto, host from Host, standard port dropped). pgcli’s rendered ROOT_URL stays http://<host>:<web-port>/ because there is no config face to advertise https/the proxy port — but that only governs webhook/redirect URLs generated server-side; interactive clone URLs follow the request. For TLS, see HTTPS via nginx.
HTTPS via nginx
Gitea serves plaintext HTTP; to reach it over https:// you front it with the
nginx addon, which terminates TLS and proxies to the
Gitea web port. Two things to get right: bring your own certificate (or let
pgcli mint a self-signed one), and use nginx’s custom --conf-file mode —
the template’s default client_max_body_size (1 MiB) rejects any larger
git push with HTTP 413.
Write the proxy config as /srv/gitea-nginx.conf — the parts that matter for
Git are client_max_body_size 10m (nginx’s built-in default is 1 MiB, and
without raising it a larger git push dies with HTTP 413 before reaching
Gitea) and the long proxy_*_timeout (streaming git push outlives the 60s
default). The same file as a generic template also lives in the nginx docs
(“Git HTTP push with client_max_body_size”):
The nginx proxy now serves https://10.0.0.9/ and forwards to Gitea’s
127.0.0.1:3080. Clone and push over the proxy URL (hand clients the .crt as
their CA so the self-signed cert verifies):
Notes:
- The Gitea web port (3080) is still open directly; the proxy does not hide it.
For an origin that only nginx may reach, reinstall Gitea with
--listen 127.0.0.1so it binds loopback only, and point the proxy’s backend at127.0.0.1:3080(same host) — works because both run on the host network. pg addon nginx reloadwill not pick up an external edit to the mountednginx.conf(the bind mount pins the original inode, so ased -i/editor rewrite is invisible until the container restarts). Reload only applies changes pgcli itself wrote. After hand-editing the conf, restart withpg addon stop nginx --name proxy && pg addon start nginx --name proxy, or reinstall with--force.- A real CA cert (Let’s Encrypt / ZeroSSL / corporate) is the right choice in
production — see Production domain + a real CA below; it drops the
--cacerthand-off entirely, browsers and git trust it out of the box. - git-over-ssh (
--ssh-port) is independent of this; TLS here only fronts the web UI and Git-over-HTTP.
Production domain + a real CA (Let’s Encrypt / corporate)
For anything facing the internet, don’t ship a pg cert self-signed leaf —
point a real domain at the host (e.g. git.example.com → 203.0.113.7) and get a
CA-signed certificate for it. How you obtain that certificate is outside
pgcli’s scope — any CA and any tool you like; all that matters is ending up
with a PEM cert (including its intermediate chain) and its private key.
Then create the nginx proxy exactly as in the steps above, only swapping the
certificate paths for your domain cert — the same --conf-file, ports and
--tls are unchanged:
Set --listen git.example.com on the Gitea install too (with --force), so
ROOT_URL and any ssh:// hints carry the real hostname. Once it’s up, users
clone https://git.example.com/<owner>/<repo>.git with no CA hand-off —
browsers and git trust public CAs by default.
Remember to replace the certificate before it expires. pgcli copies the BYO
pair into the addon’s TLS dir (<base>/addon/nginx/<name>/tls/ as
public.crt / private.key) at install, and that directory is bind-mounted
into the container — so renewal is: overwrite those two files, then
(Verified: the served certificate changes immediately after the reload. This
works because the TLS dir is mounted as a directory; unlike the single-file
nginx.conf mount, new contents are visible to the running container.)
Retrieving the web login
The login is Gitea’s web/Git-HTTP sign-in, unrelated to any PostgreSQL role (even with --dsn). Re-read the password at any time:
It is also stored in pg.yaml (addons.gitea.<name>.password).
Managing accounts (members and admins)
Self-registration is closed, so every account beyond the install admin is created
by an admin. pg addon gitea user wraps the in-container gitea admin user …
CLI (run as the git uid automatically) so you manage accounts from the host
without a manual podman exec. The instance must be running.
flag (create) |
default | meaning |
|---|---|---|
--username |
(required) | the account name |
--email |
<username>@localhost |
pass a real address when the user should get notifications / password resets |
--password |
generated | pin the login, or let pgcli generate a strong one and print it once |
--admin |
false | create a site admin instead of a normal member |
--must-change-password |
false | Gitea’s own default is true for every account after the first; that forces a change on first login and, until it happens, returns 403 for HTTP clone/push and API basic-auth. pgcli sends =false explicitly so a member can clone immediately — set it true only to hand out a one-time password |
These logins are Gitea’s own accounts, not pgcli-managed, so a generated
password is printed once and is not written to pg.yaml — record it at
creation. (Only the install admin’s login is stored; that one you retrieve with
pg addon password gitea.) To change an existing account’s password, run
gitea admin user change-password inside the container.
delete refuses to remove the install admin (it is pgcli’s stored login). A
plain delete only succeeds for an account that owns nothing; if it still owns
repositories Gitea refuses with user still has ownership of repositories —
pass --purge to remove the account and everything it owns (repos, orgs,
comments).
Ports
Each Gitea instance takes one port for the web UI, allocated from a pool with base gitea_start_port (default 3080 — Gitea’s conventional 3000 is the Grafana pool’s base in pgcli, so Gitea starts one pool over; still recognizably “the Gitea port”). Every instance also takes a port from a second pool (gitea_ssh_start_port, default 2222 — the conventional Gitea SSH mapping) to park the image’s always-running sshd off 22; whether SSH is on or off only decides whether that port is a live clone endpoint or an idle listener, so the reservation is held either way. Both pools skip ports already taken by any managed container or the used_ports list, and the allocations are written into pg.yaml:
The two pools’ bases (3080 / 2222) are disjoint from every other addon’s pool.
Configuration
Config is env-only (GITEA__<section>__<KEY> plus two bare
SSH_LISTEN_PORT / SSH_PORT vars — those are what the image’s always-running
sshd actually binds; the GITEA__server__* copy only drives Gitea’s own
SSH-aware clone-URL rendering), folded by the image into app.ini on every
boot; the app.ini inside /data is rewritten from these variables, so
hand-editing it is not a supported configuration path (settings pgcli does not
set survive the rewrite, but changing one of pgcli’s own keys by hand just gets
undone on the next boot). The variables pgcli sets are: the wizard skip
(INSTALL_LOCK), the listener (HTTP_PORT/HTTP_ADDR), the display URL
(ROOT_URL), the registration gate (DISABLE_REGISTRATION, rendered in both
states — true by default, false with --allow-registration, since Gitea’s
own default is open and an omitted line could not override a persisted one),
the always-emitted
bare SSH_LISTEN_PORT/SSH_PORT pair that parks the s6 sshd off 22,
DISABLE_SSH=true when SSH is off (Gitea then also does not write
authorized_keys or show ssh:// URLs) or — when SSH is on — DISABLE_SSH=false
plus the URL-rendering trio SSH_LISTEN_PORT/SSH_PORT/SSH_DOMAIN under
GITEA__server__ and START_SSH_LISTENER=false so Gitea’s own Go listener
does not double-bind the same port, the optional database block, and the process
uid pin (USER_UID/USER_GID).
List
Passwords are hidden by default to keep the listing paste-safe; pg addon list --show-password reveals the plaintext value, and pg addon password gitea prints a single one bare for scripting.
Start and stop
Containers run with podman’s unless-stopped restart policy, so they come back automatically with the podman service after a host reboot. start/stop manage them explicitly:
Auto-start on Boot
To have a Gitea instance started automatically on host boot, enable it:
This flags the instance in pg.yaml and installs/refreshes a boot service (a systemd --user unit on Linux, a LaunchAgent on macOS) that runs pg start --autostart. A boot-time start only brings the container up — the data dir was already chowned to uid 1000 at create time, so it needs no host privileges beyond podman itself. With --dsn, the external PostgreSQL must be up too: a managed instance with its own autostart starts earlier in the same pass; any other endpoint just needs to be reachable. See Autostart for the full mechanism, status and disabling.
Remove
Under rootless podman, --clean-data deletes the data dir through podman unshare rm — Gitea writes as the container’s git uid (1000), which maps to a subordinate uid the host user cannot rm directly. With --dsn the external database is never touched either way — drop it yourself if you really want the data gone.
Logs
Troubleshooting
installfails with a chown diagnostic — the image runs as uid 1000 and never fixes ownership of an existing mount, so pgcli chowns the host data dir before creating the container: throughpodman unshare chownunder rootless podman, or a direct recursivechownwhen pgcli itself runs as root on a rootful host. The manager shells out to the plainpodmanCLI, so this only fails outside those two cases — e.g. a non-rootpgexplicitly pointed at someone else’s rootful daemon. Run the install as root, or switch to rootless podman; a manualsudo chown -R 1000:1000 <data-dir>alone does not clear it, since pgcli re-checks ownership on every install attempt.- The login password doesn’t work after a reinstall — the admin account is created, never reset. On an existing store (kept
/dataor--dsndatabase) usegitea admin user change-passwordinside the container, or wipe and start over (--clean-data, and drop the external DB by hand in dsn mode). - Clone URLs show
localhosteven though Gitea is on a server — a wildcard--listen 0.0.0.0collapses tolocalhostin displayed URLs because0.0.0.0is not connectable. Pass--listen <host-or-ip>(with--force) to make clone URLs real. ssh://clone says connection refused / wrong port — live cloning is off unless the instance was installed with--ssh-port(the s6 sshd is running but parked, so it is not a clone endpoint). And pgcli keepsSSH_PORT(what URLs show), the Gitea-sideSSH_LISTEN_PORT, and the bareSSH_LISTEN_PORT(what the s6 sshd actually binds) all equal — a hand-mismatched config breaks exactly this way.- Log line
Bind to port 22 … Address in use— the sshd’s park port has somehow reverted to 22 (an app.ini edit survived a boot, or bare env was stripped). A fresh install never hits this: pgcli always emits bareSSH_LISTEN_PORT=<pool port>for every instance, SSH on or off. git pushover HTTP asks for a password and rejects the web one — Gitea asks for the account’s credentials; if the account has 2FA on, HTTP push needs a personal access token or an SSH key instead of the password.- Port already allocated — the assigned ports are held in
pg.yamland skipped for later instances; free them withpg addon removefirst, or pick others with--port/--ssh-port.
Known limitations
- The image must be pinned to a version tag and upgraded by changing
--image(Gitea migrates its database forward on first boot of the newer image — there is no downgrade path). Rootful and rootless builds have mutually incompatible data layouts: pgcli pins the rootful build and an-rootlesstag will not read the existing/data(or the data written by another rootless deployment). - Plaintext HTTP: TLS termination and real hostnames are the nginx addon’s job — front Gitea with it in custom
--conf-filemode (the template’s 1 MiBclient_max_body_sizedefault breaks largergit push). Gitea rewrites the on-page clone URL fromX-Forwarded-Proto+Host, so behind nginx’s default proxy headers the copy button readshttps://<proxy>/…correctly. pgcli rendersROOT_URLashttp://<host>:<web-port>/and v1 exposes no knob to change that — the only visible effect so far is anything Gitea generates fromROOT_URLdirectly (rather than from the request) keeps the internal http origin. - v1 sets no OAuth, SMTP, webhook-tuning, or repository-storage (MinFS) options — everything beyond the table above is Gitea-side configuration pgcli deliberately leaves alone. Self-registration is closed; create users with
pg addon gitea user create. - On macOS the container runs inside the podman machine; it has been validated on Linux only.
12.19 - VictoriaMetrics
pg addon install victoriametrics runs VictoriaMetrics — a fast, Prometheus-compatible time-series database — as a managed container from the official docker.io/victoriametrics/victoria-metrics image. Like Prometheus and Grafana it is a top-level addon: it fronts no single PostgreSQL instance (it ingests metrics from many sources), so it is not attached to any one of them. The three are natural partners — Prometheus scrapes, VictoriaMetrics stores long-term with superior compression, Grafana visualises — and share the same lifecycle shape (single container, host networking on Linux, one web port from its own pool, persistent data dir, autostart, logs).
VictoriaMetrics diverges from Prometheus in how it is configured. Prometheus reads a config file (prometheus.yml) plus CLI flags; VictoriaMetrics needs no config file at all — everything is passed as CLI flags after the image name (-storageDataPath / -httpListenAddr / -retentionPeriod / custom flags via --extra-arg). So pgcli passes these flags directly when creating the container; there is no config to render, no SIGHUP reload, no chown needed (the image runs as root).
There is NO web login/password. Unlike Grafana (whose
0.0.0.0default is guarded by a generated strong password), VictoriaMetrics ships no authentication at all. Its HTTP API runs arbitrary MetricsQL (a superset of PromQL) and can be loaded by expensive queries, so a remote-exposed instance is guarded only by the network boundary — your firewall, or--listen 127.0.0.1for loopback. Treat exposing it like exposing an unauthenticated read/write console, not like a password-protected dashboard.
Platform: Linux is the tested platform. The design supports macOS (the image is multi-arch; the container runs under the podman machine VM on the
pgcli-netbridge with the port published), but it has not been validated there yet.
Parameters
| Flag | Default | Description |
|---|---|---|
--name |
vm |
Instance name (map key) |
--image |
docker.io/victoriametrics/victoria-metrics:v1.154.1 |
Image override |
--port |
auto (base 8428) | Host web port; auto-allocated from victoriametrics_start_port |
--listen |
0.0.0.0 |
Bind address — defaults to every interface; use 127.0.0.1 for loopback-only (see the exposure warning above) |
--retention-months |
1 |
Data retention in months (-retentionPeriod); e.g. 3 = 3 months, 12 = 1 year |
--extra-arg |
— | Extra CLI flag passed to the binary (repeatable); appended last so last-wins overrides earlier flags. E.g. --extra-arg -search.maxUniqueTimeseries=1000000 |
--data-dir |
auto | Data directory override (default under the base dir) |
--force |
false | Recreate even when running — to apply changed ports / listen / retention / extra-args (install also recreates on its own when config changes) |
The data directory holds VictoriaMetrics’s TSDB. It survives pg addon remove (delete it with --clean-data), so reinstalling under the same name revives stored time series. The image runs as root (unlike Prometheus’s uid 65534), so no pre-chown of the data dir is needed.
Install
The command pulls the image if needed, creates the container, and prints the URL, retention, and any extra args:
VictoriaMetrics is ready to ingest and query immediately. Point Grafana at http://<host>:8428/ as a Prometheus datasource (VictoriaMetrics is Prometheus-compatible) and you can query using PromQL or MetricsQL.
Re-running install on a running instance recreates it when the config actually changed (ports, listen, retention, extra-args), and leaves it alone when it didn’t; --force recreates the container even when config is unchanged.
VictoriaMetrics does not support hot config reload (SIGHUP), so all changes require container recreation. This is simpler than Prometheus (no config file to render, no reload signal), but means brief interruption when changing settings.
Custom flags (--extra-arg)
VictoriaMetrics accepts many CLI flags for tuning cache sizes, query limits, ingestion rates, and more. Pass them via --extra-arg (repeatable):
Extra args are appended last to the container’s command line, so they override earlier flags via last-wins semantics. This lets you tune VictoriaMetrics beyond pgcli’s first-class knobs without editing any config file.
Verify the flags took effect:
Listening and exposure
VictoriaMetrics’s Listen defaults to 0.0.0.0: the container shares the host’s network namespace, so the UI and API are reachable on every interface at the assigned port. This matches Prometheus’s default for the same practical reason (Grafana on another host needs to reach it) — but there is no password behind it. Anyone who can reach the port can run arbitrary MetricsQL and read your stored time series. For a loopback-only deployment pass --listen 127.0.0.1 (recreate with --force); pg addon list then shows http://127.0.0.1:<port>/. Otherwise, restrict access at the firewall.
Ports
Each VictoriaMetrics instance owns a single port: the web UI/API. It is allocated from a pool with base victoriametrics_start_port (default 8428 — VictoriaMetrics’s conventional HTTP port), incrementing for every additional instance and skipping ports already taken by any managed container or the used_ports list. The allocation is written into pg.yaml, so a reinstall never re-hands-out a port another addon is using. 8428 shares no number with Prometheus (9090), Grafana (3000), pgAdmin (5050), or nginx (8080); an explicit --port still wins.
Configuration
There is no config file — everything is passed as CLI flags to the victoria-metrics binary (-storageDataPath, -httpListenAddr, -retentionPeriod, plus any extra_args). retention is stored as a string (months), and extra_args is a list of CLI flags appended last.
List
There are no credentials to reveal — VictoriaMetrics has none — so --show-password shows nothing extra for it.
Start and stop
Containers run with podman’s unless-stopped restart policy, so they come back automatically with the podman service after a host reboot. start/stop manage them explicitly:
Auto-start on Boot
To have a VictoriaMetrics instance started automatically on host boot, enable it:
This flags the instance in pg.yaml and installs/refreshes a boot service (a systemd --user unit on Linux, a LaunchAgent on macOS) that runs pg start --autostart. A boot-time start only brings the container up — the data dir was already created at install time, so it needs no host privileges beyond podman itself. VictoriaMetrics starts after Prometheus in the boot order. See Autostart for the full mechanism, status and disabling.
Remove
Under rootless podman, --clean-data deletes the data dir through podman unshare rm — VictoriaMetrics writes as root (container uid 0), which maps to a subordinate uid the host user cannot rm directly.
Logs
Troubleshooting
- A query returns no data — confirm VictoriaMetrics is running (
pg addon listshowsrunning), the health endpoint responds (curl -s http://<host>:<port>/-/healthyprintsVictoriaMetrics is Healthy.), and you’re querying the correct time range (usequery_rangewith explicitstart/end/step). Checkpg logs addon victoriametricsfor ingestion errors. - Install fails with “container already exists” — the container name is derived from
--nameand the namespace; use--forceto recreate, orpg addon removefirst to clean up a partial install. - Extra args didn’t take effect —
--extra-argis applied at container creation time, not runtime. Reinstall with--forceto recreate the container with the new flags. Checkpodman logs <container>to confirm the flags appear in the startup log. - Port already allocated — the assigned port is held in
pg.yamland skipped for later instances; free it withpg addon removefirst, or pick another with--port N. - Remote API unreachable / unexpectedly reachable — on the default
0.0.0.0a failure to reach it is host firewall, not bind address; conversely, if you did not want it reachable,--listen 127.0.0.1 --force.
Known limitations
- v1 is a bare storage backend: it ships no scrape capability (point a pgcli Prometheus instance at it with
pg addon install prometheus --remote-write-url http://<host>:8428/api/v1/write, or usevmagentseparately) and no alerting. It is the long-term storage layer you point Prometheus and Grafana at. - No authentication. VictoriaMetrics’s API runs arbitrary MetricsQL; the only protection for a
0.0.0.0bind is the network boundary. This is the same posture as Prometheus — do not expose it on an untrusted network. - pgcli does not validate
--extra-argvalues (there is no pre-flight check at install). A bad flag surfaces only in the container logs on startup. - On macOS the container runs inside the podman machine; it has been validated on Linux only.
12.20 - BeeGFS
BeeGFS is a parallel filesystem that chose the easy route. It was born in 2004 at Fraunhofer ITWM in Germany — a research group that needed Lustre, found its deployment and operations too heavy, and wrote its own instead, with the goal set from day one: not the biggest scale, but the least human effort to deploy and run. In 2014 the core team spun out as the commercial company ThinkParQ, the product was renamed BeeGFS, and it grew from an institute-internal tool into the shared storage you find on many European universities and regional supercomputers.
It solves the “many machines, one high-throughput dataset” problem: a local filesystem lives on a single host, and NFS-style shares cap out quickly under concurrent large-file IO from many clients. Like Lustre, BeeGFS is a parallel filesystem — metadata separated from data, files striped across storage servers, clients reading data blocks directly once they know where they are — so aggregate bandwidth scales with the number of servers. But at every point where it could be complicated, it chose simple: service roles may share one machine, daemons self-register into the cluster when they start (no hand-synced configuration), growing means adding a box, and plain Ethernet works — InfiniBand is not mandatory. The trade: it does not chase extreme scale or the deepest tuning control; what it buys is that a research group or department cluster without a dedicated storage team can be up within a day or two.
Typical fit: HPC clusters from a few up to a couple hundred nodes, the data supply layer for AI training (GPUDirect Storage since 7.3 — storage-to-GPU direct paths, so large training/inference jobs read shared datasets directly), post-production editing media in film and video (many editors reading and writing large concurrent streams without fighting for bandwidth), and department-scale shared volumes of many large files. One-line selection rule: national-lab scale, thousands of nodes, a storage team on staff → Lustre; “usable today, no storage team” → BeeGFS.
Platform: Linux only. BeeGFS daemons run on host networking (BeeGFS speaks its own TCP/UDP protocol, not HTTP, so there is no container port-mapping to hide behind). macOS has no host networking under podman, so
pg addon install beegfsis refused there with a clear error.
The client is out of scope, on purpose. Mounting a BeeGFS filesystem needs the
beegfs-clientkernel module (DKMS). Kernel modules cannot be containerised, so pgcli neither installs nor mounts it — it manages the server services only (three containers on a converged host, the configured subset on a cross-host member).pg addon beegfs mount-hintprints the exact host-side config a machine with thebeegfs-client+beegfs-utilspackages needs. See Mounting a client.
Parameters
| Flag | Default | Description |
|---|---|---|
--name |
beegfs |
Filesystem name (map key); containers are <prefix>-<role> for each role this host runs |
--image |
8.4.0 |
Version suffix — the manager builds ghcr.io/thinkparq/beegfs-<role>:<version> from it (all roles share one tag) |
--data-dir |
<base>/addon/beegfs/<name> |
One dir for the whole filesystem; its services use <dir>/{mgmtd,meta,storage} |
--listen |
0.0.0.0 |
The address clients self-reference as sysMgmtdHost; with host networking the daemons actually bind every interface |
--password |
generated (mgmtd hosts) | The connection-auth secret (ConnAuth) shared by all daemons and every client; generated on first install only if this host runs mgmtd — a join host must pass the cluster’s existing value |
--roles |
all three | v2: which services THIS host runs — comma list of mgmtd,meta,storage; omitting it is the converged single-host filesystem |
--mgmtd-host |
— | v2: address of the mgmtd host this host’s meta/storage register against (required when --roles excludes mgmtd) |
--node-id |
1 |
v2: this host’s BeeGFS node ID (1..655); storage target IDs derive from it (2 → 201, 202, …). Must be unique per service type across hosts |
--storage-target |
single dir | v2: one host dir per storage target — repeat for multiple disks (one target each) |
--license-file |
— | Path to a ThinkParQ license .pem to carry into the mgmtd container (stored in pg.yaml, injected as BEEGFS_LICENSE_FILE_DATA). mgmtd host only — pgcli never generates a license; pg addon beegfs license shows/hot-swaps it later |
--beegfs-mgmtd-port |
8008 |
Move mgmtd’s BeeMsg port (the only movable meta/storage registration target); 0 = stock |
--beegfs-mgmtd-grpc-port |
8010 |
Move mgmtd’s gRPC port (what the beegfs CLI dials); 0 = stock |
--force |
— | Recreate this host’s containers even if they are already running |
There is deliberately no --port for meta or storage: see Ports.
Install
The install is fully non-interactive. It pulls the images for the roles this
host runs up front (so a mid-cluster network failure never leaves a
half-registered filesystem), then brings them up in dependency order:
mgmtd first, then meta, then storage — a join host with --roles storage just
runs its own step. meta/storage block until they log
Registration and management info download complete, the line that proves both
“mgmtd is reachable” and “this service joined”. Each container’s first boot
runs a beegfs-setup-<service> step (driven by beegfs_setup_N env vars the
image’s entrypoint consumes) that initialises its target directory; the marker
is idempotent, so a recreated container against a surviving data dir reuses the
existing target.
Re-running install is safe and cluster-granular (over this host’s roles):
- all of them up → skip (use
--forceto recreate, orpg addon beegfs check); - all of them down and present → just start the stored cluster (no pull, no recreate);
- anything else (partial, or a fresh install) →
EnsureClusterrecreates the set in order.
The install summary lists this host’s roles and their container names, the
images, the mgmtd address and its two ports (marked remote on a join host), the
node ID and data dirs, the conn-auth state, and a pointer to
pg addon beegfs mount-hint. If --listen is the wildcard 0.0.0.0 it prints
an exposure NOTE (a parallel filesystem with TLS off is reachable from any
machine that holds the conn-auth secret).
Cross-host clusters (v2)
A BeeGFS filesystem scales by adding hosts, not by hand-syncing config:
each service daemon starts with sysMgmtdHost=<mgmtd address> plus the shared
conn-auth secret and self-registers into the cluster — mgmtd is the
registry. pgcli mirrors that model exactly: there is no central deploy
command and pgcli never SSHes anywhere. Every host runs its own
pg addon install beegfs --roles <subset> with its own local pg.yaml, and
BeeGFS does the rest — the same declarative, per-host pattern Patroni cross-host
members use (where etcd, not pgcli, aggregates topology; here the role is
mgmtd’s own SQLite registry).
Three hosts, one filesystem:
Clients mount from vm01’s address as usual — cross-host changes nothing about the kernel-module client.
Rules and boundaries, honestly stated:
--mgmtd-hostis required whenever--rolesexcludes mgmtd (there is no localhost to fall back to), and the join must carry the cluster’s existing conn-auth — pgcli never generates a secret for a member of an existing cluster. A wrong secret fails the install in seconds with a pointer topg addon password beegfs, not after a registration timeout.--node-idmust be unique per service type across hosts (node IDs are per-type namespaces:meta_1andstorage_1coexist). It seeds the storage target IDs: node 2’s targets are 201, 202, … (target IDs derive asnode_id*100 + ordinal, capped at node_id 655).- Each
pg.yamlentry stores only what its own host runs;check,start/stop,listand autostart are all per-host views. The cluster’s full membership lives in mgmtd (beegfs ... node list). - mgmtd remains a single point: if it dies, existing client IO keeps
flowing (clients talk to meta/storage directly) but registration and admin
stop. A redundant management pair is an enterprise-license feature, not
pgcli wiring. The license rides on the mgmtd host alone — join hosts
neither store nor need a PEM (
join-hintsays so), since mgmtd verifies the certificate for the whole filesystem. - There is still one filesystem per host — cross-host is the scaling axis, not second-filesystems-per-host (the fixed 8005/8003 ports make those collide).
The data directories and persistence
A converged host owns three host directories, each bind-mounted into its own
container at /data/<role>:
--data-dir <dir> lays all three out as <dir>/{mgmtd,meta,storage} in one
flag — operators never address the roles separately. On a cross-host member
only its own role’s dir is used. Multiple storage targets (the
--storage-target repeat) each get their own host dir mounted at
/data/storage/<targetID> inside the single storage container; a single target
keeps the plain /data/storage path so a v1 store still revives unchanged
(a target directory records its own path internally, so revival must mount the
same path). The three images run as
root and own their data dirs, so unlike Gitea (uid 1000) or Grafana
(uid 472) there is no pre-chown dance — the host dirs are writable as-is.
All data survives container removal: pg addon remove beegfs without
--clean-data keeps the dirs, and reinstalling the same name revives
the filesystem (the install prints a NOTE when it detects an existing
mgmtd.sqlite). Revival keeps whatever registry and connection auth the store
already had — a new --password only reaches freshly-initialised daemons, not
a surviving SQLite registry.
Failure handling: striping for performance, ZFS for reliability
BeeGFS’s cross-node striping is a performance mechanism, not a redundancy one: in the default RAID0 layout, losing ANY one storage target loses the chunks it held — and on a striped file that means the file is corrupt. Node-level data redundancy is buddy mirroring, an enterprise-license feature. So the community edition splits the two concerns, and the storage side needs a local-failure scheme underneath it:
- Across nodes: striping for throughput. Each file’s chunks spread over
many targets on many hosts; aggregate bandwidth scales with the number of
servers. This is what
--num-targets/--chunk-sizetune. It buys speed, never safety. - Within each storage server: a resilient volume under every target. The
recommended baseline is ZFS, because a BeeGFS storage target is “just a
directory” and ZFS turns that directory into a self-healing store for free —
checksums against silent corruption, copy-on-write, snapshots, compression.
The daemon cannot tell the difference, so this layer needs zero pgcli or
BeeGFS involvement: build the pool, make a dataset per target, and point
--storage-targetat it.(mdadm RAID1/5/6 works too, but ZFS’s checksumming catches the silent bit rot a BeeGFS target would otherwise serve straight to clients.) One target per dataset keeps a disk fault entirely out of the BeeGFS target path. - Whole-node loss is still not survivable (community edition): a dead
server takes its storage targets down with it — local ZFS/RAID survives a
disk, not a burnt-out machine. Mitigations without a license: keep
meta/mgmtdservices duplicated across hosts (free — service redundancy, so the filesystem stays mounted and admin keeps working), snapshot the metadata, and treat storage data as rebuild-from-upstream where the workload allows (re-generable training datasets — the honest community trade-off). A temporary full-feature certificate (free, time-limited, one per system) or a paid enterprise license is the only way to get real cross-node mirroring. - Metadata deserves the same care: losing the meta target makes the whole tree unreadable regardless of data redundancy — keep its dir on a local mirror too (it is small). pgcli takes no BeeGFS backups, so protecting the mgmtd registry and meta target is host-level work: a ZFS snapshot of the volume holding the role dirs, or a quiesced file copy while the daemons are stopped.
Ports
The four BeeGFS listener ports are the stock protocol ports, not a pool:
Verified on a live host: BeeGFS 8.4’s meta and storage daemons accept no
listen-port override (connPortsList/connTcpPort are rejected as invalid,
and connMetaPort is accepted but breaks registration). Only mgmtd’s two ports
can move, and moving mgmtd’s BeeMsg port re-targets meta/storage registration
automatically (the manager emits connMgmtdPort=<port> into both). The direct
consequence: exactly one BeeGFS filesystem can exist per host — a second
one could never bind 8005/8003 regardless of what pgcli allocated, so
EnsureCluster guards it out with an explicit error, and the CLI rejects a
hand-pinned non-default meta_port/storage_port in pg.yaml with the real
reason. There is therefore no beegfs_start_port pool — unlike every other
addon, BeeGFS ports are fixed, not allocated.
Connection auth and TLS
Every daemon in the filesystem and every client share one
connection-authentication secret (ConnAuth, delivered as the
CONN_AUTH_FILE_DATA env var that the image’s init writes to
/etc/beegfs/conn.auth). It is generated on first install (20 random chars)
only on a host that runs mgmtd, printed once, and stored in pg.yaml —
retrieve it with pg addon password beegfs. A cross-host join host must be
given that existing value via --password (see
Cross-host clusters); pgcli never generates a secret
for a member of an existing cluster. Like every pgcli secret it is never
defaulted into the config by ApplyDefaults.
TLS is disabled on mgmtd (--tls-disable true, the explicit v1 decision).
This means only clients that pass --tls-disable too can join, and it is a
real exposure consideration: a wildcard --listen 0.0.0.0 with TLS off leaves
ports 8008/8010/8005/8003 open to any machine on the network that holds the
conn-auth secret. For anything not on a private network, either bind loopback
(--listen 127.0.0.1) or firewall those four ports.
Licensing: as of BeeGFS 8 a license is expected on every deployment.
pgcli never generates a license (same principle as Gitea’s crypto secrets)
— but it carries one you obtained: the PEM content is stored in pg.yaml
(so it survives and travels with your config backups) and injected into the
mgmtd container as BEEGFS_LICENSE_FILE_DATA, which the official image’s init
writes to /etc/beegfs/license.pem and mgmtd’s start.sh passes as
--license-cert-file. Only the mgmtd host may carry it: its image is the only
one shipping the license library, and its daemon verifies the certificate for
the whole filesystem (the install refuses --license-file on a join host).
What a license costs, by tier (all three self-serve via ThinkParQ’s portal):
| Tier | Price | What it does |
|---|---|---|
| community | free | Lifts the unlicensed limits (8.4’s five-client mount cap, quota warnings); has a capacity ceiling and does NOT unlock enterprise features |
| temporary | free trial | Unlocks everything for a limited period; since 8.4 one system can hold only one |
| enterprise | paid | Storage pools, quotas, mirroring (buddy groups), the HA management pair |
Mirroring (buddy groups) is an ENTERPRISE feature — it needs a paid license. Without one, mgmtd refuses every mirroring request (
beegfs mirror create→ “no license loaded” — verified on a live cluster). The free community license does NOT unlock it; only a paid enterprise license (or a temporary full-feature trial certificate) does. Data redundancy in the community edition is limited to running multiple meta/mgmtd daemons — that is service redundancy, not mirrored storage targets.
Obtaining a PEM is out-of-band and takes a minute for a community license:
beegfs license --get prints a system-bound URL → open it in a browser →
download the .pem. Then hand it to pgcli — at install time
(--license-file license.pem) or later with pg addon beegfs license --set
(see License status and hot-swap).
The 8.4 realities of running unlicensed: mgmtd logs a quota warning,
every beegfs command nags, client mounts are capped at five (a sixth
mount is rejected), and enterprise features are refused outright (e.g.
beegfs mirror create fails with “no license loaded”). Any valid license —
even the free community one — lifts the warnings and the mount cap.
Mounting a client
The client is a host kernel module — pgcli does not install it. To mount a filesystem from a Debian/Ubuntu host, add ThinkParQ’s apt repository and install the client packages yourself. For 8.4 on Debian 12 (bookworm) — the exact commands, verified on a test host:
On RHEL-family hosts the same repo lives under
https://www.beegfs.io/release/beegfs_8.4/dists/ as an .repo file
(rpm --import …/GPG-KEY-beegfs + wget …/beegfs-rhel9.repo -O /etc/yum.repos.d/beegfs.repo). Always take the URLs for your BeeGFS version
and distribution from the ThinkParQ download
page — pinning the server to 8.4 means clients
should mount from the matching 8.4 repo.
beegfs-client depends on build-essential and recommends kernel headers —
DKMS compiles the beegfs module against linux-headers-$(uname -r) at install
time, so it needs the running kernel’s headers present and will rebuild on every
kernel update.
Then write the config pg addon beegfs mount-hint prints — it renders the three
files verbatim:
conn.auth gotcha: the client’s connection-auth file must end in a newline.
echo <secret> > /etc/beegfs/conn.authworks;printf <secret> > …(no trailing newline) is rejected withtried to authenticate stream with wrong secret— the trailing byte is part of what both sides compare.
Then mount and verify:
Because the cluster runs with TLS disabled, every beegfs CLI call needs the
disabled-TLS form pointed at the gRPC port; node list shows all four node
types (management, meta, storage, and this client once mounted):
Common client-side operations (all verified against 8.4 — the beegfs CLI is
the 8.x rewrite; 7.x beegfs-*-ctl incantations do not apply):
mount-hint is pure local rendering — it never touches podman, since the client
is out of pgcli’s management. It renders the mgmtd address from the config
entry: on a cross-host member that is the stored --mgmtd-host; when the local
--listen is the wildcard, it prints <mgmtd-host-ip> as a placeholder
(0.0.0.0 is not dial-able) rather than a value clients cannot connect to.
Health check
Reports the aggregate state of this host’s roles (running / stopped /
partial (mgmtd,meta)) and, per service, whether its container is up and
whether it logged its readiness marker — mgmtd Waiting for shutdown signal
(serving), meta/storage Registration and management info download complete
(joined). None of the images ships beegfs-ctl/check-servers, so the
daemons’ own logs are the authoritative in-container signal; the cluster-wide
membership lives in mgmtd — for the whole picture use the beegfs gRPC CLI
above (node list), and for a stopped cluster check still works (it reports
state, it does not require running).
Retrieving the connection-auth secret
pg addon list shows only a presence marker (Auth: none / set (connection-auth)) and prints the secret itself only with --show-password, so
it never leaks to a casual listing.
License status and hot-swap
Run on the mgmtd host (the license lives there — see
Connection auth and TLS). Without flags it reports
both sides: whether pg.yaml carries a PEM, and what mgmtd itself logged when
loading it — Successfully loaded license certificate for a valid one, or the
verification errors when mgmtd rejects a bad/expired certificate. pgcli only
shape-checks the PEM (BEGIN/END block); the real verification is always
mgmtd’s.
--set <pem> reads the file (shape-checked for a BEGIN/END PEM block — the
cryptographic verification is mgmtd’s, not pgcli’s), stores the content in
pg.yaml, and recreates the mgmtd container so the new env lands (podman
env only exists from creation). meta/storage and every client re-register on
their own — the swap is a few seconds and IO-safe. --clear removes the PEM
and reloads unlicensed. --set and --clear are mutually exclusive; passing
--license-file at install is the same load without the recreate (the container
is being created anyway).
List
The BeeGFS block (“Infra add-ons (beegfs)”) prints one entry per filesystem
with its aggregate state over this host’s roles, the role list, the mgmtd
address and its two ports (marked remote on a join host), the fixed
meta/storage ports, conn-auth presence, image, the data dirs of the roles this
host runs (one line per storage target), and the container names. When podman
is unavailable it still lists the configured entries (state shown as n/a).
Start and stop
Containers run with podman’s unless-stopped restart policy, so they come back
with the podman service after a host reboot. start/stop manage this host’s
roles explicitly, respecting dependency order:
A cross-host member runs only its own subset (a --roles storage host starts
and stops just storage).
Auto-start on Boot
BeeGFS is the only cluster-granular autostart: pg start --autostart brings
up the filesystem as this host runs it (mgmtd → meta → storage in order, or the
configured subset), not one container.
Because the images run as root and own their dirs, an autostart needs no host
privileges beyond podman itself (no chown step like Gitea’s). The client on
each mounting host re-attaches on its own via beegfs-mounts.conf — pgcli
never manages that. See Autostart for the full
mechanism, status, and disabling.
Remove
Remove tears down this host’s roles in reverse order (storage → meta → mgmtd). --clean-data
refuses to delete a directory that is still a mount point. A client mountpoint
pointing at this host must be unmounted either way — pgcli cannot reach the
client (it is not pgcli’s), so unmount it on the client host first.
Logs
A filesystem spans up to three containers, so pg logs addon beegfs takes
--service to pick one (default mgmtd):
--service is only valid for beegfs (a value like meta against another
addon type is rejected).
Troubleshooting
install beegfs --name fs2refuses with “a BeeGFS filesystem already exists on this host” — this is the one-filesystem-per-host rule, not a bug: meta (8005) and storage (8003) cannot move their listen ports in 8.4, so a second filesystem on the same host could never bind them. Grow by adding roles on ANOTHER host (pg addon beegfs join-hintrenders the commands), orpg addon removethe first one.- meta/storage stuck “not registered” in
check— mgmtd must be serving first; if mgmtd’s container died, both block on registration untilbeegfsSetupTimeout(60 s) fails the install. Checkpg logs addon beegfs --service mgmtdfor why mgmtd exited (port already in use on the host, or an un-writable data dir). installrefuses on themeta_port/storage_portconfig check — a non-default value was hand-edited intopg.yaml. The daemons cannot bind those, so remove the pinned values and let them fall back to 8005/8003.- Client cannot mount /
beegfsCLI says TLS error — the cluster runs with mgmtd TLS disabled; every client and everybeegfsCLI call needs--tls-disabletoo, and/etc/beegfs/conn.authmust be the exactConnAuthfrompg addon password beegfs. - A join host fails fast with “wrong secret” — the
--passwordpassed to its install is not the cluster’s connection-auth value (BeeGFS logstried to authenticate stream with wrong secret). The install aborts in seconds rather than timing out; re-run with the value frompg addon password beegfson the mgmtd host. pg addon beegfs licenseprints a verification error from mgmtd — the stored PEM is not valid for this system (certificates are system-bound — request one withbeegfs license --geton the mgmtd host) or has expired. The container still runs and community functionality is unaffected; mgmtd simply behaves as unlicensed.--clear(or--setwith the right PEM) fixes the config side.- Clone/mount to
localhostfails from another machine — a wildcard--listen 0.0.0.0renderssysMgmtdHostas a placeholder inmount-hint; install with--listen <reachable-ip>(with--force) so clients get a real mgmtd address.
Known limitations
- Linux only. Host networking is mandatory and podman provides it on Linux only; installation is refused on macOS. No bridge fallback exists because BeeGFS is not HTTP.
- One filesystem per host, fixed ports. pgcli pins the stock ports
(8008/8010/8005/8003); meta and storage have no movable listen port in 8.4,
so a single host holds a single filesystem. Scaling out is cross-host
(
--roles), not a second filesystem. - The client is never installed or mounted by pgcli. It is a kernel module
(DKMS) that cannot be containerised; pgcli manages only the server
containers and documents the mount via
mount-hint. - TLS off; licenses are carried, not issued. mgmtd runs
--tls-disable true; thebeegfsgRPC CLI and clients must match. pgcli never generates a license and never handles renewal — it carries a ThinkParQ PEM you obtained out of band (beegfs license --getprints the system-bound URL) to the mgmtd container, andpg addon beegfs licenseshows/hot-swaps it. Mirroring, pools, and quota stay enterprise-license features you still have to buy; an unlicensed 8.4 caps client mounts at five. - Cross-host deployment is per-host and manual by design (v2). Each host
runs its own
pg addon install beegfs --roles ...; pgcli never SSHes and has no “deploy the whole cluster from one box” command. Growing means running one command on the new host (the conn-auth secret is carried by hand — seejoin-hint), and every host’spg.yamldescribes only its own slice. - mgmtd is a single point. The management daemon is the cluster’s registry; if it stops, new registrations and admin operations stop (existing mounted clients keep doing IO against meta/storage). A redundant management pair is a BeeGFS enterprise-license feature and is outside pgcli’s v1/v2 wiring.
12.21 - VMCluster
pg addon install vmcluster runs VictoriaMetrics cluster edition — three components each in its own container from the official docker.io/victoriametrics/{vminsert,vmselect,vmstorage}:v1.154.1-cluster images. Unlike the single-node victoriametrics addon (one container, one port), this manages up to three containers with three separate images and five ports, supporting cross-host deployment and horizontal scaling.
Architecture:
| Component | Role | HTTP Port | Internal TCP |
|---|---|---|---|
| vminsert | Ingestion — shards writes across all vmstorage nodes by metric hash | 8480 | Dials vmstorage:InternalInsertPort |
| vmselect | Queries — merges results from all vmstorage nodes | 8481 | Dials vmstorage:InternalSelectPort |
| vmstorage | Data storage (sharded, replicable) | 8482 | Listens on InternalInsertPort + InternalSelectPort |
There is NO web login/password. Like the single-node VictoriaMetrics, the cluster edition ships no authentication at all. Its HTTP API runs arbitrary MetricsQL (a superset of PromQL), so remote-exposed instances are guarded only by the network boundary — your firewall, or
--listen 127.0.0.1for loopback. Treat it like an unauthenticated read/write console.
Platform: Linux only. VMCluster requires host networking for the internal TCP ports between components. On macOS, installation is refused with a clear error.
Deployment Models
Model 1 — Converged single-host (default)
All three components on one host, analogous to BeeGFS v1:
Model 2 — Cross-host distribution
Model 3 — Horizontal scaling with replication
Parameters
| Flag | Default | Description |
|---|---|---|
--name |
vmc |
Instance name (map key) |
--roles |
insert,select,storage |
Comma-separated roles this host runs (subset for cross-host) |
--storage-node |
auto-derived | vmstorage address (repeatable; format host:insertPort:selectPort). Lists ALL vmstorage nodes for sharding/replication. Omit for converged single-host |
--insert-port |
auto (base 8480) | vminsert HTTP port |
--select-port |
auto (base 8481) | vmselect HTTP port |
--storage-port |
auto (base 8482) | vmstorage HTTP port |
--internal-insert-port |
auto (base 8483) | vmstorage TCP port for vminsert protocol |
--internal-select-port |
auto (base 8484) | vmstorage TCP port for vmselect protocol |
--listen |
0.0.0.0 |
Bind address — defaults to every interface; use 127.0.0.1 for loopback-only |
--retention-months |
1 |
Data retention in months (only for vmstorage role) |
--replication-factor |
1 |
How many vmstorage nodes each write goes to (vminsert flag) |
--insert-image |
docker.io/victoriametrics/vminsert:v1.154.1-cluster |
vminsert image override |
--select-image |
docker.io/victoriametrics/vmselect:v1.154.1-cluster |
vmselect image override |
--storage-image |
docker.io/victoriametrics/vmstorage:v1.154.1-cluster |
vmstorage image override |
--insert-extra-arg |
— | Extra CLI flag for vminsert (repeatable) |
--select-extra-arg |
— | Extra CLI flag for vmselect (repeatable) |
--storage-extra-arg |
— | Extra CLI flag for vmstorage (repeatable) |
--data-dir |
auto | vmstorage data directory override |
--force |
false | Recreate even when running |
Install
The command pulls the three images if needed, creates the containers in dependency order (storage → insert → select), and prints the summary:
VMCluster is ready to ingest and query immediately.
Cluster URL Format
Important: The cluster edition uses different URL paths than the single-node VictoriaMetrics. All API endpoints require an <accountID> in the path (use 0 for the default account):
Ingestion via vminsert:
Query via vmselect:
Grafana datasource: Point at vmselect with URL http://<host>:8481/select/0/prometheus/
Prometheus remote_write: Configure remote_write to http://<host>:8480/insert/0/prometheus/api/v1/write
Ports
Each VMCluster instance owns 5 consecutive ports from the vmcluster_start_port pool (default 8480):
+0InsertPort (vminsert HTTP — ingestion endpoint)+1SelectPort (vmselect HTTP — query endpoint)+2StoragePort (vmstorage HTTP — admin/metrics endpoint)+3InternalInsertPort (vmstorage TCP — vminsert dials this)+4InternalSelectPort (vmstorage TCP — vmselect dials this)
The consecutive allocation matters because --storage-node references the internal TCP ports explicitly, so the block must stay together. 8480 shares no number with VictoriaMetrics (8428), Prometheus (9090), Grafana (3000), or the rest.
Configuration
There is no config file — everything is passed as CLI flags to the three binaries. storage_nodes is the cross-host topology; empty means auto-derived single-node.
Adding storage nodes
VictoriaMetrics cluster has no dynamic discovery — the -storageNode list is baked into the vminsert/vmselect containers at startup. Adding a vmstorage node requires a brief maintenance window to recreate the access-layer containers (not a hot reload). Existing vmstorage data is unaffected.
Maintenance impact: rebuilding vminsert/vmselect causes a few-second write interruption. Plan this during a low-traffic window if write continuity is critical. Historical data in vmstorage remains available throughout — only ingestion is briefly interrupted.
Steps
- Create the new vmstorage on the target host:
- Recreate vminsert + vmselect on the access-layer host, appending the new
--storage-node:
--force drops and recreates the vminsert/vmselect containers. There is a few-second write interruption; existing vmstorage data is untouched (only the access-layer containers are replaced).
Data distribution after adding a node
- Old data stays on the original vmstorage nodes (no automatic rebalance).
- New writes are re-sharded across all nodes (old + new) by metric hash.
- vmselect merges from all nodes, so queries see both old and new data seamlessly.
VictoriaMetrics cluster does not migrate existing data to a newly added node. For most monitoring workloads this is acceptable — time series are continuously written, and after a few hours the new node holds enough data for queries to be balanced. If even distribution is required, export and re-import the old data (/api/v1/export → /api/v1/import).
Removing storage nodes
Reverse the process: recreate vminsert/vmselect without the departing node’s --storage-node, then pg addon remove vmcluster on the departing host. Data on the removed vmstorage becomes inaccessible to queries — export it first if it needs to be preserved.
Start and stop
Containers run with podman’s unless-stopped restart policy, so they come back automatically after a host reboot.
Autostart on boot
Boot is cluster-granular: each host starts only the roles it runs, in dependency order (storage → insert → select), bringing up existing containers and recreating only what is missing — it never recreates running services. Run the command on every host in a cross-host deployment. See Autostart for the full mechanism.
Remove
Only vmstorage has persistent data; vminsert and vmselect are stateless and leave nothing behind.
Logs
Known limitations
- No authentication. The cluster edition’s API runs arbitrary MetricsQL; the only protection for a
0.0.0.0bind is the network boundary. - Linux only. Host networking is required for the internal TCP ports between components.
- No automatic load balancing. For multiple vminsert/vmselect instances, put HAProxy or nginx in front manually.
- No TLS. Use the nginx addon for TLS termination if needed.
- pgcli does not validate
--extra-argvalues. A bad flag surfaces only in the container logs on startup.
12.22 - JuiceFS
JuiceFS puts a real POSIX filesystem on top of object
storage: file data is split into chunks and stored in an S3 bucket, while a
separate metadata engine (SQLite, Redis, PostgreSQL…) keeps the namespace,
file attributes and chunk maps. You get an ordinary directory you can ls,
cat and grep — every write eventually lands as immutable objects in the
bucket. It solves the “I want a filesystem, but the bytes should live in S3”
problem: snapshots are cheap (objects are immutable), capacity is the bucket’s,
and the same bucket can be re-mounted from any host that knows the metadata
URL.
As a pgcli addon, JuiceFS is pgcli’s first host-binary addon: there is no
container. pg addon install juicefs downloads the official binary from GitHub
releases (sha256-verified; a juicefs already on PATH is reused), formats
the volume against an external S3 service and mounts it on the host.
Platform: Linux only (macOS refuses with a clear error, like BeeGFS). The mount is a plain host directory — visible to host processes only. It does NOT propagate into running PG instance containers (no rshared mount propagation in v1; the addon manages this host’s mount, not a cluster’s).
Everything is explicit
Both sides of the volume are named on the command line — pgcli never auto-detects or inherits from same-host addons:
- Object store: an EXTERNAL S3 service (cloud, or a MinIO on another
machine) given as
--s3-endpoint http(s)://host[:port]plus--access-key/--secret-key. pgcli probes the endpoint before formatting, so an unreachable host fails fast with “not answering” instead of hanging insidejuicefs format. The endpoint is baked into the volume metadata at format time — later mounts need only the metadata URL. - Metadata engine: an explicit URL —
--dsn(alias--meta-dsn) acceptingpostgres://,redis://orsqlite3://. pgcli never creates or drops the database; for PostgreSQL it must already exist. - Bucket: never created by pgcli. Provision it yourself (once) with the
pg mcwrapper — the install prints the exact two lines:
Any engine works the same way — pick the URL:
The mount point is yours to choose — by default it lives under pgcli’s addon
dir; point it anywhere with --mount-point. Create the directory yourself
(mkdir -p) at a path the user running pg has read-write access to:
The three engines can all be pgcli’s own services — created with pgcli, then wired in explicitly as ordinary external URLs (the install never looks them up by itself; it could equally well point at a managed DB or a cloud bucket):
These examples run pg on the same machine as the store, so loopback
(default 127.0.0.1) is enough. When the JuiceFS client lives on another
host, publish the store on the network and point --s3-endpoint at its
address — the endpoint is baked into the volume at format time, so publish it
from the start:
(The same note applies to the redis example: redis already binds 0.0.0.0
by default — its password is the gate.)
The same volume can then be mounted from any host that has the metadata URL — the credentials and endpoint already live inside it.
Parameters
| Flag | Default | Description |
|---|---|---|
--name |
juicefs |
Volume name (config map key; also the JuiceFS volume name at format time) |
--dsn / --meta-dsn |
— | REQUIRED. Metadata-engine URL: postgres://, redis:// or sqlite3:// |
--s3-endpoint |
— | REQUIRED. External S3 service as http(s)://host[:port] (no bucket path, no embedded credentials) |
--access-key / --secret-key |
— | REQUIRED together. S3 credentials for the external service |
--bucket |
juicefs-<name> |
Bucket name — pgcli does NOT create it (see above) |
--mount-point |
<base>/addon/juicefs/<name>/mnt |
Host directory to mount — pgcli never creates it: mkdir -p it beforehand, writable for the user running pg |
--cache-dir |
<base>/addon/juicefs/<name>/cache |
Local data cache dir |
--install-dir |
/usr/local/bin |
Where the host binary goes (falls back to ~/.local/bin when not writable — keep it on PATH) |
--version |
pinned (1.4.1) |
Release to download (its sha256 is pinned in pgcli; pass a version whose digest pgcli knows) |
--no-mount |
off | Format and configure, leave it unmounted |
--force |
off | Rebuild the config entry from the flags (format is still skipped when the volume already exists in the metadata engine) |
FUSE itself (/dev/fuse + fusermount3) is probed, never installed — a
missing fuse3 package fails the install with the apt line to run.
Daily operations
remove never deletes S3 data or the metadata store — losing the --dsn
target means losing the filesystem. The mount owner is the user running pg.
Moving to a different mount point
The mount point is recorded on the config entry, and mount accepts a
--mount-point override that relocates the volume — the new path is persisted
to pg.yaml, so later plain mounts (and autostart) use it. The old mount
must be down first, and the new directory must exist and be writable (same
contract as install):
Attempting the move while still mounted refuses with an “umount first” hint;
pointing it at a missing directory fails with the mkdir -p remedy. The mount
point is fully decoupled from pgcli’s bookkeeping (pid/log/cache stay under
<base>/addon/juicefs/<name>/), so relocation never disturbs the volume
itself — it is the same bucket and metadata re-attached elsewhere.
Autostart
JuiceFS is a host-binary addon, so pg start --autostart re-mounts the
volume at boot instead of starting a container. The backend is external — boot
succeeds only when the S3 endpoint is reachable again. It never re-formats. The
mount comes up as the user the boot service runs as.
12.23 - VictoriaLogs
pg addon install victorialogs runs VictoriaLogs — a fast, cost-effective log database from the VictoriaMetrics family — as a managed container from the official docker.io/victoriametrics/victoria-logs image. Like VictoriaMetrics and Prometheus it is a top-level addon: it fronts no single PostgreSQL instance (it ingests logs from many sources), so it is not attached to any one of them. VictoriaMetrics keeps metrics, VictoriaLogs keeps logs, Grafana visualises both — and all three share the same lifecycle shape (single container, host networking on Linux, one web port from its own pool, persistent data dir, restart policy).
VictoriaLogs is the exact mirror of the VictoriaMetrics addon in how it is configured: it needs no config file at all — everything is passed as CLI flags after the image name (-storageDataPath / -httpListenAddr / -retentionPeriod / custom flags via --extra-arg). So pgcli passes these flags directly when creating the container; there is no config to render, no SIGHUP reload, no chown needed (the image runs as root). One container serves everything on a single HTTP port: ingestion (JSON Lines, Elasticsearch-compatible bulk, syslog, OTLP), LogsQL queries, and the built-in UI.
There is NO web login/password. VictoriaLogs ships no authentication at all. Its HTTP API can ingest and query arbitrary logs and be loaded by expensive LogsQL queries, so a remote-exposed instance is guarded only by the network boundary — your firewall, or
--listen 127.0.0.1for loopback. Treat exposing it like exposing an unauthenticated read/write console, not like a password-protected dashboard.
Platform: Linux is the tested platform. The design supports macOS (the image is multi-arch; the container runs under the podman machine VM on the
pgcli-netbridge with the port published), but it has not been validated there yet.
Parameters
| Flag | Default | Description |
|---|---|---|
--name |
vl |
Instance name (map key) |
--image |
docker.io/victoriametrics/victoria-logs:v1.53.0 |
Image override |
--port |
auto (base 9428) | Host web port; auto-allocated from victorialogs_start_port |
--listen |
0.0.0.0 |
Bind address — defaults to every interface; use 127.0.0.1 for loopback-only (see the exposure warning above) |
--retention |
binary default | Data retention as a duration (30d, 3months, 1y, …), passed verbatim as -retentionPeriod. Omitting it keeps the victoria-logs default |
--extra-arg |
— | Extra CLI flag passed to the binary (repeatable); appended last so last-wins overrides earlier flags. E.g. --extra-arg -search.maxQueryDuration=60s |
--data-dir |
auto | Data directory override (default under the base dir) |
--force |
false | Recreate even when running — to apply changed ports / listen / retention / extra-args |
The data directory holds VictoriaLogs’s log storage. It survives pg addon remove (delete it with --clean-data), so reinstalling under the same name revives stored logs. The image runs as root (unlike Prometheus’s uid 65534), so no pre-chown of the data dir is needed.
Install
The command pulls the image if needed, creates the container, and prints the URL and retention:
VictoriaLogs is ready to ingest and query immediately. Open http://<host>:9428/select/vmui/ for the built-in web UI (the root path / is just an index of useful endpoints), or query via the HTTP API:
Re-running install on a running instance recreates it when the config actually changed (ports, listen, retention, extra-args), and leaves it alone when it didn’t; --force recreates the container even when config is unchanged.
VictoriaLogs does not support hot config reload, so all changes require container recreation — same as VictoriaMetrics. This is simpler than Prometheus (no config file to render, no reload signal), but means brief interruption when changing settings.
Web UI
Every VictoriaLogs instance ships with a built-in web UI (VMUI) — there is no extra component to install. Open:
The root path (/) is only an index of useful HTTP endpoints; the UI lives under /select/vmui/.
The UI is a LogsQL console for logs:
- Query — type a LogsQL query (e.g.
app:demo,level:error,error app:demo-api) and pick the time range; results update as you run the query. - Views — switch between
Group(rows grouped by stream fields),Table(click a row to expand every field),JSON(raw/select/logsql/queryresponse), andLive(live tail of matching logs as they arrive). - Exploration — field and stream pickers list the stored field names/values (backed by
/select/logsql/field_names,field_values,facets, …), so you can build queries without knowing the schema up front.
For example, ingest one line and search for it in the UI (app:demo):
The UI shares the same port and the same no-authentication posture as the HTTP API: anyone who can reach the port can search and also ingest logs. Restrict it with --listen 127.0.0.1 or a firewall, as described above.
Common queries
Queries use LogsQL. The examples below run against the HTTP query API; the same strings work in the VMUI at /select/vmui/.
The response is newline-delimited JSON, one object per log entry.
Always include a
_time:filter when querying production data. Without one, VictoriaLogs scans every log in the retention period.
| Goal | Query |
|---|---|
| All logs from the last 5 minutes | _time:5m |
| Full-text search | _time:15m error |
| Match specific fields | _time:1h app:demo level:error |
| Exact value (case-sensitive) | _time:1h level:=error |
| Regex match (RE2) | _time:1h path:~"/api/v[0-9]+/" |
| Phrase / prefix match | _time:1h _msg:"payment timeout" / _time:1h _msg:timeout* |
| Numeric comparison | _time:1h request_duration:>1s |
| AND / OR / NOT | _time:5m level:error -app:buggy / _time:5m (level:error OR level:warn) |
| Stream filter | _time:1h {app="demo"} |
| Search every field | _time:5m | *:timeout |
| Newest first, top 10 | _time:5m error | sort by (_time) desc | limit 10 |
| Keep only some fields | _time:5m error | fields _time, level, _msg |
| Count by level | _time:1h | stats by (level) count() |
| Total count | _time:1h error | stats count() errors |
| Unique values with hit counts | _time:1h | uniq by (user) with hits |
| Top N values | _time:1d level:error | top 10 (path) |
Notes:
field:wordmatches the word within that field;field:=valueis an exact match andfield:~"regex"is a regex match. Negated forms are-filter,!=and!~.- Time filters accept durations (
5m,1h,30d), absolute timestamps (2026-10-23Z,2026-10-23T10:00:00Z) and ranges (_time:[2026-10-01Z, 2026-10-23Z]). - Pipes run left to right, so filters must come before
sort,limit,stats,topand the other processing stages.
Ingestion: vlagent, HTTP APIs, collectors
pgcli runs the VictoriaLogs backend only — how logs reach it is up to you. There are three routes.
vlagent (official agent)
vlagent is the official log agent for VictoriaLogs — the log counterpart of vmagent (vmagent ships metrics only; do not use it for logs). It accepts the same HTTP ingestion protocols as VictoriaLogs on its own port (9429 by default), can tail log files from disk, and forwards everything to VictoriaLogs via -remoteWrite.url, which must end with /insert/native. pgcli runs and manages it for you — see the vlagent addon page for the full reference:
The first form tracks no log files: without --log-path/--include (or -kubernetesCollector via --extra-arg), vlagent only receives logs pushed to it over HTTP and forwards them. --log-path accepts any host file or directory; with no --include, a directory defaults to <dir>/**/*.log and a file to itself. The globs are matched inside the container, which is why pgcli mounts each host path at the identical absolute path.
Use vlagent when you need:
- Replication / HA — repeat
--remote-write-urlfor every VictoriaLogs instance. vlagent copies each log to all of them and keeps an independent on-disk buffer per URL (-remoteWrite.tmpDataPath, capped by-remoteWrite.maxDiskUsagePerURL); logs collected while a backend is down are replayed when it returns. - Kubernetes Pod logs — pass
--extra-arg -kubernetesCollectorand mount the host’s/var/log(plus/var/libwith the Docker runtime so runtime symlinks resolve) via--log-path. It follows/var/log/podsand/var/log/containers, adds container metadata, and checkpoints its read position in the data dir under-tmpDataPath. - Files on disk —
--log-path /var/log/nginx --include '/var/log/nginx/*.log'(globs must point at files, not directories). By defaulthostnameandfilebecome stream fields, and reading checkpoints/buffers live in the addon’s data dir.
For a single, stable VictoriaLogs instance, sending logs directly (below) is simpler — that is the recommended default.
HTTP APIs
VictoriaLogs exposes several ingestion endpoints on the same port as the UI and query API:
| Endpoint | Format | Notes |
|---|---|---|
/insert/jsonline |
JSON Lines (ndjson) | Requires Content-Type: application/json; without it the request returns 200 but the payload is silently dropped |
/insert/elasticsearch/_bulk |
Elasticsearch / OpenSearch bulk | Point Filebeat / Logstash / Fluent Bit Elasticsearch outputs here |
/insert/loki/api/v1/push |
Loki JSON push | Point Promtail / Grafana Alloy / Loki clients here |
/insert/opentelemetry/v1/logs |
OTLP/HTTP logs | Point an OpenTelemetry Collector exporter here |
| syslog TCP / UDP / Unix | RFC3164 / RFC5424 | Enable via --extra-arg -syslog.listenAddr.tcp=:514 (or .udp / .unix), then recreate |
All HTTP ingestion endpoints accept the same tuning parameters, either as query-string args or VL-* headers: _msg_field (field holding the log message, e.g. message), _time_field (e.g. @timestamp), _stream_fields (comma-separated fields that define a log stream), extra_fields (env=prod), ignore_fields, decolorize_fields, and debug=1 (log the payload instead of storing it). Use them when your collector emits a schema VictoriaLogs does not auto-detect.
Log collectors
Any collector that can speak one of the formats above works; pgcli installs and configures none of them. Commonly used ones, each with an official setup guide:
| Collector | Send to |
|---|---|
| Filebeat | Elasticsearch output → /insert/elasticsearch/_bulk |
| Logstash | Elasticsearch or HTTP output |
| Fluent Bit / Fluentd | Elasticsearch or HTTP output |
| Vector | http sink → /insert/jsonline, or Elasticsearch sink |
| Promtail / Grafana Alloy | Loki push → /insert/loki/api/v1/push |
| OpenTelemetry Collector | OTLP/HTTP exporter → /insert/opentelemetry/v1/logs |
| Telegraf | http output → jsonline |
| journald | Vector / Fluent Bit journald source → jsonline, or vlagent |
| rsyslog / syslog-ng | syslog listener (-syslog.listenAddr.*) |
| Datadog Agent | Datadog intake API |
Collectors can also point at a vlagent instance (port 9429) instead of VictoriaLogs directly — useful when you want buffering, replication, or a single egress point.
Custom flags (--extra-arg)
VictoriaLogs accepts many CLI flags for tuning cache sizes, query limits, ingestion rates, and more. Pass them via --extra-arg (repeatable):
Extra args are appended last to the container’s command line, so they override earlier flags via last-wins semantics. This lets you tune VictoriaLogs beyond pgcli’s first-class knobs without editing any config file. Verify the flags took effect (the binary does not print them in its logs):
Listening and exposure
VictoriaLogs’s Listen defaults to 0.0.0.0: the container shares the host’s network namespace, so the UI and API are reachable on every interface at the assigned port. This matches VictoriaMetrics’s default for the same practical reason (log shippers and Grafana on other hosts need to reach it) — but there is no password behind it. Anyone who can reach the port can run arbitrary LogsQL and read your stored logs. For a loopback-only deployment pass --listen 127.0.0.1 (recreate with --force); pg addon list then shows http://127.0.0.1:<port>/. Otherwise, restrict access at the firewall.
Ports
Each VictoriaLogs instance owns a single port: the web UI/API. It is allocated from a pool with base victorialogs_start_port (default 9428 — VictoriaLogs’s conventional HTTP port), incrementing for every additional instance and skipping ports already taken by any managed container or the used_ports list. The allocation is written into pg.yaml, so a reinstall never re-hands-out a port another addon is using. 9428 shares no number with VictoriaMetrics (8428), vmcluster (8480-8484), Prometheus (9090), Grafana (3000), pgAdmin (5050), or nginx (8080); an explicit --port still wins.
Configuration
There is no config file — everything is passed as CLI flags to the victoria-logs binary (-storageDataPath, -httpListenAddr, -retentionPeriod, plus any extra_args). retention is stored as a duration string (omitted = binary default), and extra_args is a list of CLI flags appended last.
List
There are no credentials to reveal — VictoriaLogs has none — so --show-password shows nothing extra for it.
Start and stop
Containers run with podman’s unless-stopped restart policy, so they come back automatically with the podman service after a host reboot. start/stop manage them explicitly:
Autostart on boot
This flags the instance in pg.yaml and installs/refreshes a boot service (a systemd --user unit on Linux, a LaunchAgent on macOS) that runs pg start --autostart. A boot-time start only brings the container up — the log data dir already exists, so stored logs survive the reboot. Disable with pg autostart disable --victorialogs --name vl; see Autostart for the full mechanism.
Remove
Under rootless podman, --clean-data deletes the data dir through podman unshare rm — VictoriaLogs writes as root (container uid 0), which maps to a subordinate uid the host user cannot rm directly.
Troubleshooting
- Ingestion answers 200 but queries return nothing — the JSON Lines endpoint requires
Content-Type: application/json; without it VictoriaLogs accepts the request and silently drops the payload (vl_rows_ingested_total{type="jsonline"}on/metricsstays 0). Add the header, or use an ingestion client that sets it. - A query returns no data — confirm VictoriaLogs is running (
pg addon listshowsrunning), the health endpoint responds (curl -s http://<host>:<port>/-/healthy), and you’re querying the correct time range and stream filters (_stream:prefix,_time:bounds). Checkpodman logs <container>for ingestion errors. - Install fails with “container already exists” — the container name is derived from
--nameand the namespace; use--forceto recreate, orpg addon removefirst to clean up a partial install. - Extra args didn’t take effect —
--extra-argis applied at container creation time, not runtime. Reinstall with--forceto recreate the container with the new flags. Checkpodman inspect <container> --format '{{json .Config.Cmd}}'to confirm the flags are on the command line (the binary does not log them). - Port already allocated — the assigned port is held in
pg.yamland skipped for later instances; free it withpg addon removefirst, or pick another with--port N. - Remote API unreachable / unexpectedly reachable — on the default
0.0.0.0a failure to reach it is host firewall, not bind address; conversely, if you did not want it reachable,--listen 127.0.0.1 --force.
Known limitations
- VictoriaLogs itself is a bare log backend: pgcli ships a dedicated
vlagentaddon for file/HTTP collection (plus direct HTTP ingestion APIs and third-party collectors, as described above), but configures no third-party shippers and does no alerting (usevlalertseparately). It is the long-term log store you point Grafana (VictoriaLogs datasource) and your shippers at. - No authentication. VictoriaLogs’s API ingests and queries arbitrary logs; the only protection for a
0.0.0.0bind is the network boundary. This is the same posture as VictoriaMetrics — do not expose it on an untrusted network. - pgcli does not validate
--extra-argvalues (there is no pre-flight check at install). A bad flag surfaces only in the container logs on startup. - On macOS the container runs inside the podman machine; it has been validated on Linux only.
12.24 - vlagent
pg addon install vlagent runs vlagent — the official log agent of the VictoriaLogs family and the log counterpart of vmagent — as a managed container from the official docker.io/victoriametrics/vlagent image. It is a top-level addon (it fronts no single PostgreSQL instance) and the natural partner of the VictoriaLogs addon: VictoriaLogs stores logs, vlagent collects them from files or over HTTP and forwards them to one or more VictoriaLogs instances.
Like VictoriaMetrics and VictoriaLogs there is no config file at all — everything is passed as CLI flags after the image name. pgcli adds the file-collector wiring on top: every --log-path is bind-mounted read-only at the same absolute path inside the container, and --include/--exclude select files using vlagent’s native glob syntax (not regular expressions).
There is NO authentication. vlagent’s HTTP listener accepts logs from anyone who can reach the port, and it forwards to VictoriaLogs instances that (by default) have no auth either. A remote-exposed instance is guarded only by the network boundary — use
--listen 127.0.0.1for loopback or restrict access at the firewall.
Platform: Linux is the tested platform. The design supports macOS (the image is multi-arch; the container runs under the podman machine VM on the
pgcli-netbridge with the port published), but it has not been validated there yet.
Parameters
| Flag | Default | Description |
|---|---|---|
--name |
va |
Instance name (map key) |
--image |
docker.io/victoriametrics/vlagent:v1.53.0 |
Image override |
--port |
auto (base 9429) | Host HTTP ingest port; auto-allocated from vlagent_start_port |
--listen |
0.0.0.0 |
Bind address — defaults to every interface; use 127.0.0.1 for loopback-only |
--remote-write-url |
required | VictoriaLogs endpoint(s) to forward to (repeatable). Every URL must end with /insert/native |
--log-path |
— | Host file or directory to track (repeatable). Mounted read-only at the same absolute path inside the container |
--include |
derived | Glob pattern(s) selecting files (repeatable; absolute container paths). Defaults to <dir>/**/*.log per directory and the file itself per file |
--exclude |
— | Glob pattern(s) excluding files (repeatable; positionally paired with --include) |
--extra-arg |
— | Extra CLI flag passed to the binary (repeatable); appended last so last-wins overrides earlier flags |
--data-dir |
auto | Data directory override (default under the base dir) |
--force |
false | Recreate even when running — to apply changed ports / listen / URLs / paths / globs |
The data directory holds vlagent’s on-disk remote-write buffers and the file-collector checkpoints (its read positions). It survives pg addon remove (delete it with --clean-data), so reinstalling under the same name resumes exactly where collection stopped instead of re-sending or skipping file contents. The image runs as root, so no pre-chown is needed.
There is no --password: vlagent has no concept of a local account, and neither side of the connection authenticates.
Install
Relay-only — accept HTTP pushes on :9429 and forward them to a local VictoriaLogs:
Track a directory of host log files (the common case):
With no --include, the directory defaults to /var/log/nginx/**/*.log — every .log file at any depth below it. The flag is quoted here only for shell safety; pgcli stores the glob literally.
Track a single file, or several paths with explicit globs and excludes:
Every --log-path becomes one read-only bind mount (host:host:ro), so the globs pgcli passes to vlagent are literally the host paths. Paths must be absolute and must already exist.
Re-running install on a running instance recreates it when the config actually changed (port, listen, URLs, log paths, includes/excludes, extra-args), and leaves it alone when it didn’t; --force recreates the container even when config is unchanged. List-valued flags replace the stored list: to add a path, repeat the whole set.
How file collection works
vlagent’s file collector is driven by -fileCollector.glob. pgcli maps its flags one-to-one:
| pgcli flag | vlagent flag | Notes |
|---|---|---|
--log-path |
(bind mount) | -v <path>:<path>:ro — required so the container can see the file |
--include |
-fileCollector.glob |
Repeatable; must match files, not directories |
--exclude |
-fileCollector.excludeGlob |
Positionally paired with the Nth include |
--data-dir |
-tmpDataPath |
Contains buffers and file-checkpoints.json |
Each collected line becomes a log entry with two extra fields: file (the source path) and hostname. Both are stream fields by default, so each file is its own log stream in VictoriaLogs — exactly what you want for LogsQL filtering. Tune them with --extra-arg -fileCollector.streamFields=..., -fileCollector.fileField=..., or -fileCollector.hostnameField=....
The collector polls for new files every 10 seconds (-fileCollector.refreshInterval). It supports the create and nocreate rotation strategies: on rotation it finishes the old file before switching, and it resumes rotated files from the saved position after a restart. Checkpoints are written under the data dir.
Glob syntax
This is not a regular expression — it is vlagent’s own glob dialect:
| Pattern | Matches |
|---|---|
* |
Any sequence of characters except path separators |
/**/ |
Zero or more directories (recursive) |
? |
Any single character except a path separator |
[abc], [a-z] |
Character class; prefix with ^ or ! to negate |
{a,b,c} |
Alternatives — any one of the comma-separated terms |
\c |
Escaped character literal |
Examples:
* does not match hidden files (names starting with .); name the leading dot explicitly (.*.log) if you need them.
Excludes are positional
vlagent pairs the Nth --exclude with the Nth --include, like its own flags. An empty string '' is a placeholder meaning “no exclude at this position”:
This collects everything except nginx first, then nginx in the second pair. Without the positional pairing, the nginx exclusion would also silence the second pair.
pgcli emits every exclude entry up to the last non-empty one; trailing empty placeholders are simply dropped. If you change --log-path without passing --include, the default globs are recomputed for the new paths and stored excludes beyond the new pair count are trimmed (pass --exclude explicitly in the same run to override).
Replication and high availability
Repeat --remote-write-url for every VictoriaLogs instance:
vlagent copies every log to all targets, keeps an independent disk buffer per URL, and replays buffered logs when a backend returns. Cap each buffer with --extra-arg -remoteWrite.maxDiskUsagePerURL=....
Other ingestion over HTTP
Even without --log-path, vlagent is a useful HTTP front door: it accepts the same protocols as VictoriaLogs on its listen port and forwards them, so collectors can push into vlagent instead of directly into VictoriaLogs:
| Endpoint | Format |
|---|---|
/insert/jsonline |
JSON Lines (requires Content-Type: application/json) |
/insert/elasticsearch/_bulk |
Elasticsearch / OpenSearch bulk |
/insert/loki/api/v1/push |
Loki JSON push |
/insert/opentelemetry/v1/logs |
OTLP/HTTP logs |
For Kubernetes node logs, pass --extra-arg -kubernetesCollector and mount the node’s /var/log (plus /var/lib with the Docker runtime) via --log-path. See the VictoriaLogs ingestion section for the full protocol and collector table.
Ports
Each vlagent instance owns a single port: the HTTP ingest listener. It is allocated from a pool with base vlagent_start_port (default 9429 — vlagent’s conventional port), incrementing for every additional instance and skipping ports already taken by any managed container or the used_ports list. The pool is separate from VictoriaLogs (9428), so adding a VictoriaLogs instance can never steal a vlagent port. An explicit --port still wins.
Configuration
Everything is passed as CLI flags to the vlagent binary: remote_write_urls becomes repeated -remoteWrite.url, includes become -fileCollector.glob, excludes become -fileCollector.excludeGlob, log_paths become read-only bind mounts, and data_dir becomes -tmpDataPath.
List
Start and stop
The container runs with podman’s unless-stopped restart policy. File read positions are checkpointed to the data dir, so a stop/start (or host reboot) resumes without duplicating or losing file content.
Autostart on boot
This flags the instance in pg.yaml and installs/refreshes a boot service (a systemd --user unit on Linux, a LaunchAgent on macOS) that runs pg start --autostart. A boot-time start only brings the container up — the data dir with the file checkpoints and remote-write buffers already exists, so the agent resumes where it stopped. vlagent starts after VictoriaLogs in the boot sequence, so a same-host remote-write target is already listening. Disable with pg autostart disable --vlagent --name va; see Autostart for the full mechanism.
Remove
Removing the agent does not remove any log files it tracked — they are mounted read-only and never modified.
Troubleshooting
- No file logs in VictoriaLogs — confirm the container is running (
pg addon list), the globs match real files (--includemust match files, not directories), and the paths are visible inside the container.podman inspect pgcli-vlagent-<ns>-<name> --format '{{json .Config.Cmd}}'shows the exact flags;podman logs <container>shows collector errors. - Some files are skipped —
*does not match hidden files, and rotated names likeapp.log.1do not match*.log. Add explicit patterns (e.g.--include '/var/log/myapp/app.log.[0-9]'). Excludes are positional: a broad first-pair exclude can hide files from a later pair. - Data lands with the wrong stream —
fileandhostnameare stream fields by default; override with--extra-arg -fileCollector.streamFields=.... - Extra args didn’t take effect —
--extra-argis applied at container creation time. Reinstall (config changes recreate automatically) or use--force; verify withpodman inspect <container> --format '{{json .Config.Cmd}}'. - Container cannot read the logs (SELinux hosts) — pgcli mounts log paths with
:roonly, deliberately not:z, because relabeling/var/logwould modify the host. On enforcing-SELinux systems pass workload-appropriate labeling yourself (e.g. a sharedcontainer_log_tlabel or--extra-arg-independent podman policy) or run the container with a matching security profile. - Remote write fails — the URL must end in
/insert/native; check VictoriaLogs health (curl -s http://<vl>:9428/-/healthy) and whether a firewall separates the hosts. Buffered logs replay once it is reachable again.
Known limitations
- No regular expressions. vlagent matches globs only (
*,/**/,?,[a-z],{a,b}). Complex file-name rules need a broader include plus a LogsQL filter on the query side. - No authentication on the ingest listener or the remote-write side; guard it with
--listen 127.0.0.1or a firewall. - pgcli does not validate
--extra-argvalues; a bad flag surfaces in the container logs at startup. - macOS is code-complete but untested; Linux is the validated platform.
12.25 - VLCluster
pg addon install vlcluster runs VictoriaLogs cluster edition — the horizontally scalable log database. Unlike the single-node victorialogs addon (one container, one port), this manages up to three containers from the same image (docker.io/victoriametrics/victoria-logs:v1.53.0) and three HTTP ports per host. The role is selected entirely by flags.
Architecture:
| Component | Role | HTTP Port | Talks to |
|---|---|---|---|
| vlinsert | Ingestion — shards writes across all vlstorage nodes by stream hash | 9430 | Dials every vlstorage:StoragePort |
| vlselect | Queries — fans out LogsQL and merges results | 9431 | Dials every vlstorage:StoragePort |
| vlstorage | Data storage (sharded, not replicated) | 9432 | Serves /internal/insert + /internal/select/* on that one port |
vlinsert and vlselect are stateless and dial the same single port on each vlstorage node; there are no separate internal TCP ports like VMCluster’s host:insertPort:selectPort. A storage node address is therefore just host:storagePort.
There is NO web login/password. Like single-node VictoriaLogs, the cluster edition ships no authentication at all. Its HTTP API can ingest, query, and delete arbitrary logs, so a remote-exposed instance is guarded only by the network boundary — your firewall, or
--listen 127.0.0.1for loopback. Treat it as an unauthenticated read/write console.
Platform: Linux only. VLCluster requires host networking for the ports that vlinsert/vlselect dial on every vlstorage node. On macOS, installation is refused with a clear error.
No replication. vlinsert shards but does not copy: every stream lives on exactly one vlstorage node. Losing any vlstorage makes queries return HTTP 502 until it comes back (ingestion keeps working, buffered by vlagent or the client). Plan capacity and backup accordingly — this is an upstream design property, not a pgcli limitation.
Deployment Models
Model 1 — Converged single-host (default)
All three components on one host:
Model 2 — Cross-host sharding
Same --roles + --storage-node model as VMCluster: each host runs its own pg addon install vlcluster, and pgcli never SSHes.
An access-only host (--roles insert and/or --roles select) must pass --storage-node — install aborts otherwise, because the auto-derived loopback address would create an isolated node that silently joins nothing.
All hosts must allocate the same port block for the instance name so the addresses match. The default base is 9430 (see Ports); if you change vlcluster_start_port on one host, set it identically on the others.
Parameters
| Flag | Default | Description |
|---|---|---|
--name |
vlc |
Instance name (map key) |
--roles |
insert,select,storage |
Comma-separated roles this host runs (subset for cross-host) |
--storage-node |
auto-derived | vlstorage address (repeatable; format host:storagePort, e.g. 10.0.0.1:9432). Lists ALL vlstorage nodes for sharding. Omit on a converged host |
--insert-port |
auto (base 9430) | vlinsert HTTP port |
--select-port |
auto (base 9431) | vlselect HTTP port |
--storage-port |
auto (base 9432) | vlstorage HTTP port |
--listen |
0.0.0.0 |
Bind address — use 127.0.0.1 for loopback-only |
--retention |
binary default | vlstorage retention, e.g. 30d (only when this host runs storage) |
--image |
docker.io/victoriametrics/victoria-logs:v1.53.0 |
Image override (one image for all roles) |
--insert-extra-arg |
— | Extra CLI flag for vlinsert (repeatable, appended last) |
--select-extra-arg |
— | Extra CLI flag for vlselect (repeatable, appended last) |
--storage-extra-arg |
— | Extra CLI flag for vlstorage (repeatable, appended last) |
--data-dir |
auto | vlstorage data directory override |
--force |
false | Recreate even when running and unchanged |
--retention is deliberately not defaulted: empty means the victoria-logs binary default. Unlike vmcluster’s --retention-months, VictoriaLogs parses durations such as 30d, 3months, or 1y.
Install
The command pulls the image if needed, creates the containers in dependency order (storage → insert → select), and prints the summary:
Reinstalling with the same flags is a no-op (unchanged); changed flags recreate only what is needed. A role removed from --roles has its stale container deleted.
URL Format
The cluster edition uses the same API paths as single-node VictoriaLogs — no account ID, no extra prefix:
Ingestion via vlinsert:
Query via vlselect:
vlselect also serves the built-in UI at http://<host>:9431/select/vmui/. vlagent forwards to http://<vlinsert>:9430/insert/native.
The vlstorage port (9432) serves /internal/insert and /internal/select/* for the other two roles; its own LogsQL endpoints query only that node’s local data, which is useful for per-node debugging but not for cluster-wide queries.
Ports
Each VLCluster instance owns 3 consecutive ports from the vlcluster_start_port pool (default 9430):
+0InsertPort (vlinsert HTTP — ingestion endpoint)+1SelectPort (vlselect HTTP — query endpoint)+2StoragePort (vlstorage HTTP — internal insert/select APIs)
All three are allocated even on hosts that run only a subset, so a storage node’s address is predictable across hosts. The base sits just above single-node VictoriaLogs (9428) and vlagent (9429); the three pools are mutually aware and never hand out the same port.
Configuration
There is no config file inside the containers — everything is passed as CLI flags. storage_nodes is the cross-host topology; empty means auto-derived single-node.
Adding storage nodes
vlinsert/vlselect bake the -storageNode list at startup, so adding a node requires recreating the access-layer containers (a brief ingestion pause; existing data is untouched):
No automatic rebalance: old data stays on the original nodes; only new writes are spread across the enlarged set. vlselect merges all nodes, so queries see both. To rebalance older data, wait for retention to expire it or manually copy per-day partitions (upstream offers no rebalance tool).
Start and stop
Containers run with podman’s unless-stopped restart policy and come back after a host reboot. Stop goes in reverse dependency order (select → insert → storage).
Autostart on boot
Boot is cluster-granular: each host starts only the roles it runs, in dependency order (storage → insert → select), starting existing containers and recreating only what is missing. Run it on every host in a cross-host deployment. See Autostart for the full mechanism.
Remove
Removal deletes all three role containers on this host, even orphans left behind by a role change. On an access-only host this does not touch remote storage nodes.
Logs
A role this host does not run is rejected with a clear error.
Known limitations
- No replication. One copy per stream; any vlstorage outage makes queries return 502. Ingestion continues and can be buffered upstream (e.g. vlagent’s disk queue). Cross-cluster HA needs multiple vlagent
--remote-write-urltargets and a routing layer such as vmauth — out of scope here. - No automatic rebalance. Adding a storage node only affects new data.
- No authentication. The HTTP API ingests and queries arbitrary logs; a
0.0.0.0bind is protected only by the network boundary. - Linux only. Host networking is required.
- No TLS. Terminate TLS in front (nginx) or use a private network.
- Per-host status.
pg addon listreports only the roles configured on the current host; it does not probe remote storage nodes. - pgcli does not validate
--*-extra-argvalues. A bad flag surfaces only in the container logs on startup.
12.26 - ZeroFS
ZeroFS is an S3-backed filesystem that keeps everything inside the bucket: instead of JuiceFS’s external metadata engine, it runs its own LSM tree directly on object storage, and every object is encrypted with a per-volume data key wrapped by your encryption password. Snapshots and consistency are handled by background compaction — there is no database to back up, no Redis to keep alive. You lose neither property than a normal filesystem gives you (it mounts via FUSE through a local 9P hop), and the bucket alone is a complete, encrypted copy of the volume.
Like JuiceFS, ZeroFS is a host-binary addon — no container.
pg addon install zerofs downloads the official release binary
(sha256-verified; a zerofs already on PATH is reused), writes the volume’s
config.toml itself, and runs two host processes:
- the server (
zerofs run -c config.toml) — owns the S3 + encryption side and speaks 9P on a loopback port (default 5564). 9P has no authentication, so pgcli hard-rejects a non-loopback--listen; - the client (
zerofs mount 127.0.0.1:<port> <mount-point>) — the FUSE mount you actually use, auto-reconnecting if the server bounces.
Platform: Linux only (host FUSE; macOS refuses, like BeeGFS). The mount is a plain host directory — it does not propagate into PG instance containers.
Everything is explicit
The object store is an EXTERNAL S3 service (cloud, or a MinIO on another machine), named on the command line — pgcli never auto-detects or inherits a same-host addon’s credentials:
--s3-endpointis a barehttp(s)://host[:port](no bucket path, no embedded credentials); pgcli probes it before starting the server, so an unreachable host fails fast instead of hanging the server’s retry loop.--access-key/--secret-keyare required, always together.- The bucket is never created by pgcli — provision it yourself with the
pg mcwrapper; the install prints the exact two lines. - ZeroFS signs requests, so
--s3-regionis honoured (optional — zerofs itself defaults tous-east-1; most MinIO-compatible stores accept the default).
The mount point is yours to choose — by default it lives under pgcli’s addon
dir; point it anywhere with --mount-point. Create the directory yourself
(mkdir -p) at a path the user running pg has read-write access to — pgcli
never creates it:
A missing or unwritable directory fails the install (and every later mount)
with the exact mkdir -p remedy before anything is downloaded or started.
Both backends can be pgcli’s own services — created with pgcli, then wired in explicitly as ordinary external URLs (the install never looks them up by itself; it could equally well point at a cloud bucket or a remote Redis):
These examples run pg on the same machine as the store, so loopback
(default 127.0.0.1) is enough. When the zerofs client lives on another
host, publish the store on the network and point --s3-endpoint at its
address — the endpoint goes into config.toml, so re-render with --force
if you change it later:
(Redis already binds 0.0.0.0 by default — its password is the gate. Note
this is about the S3/redis backends only: zerofs’s OWN 9P --listen must
stay loopback — 9P has no authentication.)
The password is the data
⚠️ 🔑
pg addon removeDESTROYS your only copy of the password — save it first.removekeeps the bucket but deletes the config entry, and the password lives only in that entry and itsconfig.toml. Without the original password the kept bucket is permanently unreadable — no recovery path exists. Before anyremoveyou might want to undo later:pg addon password zerofs --name z1 > /somewhere/safe(outside pgcli’s dirs).
The encryption password wraps the bucket’s data-encryption key (DEK). It is generated on first install — retrieve it any time with:
Lose it and the bucket’s contents are unrecoverable. This also matters on
pg addon remove, which deliberately keeps the bucket: the config entry
(and its copy of the password) is gone, so a plain re-install generates a
new password that cannot unwrap the old DEK — the server fails to start
with Invalid password or corrupted key. Re-attach the kept bucket by passing
the original password back in:
(To truly start over: empty the bucket, or pick a new --bucket.)
Fencing: conditional put
ZeroFS fences concurrent writers with S3 conditional puts
(If-None-Match). --conditional-put selects how:
| Mode | Meaning |
|---|---|
auto (default) |
Use the store’s native If-None-Match — needs MinIO RELEASE.2022-08+ or real S3 |
redis |
Fence through Redis instead — for stores lacking conditional-put (e.g. silo); requires an explicit --redis-dsn redis://… (no same-host addon lookup) |
none |
No fencing — safe only when nothing else ever touches the bucket |
If a server start fails on a store that lacks conditional-put, the install
recognises the log signature and tells you to retry with
--conditional-put redis --redis-dsn ….
--redis-dsn is a plain redis:// URL — the DSN is written verbatim into
config.toml, and any Redis works, including a pgcli redis addon (see the
wiring example above; pg addon password redis returns its generated
password bare, /N picks the logical database). TLS endpoints use the same
URL shape as everywhere else in pgcli: rediss://user:pass@redis-host:6380/1.
One 9P port per instance: a zerofs server that loses the port bind starts
silently and listens on nothing, so install and start refuse an already-
answering --port outright — run several volumes by giving each its own port.
Parameters
| Flag | Default | Description |
|---|---|---|
--name |
zerofs |
Volume name (config map key) |
--s3-endpoint |
— | REQUIRED. External S3 service as http(s)://host[:port] |
--s3-region |
zerofs’s own us-east-1 |
SigV4 region for the endpoint |
--bucket |
zerofs-<name> |
Bucket — pgcli does NOT create it |
--access-key / --secret-key |
— | REQUIRED together. S3 credentials |
--encryption-password |
generated | Wrap password; pass the stored one to re-attach a kept bucket |
--mount-point |
<base>/addon/zerofs/<name>/mnt |
Host directory to mount — pgcli never creates it: mkdir -p it beforehand, writable for the user running pg |
--cache-dir |
<base>/addon/zerofs/<name>/cache |
Server-side disk cache dir |
--server-cache-gb |
100 | Server cache size (GiB) |
--listen |
127.0.0.1 |
9P bind address — must be loopback (no auth in 9P) |
--port |
5564 | 9P port (refused when already answering — one per zerofs instance) |
--conditional-put |
auto |
auto / redis / none (see Fencing) |
--redis-dsn |
— | Redis fence backend, REQUIRED with --conditional-put redis |
--install-dir |
/usr/local/bin |
Where the host binary goes (falls back to ~/.local/bin) |
--version |
pinned (2.3.5) |
Release to download (digest pinned in pgcli) |
--no-mount |
off | Write config + start server, leave it unmounted |
--force |
off | Re-render config.toml and restart — keeps the stored encryption password (a fresh one could not unwrap existing data) |
FUSE (/dev/fuse + fusermount3) is probed, never installed.
Daily operations
The config.toml, server/client logs and pid files live under
<base>/addon/zerofs/<name>/; the secrets (encryption password + S3 keys)
are kept in the 0600 config.toml and — like every pgcli addon credential —
in plain view in your pg.yaml.
Relocating the mount point works exactly like JuiceFS:
umount, mkdir -p the new directory (writable — same contract as
install), then mount with the override; the new path is persisted to
pg.yaml, so plain mounts and autostart use it from then on:
Attempting the move while still mounted refuses with an “umount first”
hint; the FUSE mount point is fully decoupled from pgcli’s bookkeeping (the
9P server keeps running on its port, config.toml/logs/pid stay under
<base>/addon/zerofs/<name>/), so the move never touches the volume.
Autostart
At boot, pg start --autostart brings up the 9P server (waiting for the port
to listen) and then the FUSE client. The backend is external — boot succeeds
only when the S3 endpoint is reachable again. Nothing is re-rendered or
regenerated: the existing config.toml and stored password are reused
verbatim.
12.27 - DuckDB
DuckDB is an embedded analytical database — often called
“SQLite for analytics”. It is a C++ columnar engine with vectorised execution
that queries Parquet, CSV, JSON and Arrow files directly, without a server
process. Think of it as a single-binary OLAP engine you point at your data:
SELECT * FROM read_parquet('s3://bucket/data.parquet') works out of the box
once the httpfs extension is loaded.
As a pgcli addon, DuckDB is a host-binary addon: there is no container and
no daemon. pg addon install duckdb downloads the official CLI zip from GitHub
releases (sha256-verified; a duckdb already on PATH is reused), installs
the httpfs extension (parquet and json are bundled in the binary), and
records the entry in pg.yaml.
Platform: Linux only, amd64 only (macOS and other architectures refuse with a clear error). DuckDB is a standalone CLI tool — it does not run as a background service, does not listen on a port, and does not propagate into running PG instance containers.
Why DuckDB alongside pgcli
DuckDB fills the analytical gap that PostgreSQL’s row-oriented engine does not
target: ad-hoc queries over large Parquet/CSV datasets, data-lake exploration,
ETL pre-processing before loading into PostgreSQL, or quick aggregations on
files that already live in S3. pgcli already manages S3 stores (MinIO, silo,
rustfs, garage, seaweedfs) — DuckDB queries their contents directly via
httpfs.
Usage
Once installed, launch the interactive shell or run one-shot queries:
Parameters
| Flag | Default | Description |
|---|---|---|
--name |
duckdb |
Instance name (config map key) |
--version |
1.5.6 |
Release to download (its sha256 is pinned in pgcli; 1.5.6 LTS and 2.0.0 supported) |
--data-dir |
<base>/addon/duckdb/<name> |
Default database directory for .duckdb files |
--install-dir |
/usr/local/bin |
Where the host binary goes (falls back to ~/.local/bin when not writable — keep it on PATH) |
--no-extensions |
off | Skip installing the httpfs extension (parquet and json are bundled in the binary) |
--force |
off | Rebuild the config entry from the flags |
Daily operations
remove never deletes the binary — it may be shared across multiple named
instances. Pass --clean-data to delete the data directory (database files);
without it, the directory is kept and reported.
Server/Client mode (v2.0.0+)
DuckDB v2.0.0 introduced the Quack protocol for native server/client operation. This allows DuckDB to serve its databases over HTTP, letting multiple clients query the same instance.
Starting a server
The server listens on localhost:9494 by default and prints an auto-generated
auth token. By default, it only accepts local connections — for remote access,
put it behind a TLS-terminating reverse proxy (see
DuckDB docs).
Cross-host access (0.0.0.0 binding)
To accept connections from any network interface, use quack_serve() with
explicit parameters instead of -serve:
Three parameters are needed:
listen_uri='quack:0.0.0.0:9494'— bind to all interfaces (not just localhost)allow_other_hostname=true— bypass the localhost-only restrictiondisable_ssl=true— use plain HTTP (default is HTTPS with auto-generated cert)
Clients connect using the server’s IP address:
Security warning: Binding to
0.0.0.0withdisable_ssl=trueexposes the database to the network without encryption. Use only on trusted networks or behind a firewall. For production, use a reverse proxy with TLS.
Connecting to a server
The -connect flag auto-discovers the token from the server’s output. For
manual connections, copy the token from the server’s startup message.
Key points
- stdin must stay open: the server exits when stdin closes (use
< /dev/zeroortail -f /dev/null |for background operation) - localhost only: remote access requires a reverse proxy with TLS termination
- Auth token: auto-generated, printed at startup, required for client connections
- Not managed by pgcli:
pg addon start/stop duckdbremain no-ops — the Quack server is a standalone process you manage yourself
Extensions
DuckDB’s extension system installs shared libraries on first use. pgcli
pre-installs httpfs at install time — it enables reading files over HTTP/S3.
parquet and json are statically linked into the DuckDB CLI binary, so they
are always available without a network download.
httpfs— read files over HTTP/S3 (read_parquet('s3://...'))parquet— Parquet file reader/writer (bundled)json— JSON file reader (read_json_auto(...)) (bundled)
Additional extensions can be installed later from the DuckDB shell:
Pass --no-extensions at install time if you prefer to manage extensions
manually, or if the host has no internet access at install time.
Accessing S3, Parquet, JSON and CSV data
DuckDB is built for analytical queries over files — no server, no COPY, no
import step. Point it at your data.
Parquet files
JSON files
CSV files
S3 access (httpfs extension)
The httpfs extension enables S3 access. Credentials and endpoint are configured
via CREATE SECRET (v2.0.0+) or legacy SET commands (v1.5.x).
DuckDB v2.0.0+ — CREATE SECRET
DuckDB v1.5.x — legacy SET commands
Connecting to pgcli-managed S3 stores
pgcli manages several S3-compatible stores (MinIO, silo, rustfs, garage,
seaweedfs). DuckDB queries them directly — the s3_endpoint is the store’s
HTTP address, and credentials come from pg addon password.
First, create an S3 store instance and a bucket. The setup is identical across
MinIO, silo (Pigsty’s MinIO fork), and rustfs (Rust reimplementation) — they
all share the same pg mc wrapper and the same DuckDB s3_url_style = 'path':
Then query it from DuckDB — note URL_STYLE 'path' is required for
MinIO, silo, rustfs and compatible stores (they do not support virtual-host style):
For a more permanent setup, create a DuckDB startup file (~/.duckdbrc) with
the CREATE SECRET (v2.0.0+) or SET (v1.5.x) commands — they run on every
session start:
Environment variables for S3
DuckDB reads endpoint and SSL settings from DUCKDB_S3_* environment variables,
but credentials must use standard AWS variables (AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY) — DuckDB’s enable_global_s3_configuration auto-detects these.
v2.0.0 limitation:
URL_STYLEcannot be set via environment variables. It defaults to virtual-host, which breaks MinIO/silo/rustfs (they require path-style). For these stores, useCREATE SECRETwithURL_STYLE 'path'instead of env vars.
For MinIO/silo/rustfs, you still need s3_url_style = 'path' because these
stores don’t support virtual-host style URLs.
12.28 - Node_exporter
pg addon install nodeexporter runs node_exporter — the official Prometheus exporter that publishes the host’s CPU, memory, disk, network and filesystem metrics at /metrics — as a managed container from the official docker.io/prom/node-exporter image. Like Prometheus, VictoriaMetrics and Grafana it is a top-level addon: it describes the machine, not one PostgreSQL instance, so it is attached to none of them. It is the natural companion to a pgcli Prometheus addon — node_exporter exposes, Prometheus scrapes.
node_exporter shares VictoriaMetrics’s shape — single container, no config file (everything is CLI flags after the image name), no hot reload, no authentication — but it has one requirement no other web addon does, described below.
The non-negotiable: host mounts. A container reading its own
/procreports the CONTAINER’s metrics, not the host’s. To export the machine, pgcli hard-wires three read-only bind mounts — host/proc→/host/proc,/sys→/host/sys,/→/rootfs— and points the binary at them with--path.procfs/--path.sysfs/--path.rootfs. These are fixed in pgcli, not user-configurable; without them the addon would silently export the wrong numbers. They are mounted:roonly (never:z), so pgcli never relabels the host root filesystem.
There is NO login/password and no TLS. node_exporter just serves
/metricsin plain text. Because Prometheus scraping it is usually on a different host,Listendefaults to0.0.0.0— anyone who can reach the port can read detailed host facts (hardware, mounts, network). Restrict it to your monitoring network, or pass--listen 127.0.0.1for a same-host Prometheus.
Platform: Linux is the tested platform (the container uses host networking, so the exporter binds the host port directly). The design supports macOS (bridge + published port), but it has not been validated there yet.
Parameters
| Flag | Default | Description |
|---|---|---|
--name |
nodeexporter |
Instance name (map key) |
--image |
docker.io/prom/node-exporter:v1.12.1 |
Image override |
--port |
auto (base 9100) | Host port; auto-allocated from nodeexporter_start_port |
--listen |
0.0.0.0 |
Bind address — every interface by default (cross-host Prometheus); use 127.0.0.1 for loopback-only |
--extra-arg |
— | Extra CLI flag passed to the binary (repeatable); appended last so last-wins overrides earlier flags. E.g. --extra-arg --no-collector.cpufreq |
--force |
false | Recreate even when running — to apply changed ports / listen / extra-args |
node_exporter is stateless — it stores nothing, so there is no data directory. pg addon remove deletes only the container; reinstalling always starts fresh.
Install
The command pulls the image if needed, creates the container with the three host mounts, and prints the scrape URL:
Because the default bind is 0.0.0.0, the summary also prints an exposure warning and the matching Prometheus command. Verify the exporter serves real host numbers:
The values should track the host (free -b, cat /proc/loadavg) — if they track the container instead, a mount is missing (they cannot be, via pgcli, but a hand-run node_exporter often gets this wrong).
Wire it into Prometheus
The exporter is only useful once something scrapes it. A pgcli Prometheus instance does, via a one-line --scrape job (no service discovery, no Prometheus config edits):
To add this exporter to a pgcli Prometheus that is already running and already
scraping other jobs, use --scrape-append instead — it folds the node-exporter
job into the stored scrape list (merging same-named jobs, appending unseen ones)
and SIGHUP-reloads, so it never wipes the existing targets:
(A plain --scrape replaces the whole job set, so re-running install with it
would drop any other jobs p1 was scraping.) Then in Prometheus,
up{job="node-exporter"} is 1 and count(node_cpu_seconds_total) is large. See
the Prometheus addon for the full --scrape / --scrape-append
mechanics.
Re-running install on a running instance recreates it when the config changed (port, listen, extra-args), and leaves it alone otherwise; --force recreates even when unchanged. node_exporter has no SIGHUP reload, so every change requires container recreation.
Custom flags (--extra-arg)
node_exporter has dozens of collector flags. Toggle individual collectors off/on, or add --collector.*/--no-collector.* switches, via --extra-arg (repeatable):
Extra args are appended last to the command line, so they override pgcli’s injected defaults via last-wins (including pgcli’s own --collector.filesystem.mount-points-exclude, which drops the pseudo-filesystems pgcli just mounted so node_filesystem_* isn’t all noise). There is no config file to edit.
Listening and exposure
Listen defaults to 0.0.0.0: on Linux the container shares the host network namespace, so /metrics is reachable on every interface at the assigned port. This is deliberate — a Prometheus scraping node_exporter is usually on another machine. But there is no authentication, and node_exporter exposes fine-grained host detail (mount table, network interfaces, hardware). For a same-host Prometheus, pass --listen 127.0.0.1 (recreate with --force); otherwise restrict the port at the firewall. (Under the macOS bridge build, pgcli widens the bind to 0.0.0.0 automatically so the published port stays reachable through the podman VM.)
Ports
Each node_exporter instance owns a single port — the metrics endpoint — allocated from a pool with base nodeexporter_start_port (default 9100, node_exporter’s conventional port), incrementing for each additional instance and skipping ports taken by any managed container or used_ports. The allocation is saved to pg.yaml, so a reinstall never re-hands-out a port another addon holds. 9100 shares no number with Prometheus (9090), VictoriaMetrics (8428), Grafana (3000), Alertmanager (9093), or nginx (8080); an explicit --port still wins.
Configuration
There is no config file — everything is passed as CLI flags to the node_exporter binary, and the three host mounts are fixed by pgcli. extra_args is a list of flags appended last.
List
There are no credentials to reveal — node_exporter has none.
Start and stop
Containers run with podman’s unless-stopped restart policy, so they come back with podman after a host reboot. start/stop manage them explicitly:
Auto-start on Boot
To bring a node_exporter instance up on host boot:
This flags the instance in pg.yaml and installs/refreshes a boot service (a systemd --user unit on Linux, a LaunchAgent on macOS) that runs pg start --autostart. A boot-time start only brings the container up — node_exporter is stateless, so there is nothing to prepare. See Autostart for the full mechanism, status and disabling.
Remove
node_exporter keeps nothing on disk, so --clean-data has no effect beyond an informational note.
Logs
Troubleshooting
- Metrics look like the container’s, not the host’s — not possible via
pg addon install(the mounts are hard-wired), but if you ever runnode_exporterby hand you must pass the/proc//sys//read-only mounts and the matching--path.*flags. Confirm withcurl -s …/metrics | grep node_uname_infoand comparenode_memory_MemAvailable_bytesto the hostfree -b. - Prometheus shows the target DOWN — check the exporter is running (
pg addon list), that the scrape target address/port match (--scrape node-exporter=<host>:<port>), and that a loopback--listen 127.0.0.1exporter isn’t being scraped from another host. - A collector flag didn’t take effect —
--extra-argis applied at container creation. Reinstall with--forceto recreate, then checkpg logs addon nodeexporterfor the flag on the startup line. - Port already allocated — the assigned port is held in
pg.yamland skipped for later instances; free it withpg addon removefirst, or pick another with--port N.
Known limitations
- No authentication, no TLS. node_exporter serves plain-text
/metrics; the only protection for a0.0.0.0bind is the network boundary. Front with nginx or restrict by firewall if it must leave a trusted network. - Stateless — no data dir, no retention; nothing survives beyond the running process. Reinstall always starts fresh.
- Rootful assumption. The
-v /:/rootfs:romount reads the whole host tree; under rootless podman some host paths may be unreadable. All target VMs run rootful podman, matching the rest of pgcli. - pgcli does not validate
--extra-argvalues; a bad flag surfaces only in the container logs on startup. - On macOS the container runs inside the podman machine with the port published; validated on Linux only.
12.29 - InfluxDB
pg addon install influxdb runs InfluxDB 1.13.1 OSS — a single-node time-series database with TSM storage and InfluxQL — as a managed container from pgcli’s own ghcr.io/mars-base/pgcli/pgcli-influxdb image (the official linux/amd64 release binaries on Alpine). Like VictoriaMetrics, it is a top-level addon: it does not front a PostgreSQL instance.
The addon is Linux/amd64 only (the image is single-arch). InfluxDB 1.x has no clustering/HA, no built-in web UI, and upstream has put v1 into maintenance mode; pgcli pins the final 1.13.1 release. For new deployments consider VictoriaMetrics or VictoriaLogs if you need a maintained, auth-free-but-modern engine; InfluxDB 1.x remains a good fit when you need InfluxQL compatibility with existing tooling.
Authentication is always enabled. The install generates an admin password, stores it in
pg.yaml, andpg addon password influxdb [--name NAME]prints it again. The HTTP API cannot be used anonymously. The backup/restore RPC port is loopback-only by design — upstream has no authentication for it, so pgcli never exposes it on the network regardless of--listen.
Parameters
| Flag | Default | Description |
|---|---|---|
--name |
influxdb |
Instance name (map key; default subcommand name is the same) |
--version |
1.13.1 |
Pinned image release (selects the published pgcli-influxdb tag) |
--data-dir |
<base_dir>/addon/influxdb/<name>/data |
Data directory bind-mounted at /var/lib/influxdb; absolute or relative to base_dir |
--listen |
0.0.0.0 |
HTTP bind address. The RPC endpoint always stays on 127.0.0.1 |
--http-port |
auto (base 8086) | Host HTTP port; auto-allocated from influxdb_start_port |
--rpc-port |
auto (base 8088) | Backup/restore RPC port, loopback only; each instance gets a unique port |
--admin-user |
admin |
InfluxQL admin username (no web UI) |
--admin-password |
generated | Admin password. On --force the stored password is kept unless this is set |
--config-option |
— | Extra influxdb.conf setting as KEY=VALUE (top-level) or SECTION.KEY=VALUE (table), raw TOML value (repeatable). Replaces the stored override list and is persisted |
--force |
false | Rewrite the generated config and recreate the container, keeping data and the stored admin password |
The generated config only overrides paths, listeners and auth; every other value is the upstream 1.13.1 default. The rendered file lives at <base_dir>/addon/influxdb/<name>/influxdb.conf and is mounted read-only. Overrides are stored both in pg.yaml and in that file so they survive restarts and a re-render.
Install
Second and later instances automatically take the next free HTTP/RPC ports from their pools (for example 8087/8089) and can point --data-dir anywhere:
Re-running install without --force is a no-op that reports the current state. --force rewrites the config and recreates the container, but keeps the data dir and the stored admin password (the existing admin user would otherwise become unreachable); pass --config-option again to retain your overrides, because the flag replaces the stored list.
Credentials
The password is stored in pg.yaml (mode 0644 by default — protect that file). Commands that need credentials pass them to containers through the environment, never on the podman command line.
Config overrides
config set/unset rewrites both the generated file and pg.yaml; --restart recreates the container so the change takes effect. Without --restart the new value applies at the next start. Values are raw TOML: quote strings ('data.index-version="tsi1"'), leave numbers bare.
The upstream default index is inmem. For high-cardinality series set data.index-version="tsi1" — it can only be changed on a fresh data dir, so decide before loading data.
The pg influx CLI
pg influx runs the influx client from the same image in a throwaway container — nothing is installed on the host and the container is removed after each command. Any flag pg’s own parser rejects goes after --; arguments are forwarded verbatim.
Local instance
The host, port and credentials are injected automatically.
Remote InfluxDB 1.x
Credentials fall back to INFLUX_USERNAME / INFLUX_PASSWORD in both modes. Prefer the environment variables over --password, whose value is visible in the process list. --ssl enables HTTPS; --unsafe-ssl skips certificate verification and requires --ssl. --dsn already carries the connection settings and cannot be combined with --host/--port/--ssl/...; --name cannot be combined with any remote flag.
Import files in remote mode
The file named by -path is bind-mounted into the throwaway container at its absolute path, so exports produced by pg addon influxdb export (or influx_inspect export) import into remote servers too.
A # DML section with a # CONTEXT-DATABASE: marker is required; the # DDL section is optional. Include it when the target database may not exist — CREATE DATABASE is a no-op when it already does. The import target comes from the context marker, not from --database; importing into a missing database fails with database not found. A file without a # DML header is parsed as InfluxQL and fails with a syntax error.
/tmp/demo.txt is read on the machine running pg (it is bind-mounted into the container), not on db.example.com. A minimal file:
The # DDL block runs as InfluxQL before the data (CREATE DATABASE is a no-op if the database exists). Everything after # DML is line protocol; timestamps are optional (default nanoseconds, -precision changes the interpretation). Add -pps N to throttle and -compressed if the file is gzip-compressed. When the remote server has authentication enabled, pass --username/--password or export INFLUX_USERNAME/INFLUX_PASSWORD.
Cross-host workflows
pg influx remote mode runs entirely from the client machine — it needs podman and the pgcli-influxdb image, but no local InfluxDB and no add-on instance.
Add-on lifecycle and backup commands always act on the local podman instance, so they cannot reach a server on another host: pg addon influxdb status|backup|restore|export|import, pg addon start|stop|remove and pg logs addon influxdb must be run on the InfluxDB host itself — log in to that host and run them there. From other machines use pg influx --host/--dsn or the HTTP API (/query, /write) described below.
Move data between hosts with export + import:
Restores use the loopback-only RPC endpoint, so the backup directory must be present on the target host and restore run there:
Common operations
Databases
Retention policies
Every database has at least one retention policy — the default autogen never expires data. A retention policy controls how long data is kept and how it is sharded; expired shards are dropped automatically.
Equivalent InfluxQL:
DURATIONacceptsh,d,wandINF; the minimum is1h.SHARD DURATIONis optional — by default it is derived fromDURATION.- Only one policy can be
DEFAULT; making another one default demotes the previous one. Queries without an RP prefix use the default policy. DROP RETENTION POLICYdeletes its data. Expired shards are removed by the background retention check (every 30 minutes by default), so disk usage drops after the next check.
Interactive shell
Type InfluxQL directly (SHOW MEASUREMENTS, SELECT ..., use otherdb, …); exit or Ctrl-D leaves the shell. --database selects the initial database context.
Inspect the schema
InfluxQL quoting: double quotes delimit identifiers ("host", "cpu"), single quotes delimit string values ('server01'). If the query itself contains double quotes, wrap the whole -execute argument in single quotes as in the SHOW TAG VALUES example.
Query data
Write and delete
For bulk writes use pg influx ... -- -import -path FILE — see Export and import.
Users and permissions
Avoid putting the admin password in argv (ps shows it); use the interactive shell, INFLUX_USERNAME/INFLUX_PASSWORD, or --password '' to be prompted.
Logs and status
HTTP API (URL operations)
The addon serves the stock InfluxDB 1.x HTTP API on <listen>:<http-port> with authentication always enabled (HTTP Basic). The same calls work locally (http://127.0.0.1:8086) and remotely (http://db.example.com:8086, or https://influx.example.com when nginx terminates TLS).
| Parameter | Endpoint | Meaning |
|---|---|---|
db |
/query, /write |
Target database (required for most statements) |
rp |
/query, /write |
Target retention policy (defaults to the database default) |
q |
/query |
InfluxQL statement; URL-encode it (--data-urlencode does this) |
pretty=true |
/query |
Indent the JSON response |
epoch |
/query |
ns, u, ms, s, m, h; omit for RFC3339 timestamps |
precision |
/write |
Timestamp precision of the supplied line protocol (ns by default) |
Notes:
- Unauthenticated requests return
401. A successful write returns204; malformed points return400with apartial writemessage that names the dropped points. curl -u user:passexposes the password in the process list. Prefer a mode-0600 netrc file:machine db.example.com login admin password secret, thencurl --netrc-file ~/.netrc-influx ....- For day-to-day local work
pg influxis simpler and injects credentials through the environment; the HTTP API is the way to integrate from other machines, scripts and applications.
Backup and restore
Portable backups are online — the instance keeps running:
The default output dir is <instance dir>/backups/<UTC timestamp>; --out DIR overrides it. A backup directory contains .manifest, .meta and per-shard .tar.gz files.
⚠️ Store backups on remote S3. The backup is written on the InfluxDB host, so losing that host would lose the backup too. Upload each backup directory with any S3 client — for example the
pg mcadd-on:
A restore reads the directory through the loopback RPC, so download it back to the target host before running pg addon influxdb restore.
Restores are online too (the helper talks to the running server over the loopback RPC; a stopped instance is started first). A complete backup → drop → restore cycle for one database:
influxd restore refuses to overwrite an existing database. To restore alongside the old one instead of replacing it, use a new name — --newdb requires --database:
The instance is left running after the restore.
⚠️ A restore is not a complete identity/metadata copy. Users, privileges and continuous queries are not restored — recreate them manually after a restore. Retention policies and their durations are restored.
Export and import
export is offline: pgcli stops the container so influx_inspect reads consistent TSM files, then restarts it unless --no-start is given.
The default file is <instance dir>/exports/<database|all>-<UTC timestamp>.txt (.gz with --compress).
⚠️ After an import you must recreate users, privileges and continuous queries, and recreate non-default retention policies with their real
DURATION/SHARD DURATION. The export DDL emits RPs asDURATION 0sand omits the shard duration, so a 30d RP would become infinite unless fixed.
An export file is a logical data copy, not a full instance snapshot: it contains every data point (TSM plus WAL) with per-database/retention-policy context markers, plus CREATE DATABASE / CREATE RETENTION POLICY DDL. Without --database, all databases including _internal are exported. Use backup/restore for disaster recovery and export/import to move points between servers.
import needs a running instance and takes an export-format file:
With --database, pgcli copies the file under the instance directory and rewrites the # CONTEXT-DATABASE: lines to the target database, so the original export is not modified. Importing into a remote server is done with pg influx --host ... -- -import -path FILE (above).
Unlike restore, import has no new-name restriction: it writes line-protocol points into the target and merges them with existing data (a point with the same series and timestamp overwrites the old one). The demo_copy above is only an example of importing into another database — importing into the original demo is fine. The target database must already exist (the file’s # DDL can create it) unless --database names a database you created first.
Lifecycle
Data and the generated config survive remove without --clean-data, so reinstalling under the same name revives the stored series. --clean-data also removes a custom --data-dir.
Auto-start after reboot:
At boot pgcli starts the existing container; it does not re-run authentication bootstrap.
Production notes
- No HTTPS listener. InfluxDB 1.x serves plain HTTP. Front it with the nginx addon if you need TLS, or bind
--listen 127.0.0.1and keep it local. - RPC is loopback-only. The backup/restore RPC protocol has no authentication; pgcli pins it to
127.0.0.1and allocates one unique port per instance. Do not expose it. - No HA. InfluxDB 1.x OSS is single-node. Backups (
backup/export) are the disaster-recovery path; there is no scheduled backup — wirepg addon influxdb backupinto your own timer if needed. - v1 is in maintenance mode. The version is pinned to 1.13.1; upgrading means publishing a new image tag and re-testing. There is no in-place major upgrade path from v1 to v2/v3.
- High cardinality: switch to
data.index-version="tsi1"before loading data (above). - Platform: Linux/amd64 only — the image packages the official amd64 tarball.
CLI flag reference
A one-page lookup of every pgcli flag that applies to InfluxDB. InfluxDB’s own
CLI flags go after -- and are forwarded verbatim (see influx -help).
pg addon install influxdb
| Flag | Default | Description |
|---|---|---|
--name NAME |
influxdb |
Instance name (map key); [A-Za-z0-9][A-Za-z0-9_-]*, max 48 chars |
--version V |
1.13.1 |
Pinned image release; only versions shipped as a pgcli image are accepted |
--data-dir DIR |
<base_dir>/addon/influxdb/<name>/data |
Host dir bind-mounted at /var/lib/influxdb; absolute or relative to base_dir |
--listen ADDR |
0.0.0.0 |
HTTP bind address only; the RPC listener is always 127.0.0.1 |
--http-port N |
auto (base 8086) |
HTTP host port; 0 = auto-assign from influxdb_start_port |
--rpc-port N |
auto (base 8088) |
Backup/restore RPC port, loopback only; 0 = auto-assign; must differ from --http-port |
--admin-user USER |
admin |
InfluxQL admin username |
--admin-password PW |
generated | Admin password; on --force the stored value is kept unless supplied |
--config-option K=V |
— | Extra influxdb.conf entry: KEY=VALUE (top-level) or SECTION.KEY=VALUE (table), raw TOML; repeatable; replaces the stored override list |
--force |
false | Rewrite the generated config and recreate the container; keeps data and the stored password |
--image does not apply to InfluxDB — use --version.
pg addon influxdb status
| Argument/flag | Default | Description |
|---|---|---|
--name NAME |
influxdb |
Instance to inspect |
pg addon influxdb shell [-- influx args...]
| Argument/flag | Default | Description |
|---|---|---|
--name NAME |
influxdb |
Instance to connect to |
--database DB |
— | Adds -database DB |
args after -- |
— | Forwarded verbatim to the influx client (-execute, -format, …) |
pg addon influxdb database create NAME / database list
| Argument/flag | Default | Description |
|---|---|---|
NAME |
— | Database to create (positional, create only) |
--name NAME |
influxdb |
Instance to operate on |
pg addon influxdb backup
| Flag | Default | Description |
|---|---|---|
--name NAME |
influxdb |
Instance to back up (must be running) |
--out DIR |
<instance dir>/backups/<UTC timestamp> |
Backup output directory |
--database DB |
all | Back up one database (-db) |
--since RFC3339 |
— | Back up data since this instant; cannot be combined with --start/--end |
--start RFC3339 |
— | Range start; requires --end |
--end RFC3339 |
— | Range end; requires --start |
pg addon influxdb restore BACKUP_DIR
| Argument/flag | Default | Description |
|---|---|---|
BACKUP_DIR |
— | Backup directory to restore (positional, required) |
--name NAME |
influxdb |
Target instance (started first if stopped) |
--database DB |
all | Restore one database (-db) |
--newdb NAME |
— | Restore under a new database name (-newdb); requires --database |
Restoring a database that already exists fails; use --newdb to restore alongside it.
pg addon influxdb export
| Flag | Default | Description |
|---|---|---|
--name NAME |
influxdb |
Instance to export (stopped for the duration) |
--database DB |
all | Export one database (-database) |
--out FILE |
<instance dir>/exports/<database|all>-<UTC timestamp>.txt[.gz] |
Output file |
--compress |
false | Gzip the output (-compress) |
--start RFC3339 |
— | Range start; requires --end |
--end RFC3339 |
— | Range end; requires --start |
--no-start |
false | Leave the instance stopped afterwards |
pg addon influxdb import FILE
| Argument/flag | Default | Description |
|---|---|---|
FILE |
— | Export-format file to import (positional, required) |
--name NAME |
influxdb |
Target instance (must be running) |
--database DB |
file’s context | Target database (-database); rewrites # CONTEXT-DATABASE: lines |
--precision P |
— | Timestamp precision (-precision): ns, u, ms, s, m, h |
pg addon influxdb config list / set / unset
| Argument/flag | Default | Description |
|---|---|---|
KEY=VALUE... |
— | Entries to set (positional, set only; one or more) |
SECTION.KEY... |
— | Entries to remove (positional, unset only; one or more) |
--name NAME |
influxdb |
Instance to modify |
--restart |
false | Restart the container after rewriting the config (set/unset only) |
pg influx
| Flag | Default | Description |
|---|---|---|
--name NAME |
influxdb |
Managed addon instance; conflicts with every remote flag |
--database DB |
— | Adds -database DB |
--host HOST |
— | Remote host; enables remote mode |
--port N |
8086 |
Remote HTTP port |
--dsn URL |
— | influxdb://user:pass@host:port/db or http(s)://...; exclusive with the other connection flags |
--username USER |
$INFLUX_USERNAME |
Remote username |
--password PW |
$INFLUX_PASSWORD |
Remote password (prefer the env var: ps shows argv) |
--ssl |
false | Use HTTPS (-ssl) |
--unsafe-ssl |
false | Skip TLS verification (-unsafeSsl); requires --ssl |
args after -- |
— | Forwarded verbatim to the influx client |
Lifecycle, credentials, logs, autostart
| Command | Flags | Description |
|---|---|---|
pg addon start influxdb |
--name NAME (default influxdb) |
Start (create if missing) the container |
pg addon stop influxdb |
--name NAME (default influxdb) |
Stop the container |
pg addon remove influxdb |
--name NAME (default influxdb), --clean-data |
Remove the container and config entry; --clean-data also deletes the instance dir, backups and data dir |
pg addon password influxdb |
--name NAME (default influxdb) |
Print the admin password |
pg logs addon influxdb |
--name NAME (default influxdb), -n N (--tail, default 50), -f (--follow) |
Container logs |
pg autostart enable|disable |
--influxdb, --name NAME (default influxdb) |
Toggle boot start for one instance |
Global flags
| Flag | Description |
|---|---|
-c, --config FILE |
Config file (default ~/.pgcli/pg.yaml) |
-o, --output FILE |
Output config path (default the same file) |
-i, --instance NAME |
PostgreSQL instance selector — not used by InfluxDB; use --name instead |
Full influxdb.conf parameter reference
The generated config is minimal: pgcli renders only the keys below and leaves every other InfluxDB 1.13.1 default in place. The defaults in the tables are the effective values printed by influxd config (1.13.1).
| pgcli-managed key | Rendered value | Why |
|---|---|---|
reporting-disabled |
true |
Disables upstream usage reporting |
bind-address (top level) |
"127.0.0.1:<rpc-port>" |
Backup/restore RPC; upstream has no auth for it, so it is never exposed |
[meta] dir |
/var/lib/influxdb/meta |
Inside the data-dir bind mount |
[data] dir |
/var/lib/influxdb/data |
Inside the data-dir bind mount |
[data] wal-dir |
/var/lib/influxdb/wal |
Inside the data-dir bind mount |
[http] bind-address |
<listen>:<http-port> |
Host networking: this is the actual host listener |
[http] auth-enabled |
true |
Authentication is always on |
These seven keys are rejected by --config-option / config set — change them through pg addon install / config flags instead. Everything else can be overridden.
How to override
Values are raw TOML — quote strings, durations and sizes ('cache-max-memory-size="2g"'), leave numbers/bools bare. Limitations:
- Only single
[section]tables are supported. The repeated[[graphite]],[[collectd]],[[opentsdb]]and[[udp]]listener tables cannot be expressed; neither can nested keys such ashttps-client-ca.pathsorhttp.headers. - Such settings require editing
<base_dir>/addon/influxdb/<name>/influxdb.confdirectly and restarting the container (pg addon stop|start influxdb). Ordinary start/stop keeps the file;install --forceand anyconfig set/unsetre-render it and will drop hand edits. --config-optionreplaces the whole stored override list; re-pass all entries when changing one.
Top level
| Key | Default | Description |
|---|---|---|
reporting-disabled |
false |
pgcli renders true (managed) |
bind-address |
"127.0.0.1:8088" |
RPC backup/restore listener (managed) |
hardening-enabled |
false |
Restrict Flux HTTP requests to public addresses (SSRF hardening) |
[meta]
| Key | Default | Description |
|---|---|---|
dir |
~/.influxdb/meta (/var/lib/influxdb/meta in the container) |
Metadata/Raft database directory (managed) |
retention-autocreate |
true |
Auto-create the autogen retention policy on CREATE DATABASE |
logging-enabled |
true |
Log meta-service messages |
[data]
| Key | Default | Description |
|---|---|---|
dir |
~/.influxdb/data |
TSM storage directory (managed) |
wal-dir |
~/.influxdb/wal |
WAL directory (managed) |
wal-fsync-delay |
"0s" |
Batch window before fsync; 0s fsyncs every write; 0–100ms helps non-SSD disks |
index-version |
"inmem" |
Shard index: inmem (default) or tsi1 (high cardinality; only settable on a fresh data dir) |
validate-keys |
false |
Reject keys with invalid UTF-8/unicode (small write overhead) |
skip-field-size-validation |
false |
Skip the maximum field-size check on writes |
strict-error-handling |
false |
Return errors instead of silently dropping invalid points (e.g. ±Inf in SELECT INTO) |
query-log-enabled |
true |
Log queries before execution (may log sensitive data) |
cache-max-memory-size |
"1g" |
Per-shard cache limit before writes are rejected |
cache-snapshot-memory-size |
"25m" |
Cache size that triggers a snapshot to TSM |
cache-snapshot-write-cold-duration |
"10m" |
Snapshot the cache after this long without writes/deletes |
compact-full-write-cold-duration |
"4h" |
Full compaction after this long without writes/deletes |
compact-throughput |
"48m" |
Compaction write rate limit (bytes/s) |
compact-throughput-burst |
"48m" |
Allowed short burst above the compaction rate limit |
aggressive-points-per-block |
10000 |
Points per block for aggressive compaction |
ingress-metric-by-measurement-enabled |
false |
Collect per-measurement write statistics |
ingress-metric-by-login-enabled |
false |
Collect per-login write statistics |
max-series-per-database |
1000000 |
Series limit per database before writes are dropped (0 = unlimited) |
max-values-per-tag |
100000 |
Tag-value limit per tag (0 = unlimited) |
max-concurrent-compactions |
0 |
Concurrent full/level compactions; 0 = 50% of GOMAXPROCS |
max-concurrent-optimized-compactions |
0 |
Concurrent optimized compactions (0 = default) |
max-index-log-file-size |
"1m" |
TSI index WAL threshold before compaction into an index file |
max-concurrent-deletes |
1 |
Simultaneous DELETE calls per shard |
series-id-set-cache-size |
100 |
TSI series-id-set cache entries (0 disables) |
series-id-set-cache-max-size |
0 |
Upper bound for adaptive cache growth (0 = adaptive sizing off) |
series-id-set-cache-target-hit-rate |
0.0 |
Target hit rate for adaptive sizing (0.0 = off, 1.0 invalid) |
series-id-set-cache-shrink-conservatism |
2.5 |
Shrink-policy margin in standard deviations |
series-file-max-concurrent-snapshot-compactions |
0 |
Concurrent series-file snapshot compactions (0 = default) |
trace-logging-enabled |
false |
Verbose TSM engine trace logging |
tsm-use-madv-willneed |
false |
Advise the kernel with MADV_WILLNEED for TSM files |
tar-stream-buffer-size |
"1m" |
Buffer size for tar streaming during backup/rename/copy |
[coordinator]
| Key | Default | Description |
|---|---|---|
write-timeout |
"10s" |
How long a write waits before returning a timeout |
max-concurrent-queries |
0 |
Concurrent query limit (0 = unlimited) |
query-timeout |
"0s" |
Kill queries after this long (0s = no limit) |
log-queries-after |
"0s" |
Slow-query logging threshold (0s = off) |
log-timedout-queries |
false |
Log queries killed by query-timeout |
max-select-point |
0 |
Maximum points a SELECT may process (0 = unlimited) |
max-select-series |
0 |
Maximum series a SELECT may touch (0 = unlimited) |
max-select-buckets |
0 |
Maximum GROUP BY time() buckets (0 = unlimited) |
termination-query-log |
false |
Print running queries on SIGTERM |
[flux-controller]
| Key | Default | Description |
|---|---|---|
query-concurrency |
0 |
Concurrent Flux queries (0 = default) |
query-initial-memory-bytes |
"0" |
Initial memory budget per Flux query |
query-max-memory-bytes |
"0" |
Maximum memory per Flux query |
total-max-memory-bytes |
"0" |
Total memory for all Flux queries |
query-queue-size |
0 |
Flux query queue length |
[retention]
| Key | Default | Description |
|---|---|---|
enabled |
true |
Enforce retention policies |
check-interval |
"30m" |
How often retention enforcement runs |
[shard-precreation]
| Key | Default | Description |
|---|---|---|
enabled |
true |
Pre-create shards before data arrives |
check-interval |
"10m" |
How often pre-creation runs |
advance-period |
"30m" |
How far ahead of a shard group’s end its successor is created |
[monitor]
| Key | Default | Description |
|---|---|---|
store-enabled |
true |
Record internal statistics |
store-database |
"_internal" |
Destination database |
store-interval |
"10s" |
Recording interval |
[http]
| Key | Default | Description |
|---|---|---|
enabled |
true |
Enable the HTTP API |
bind-address |
":8086" |
HTTP listener (managed: <listen>:<http-port>) |
auth-enabled |
false |
Require authentication (managed: true) |
realm |
"InfluxDB" |
Basic-auth challenge realm |
log-enabled |
true |
HTTP request logging |
suppress-write-log |
false |
Hide write requests from the HTTP log |
access-log-path |
"" |
Access-log file; empty writes to stderr |
access-log-status-filters |
[] |
Status filters (NNN, NNX, NXX); empty logs everything |
write-tracing |
false |
Detailed write logging |
flux-enabled |
false |
Enable the Flux query endpoint |
flux-log-enabled |
false |
Log Flux queries |
flux-testing |
false |
Expose Flux testing endpoints |
pprof-enabled |
true |
Enable /debug/pprof |
pprof-auth-enabled |
false |
Require admin auth for pprof |
debug-pprof-enabled |
false |
Bind an extra pprof endpoint on localhost:6060 at startup |
ping-auth-enabled |
false |
Require auth on /ping, /metrics, /status |
prom-read-auth-enabled |
false |
Require auth on the Prometheus remote-read API |
https-enabled |
false |
Serve HTTPS |
https-certificate |
"/etc/ssl/influxdb.pem" |
TLS certificate (PEM) |
https-private-key |
"" |
TLS private key; empty = combined with the certificate |
https-insecure-certificate |
false |
Allow insecure certificate file permissions |
https-ignore-sanity-checks |
false |
Load certificates that fail server-auth sanity checks |
https-client-auth-type |
unset | Mutual TLS client-auth mode (NoClientCert, RequestClientCert, RequireAnyClientCert, VerifyClientCertIfGiven, RequireAndVerifyClientCert) |
https-client-ca |
unset | Client CA bundle: { paths = [...], include-system = false } |
shared-secret |
"" |
JWT validation secret |
max-row-limit |
0 |
Chunked-result row limit (0 = unlimited) |
max-connection-limit |
0 |
Concurrent HTTP connections (0 = unlimited) |
unix-socket-enabled |
false |
Serve HTTP over a Unix socket |
unix-socket-permissions |
"0777" |
Unix socket permissions |
bind-socket |
"/var/run/influxdb.sock" |
Unix socket path |
max-body-size |
25000000 |
Maximum request body in bytes (0 = unlimited) |
max-concurrent-write-limit |
0 |
Concurrent writes (0 = unlimited) |
max-enqueued-write-limit |
0 |
Queued writes (0 = unlimited) |
enqueued-write-timeout |
"30s" |
Maximum queue wait for a write |
user-query-bytes-enabled |
false |
Track query response bytes per user |
user-write-bytes-enabled |
false |
Track write request bytes per user |
[http.headers] |
unset | Arbitrary response headers, e.g. X-Header-1 = "value" |
[logging]
| Key | Default | Description |
|---|---|---|
format |
"auto" |
Log encoder: auto, logfmt or json |
level |
"info" |
Minimum level: error, warn, info, debug |
suppress-logo |
false |
Suppress the startup logo |
[subscriber]
| Key | Default | Description |
|---|---|---|
enabled |
true |
Enable subscriptions (fork every write to an endpoint) |
http-timeout |
"30s" |
Subscriber write timeout |
insecure-skip-verify |
false |
Skip TLS verification for subscribers |
ca-certs |
"" |
PEM CA bundle path; empty uses the system roots |
root-ca |
unset | Extra subscriber CAs: { paths = [...], include-system = false } |
certificate |
"" |
Client certificate presented to subscribers (mutual TLS) |
private-key |
"" |
Key for certificate |
insecure-certificate |
false |
Allow insecure certificate/key file permissions |
ignore-cert-sanity-checks |
false |
Load certificates failing server-auth sanity checks |
write-concurrency |
40 |
Writer goroutines |
write-buffer-size |
1000 |
In-flight writes buffered in the channel |
total-buffer-bytes |
"0" |
Total buffer budget (0 = unlimited) |
Repeated listener tables
These are array-of-tables ([[graphite]], [[collectd]], [[opentsdb]], [[udp]]) and cannot be set with --config-option; edit the generated file directly if you need them. Each table starts disabled.
| Section | Key | Default | Description |
|---|---|---|---|
[[graphite]] |
enabled |
false |
Enable the Graphite listener |
bind-address |
":2003" |
Listener address | |
database |
"graphite" |
Target database | |
retention-policy |
"" |
Target retention policy | |
protocol |
"tcp" |
tcp or udp |
|
consistency-level |
"one" |
Write consistency level | |
batch-size |
5000 |
Points buffered before flush | |
batch-pending |
10 |
Pending batches | |
batch-timeout |
"1s" |
Maximum time before a flush | |
udp-read-buffer |
0 |
UDP read buffer (0 = OS default) |
|
separator |
"." |
Joins template measurement parts | |
tags |
[] |
Default tags, e.g. ["region=us-east"] |
|
templates |
[] |
Template rules (one default) | |
[[collectd]] |
enabled |
false |
Enable the collectd listener |
bind-address |
":25826" |
Listener address | |
database |
"collectd" |
Target database | |
retention-policy |
"" |
Target retention policy | |
typesdb |
"/usr/share/collectd/types.db" |
Types DB file or directory | |
security-level |
"none" |
none, sign or encrypt |
|
auth-file |
"/etc/collectd/auth_file" |
Auth file for signed/encrypted traffic | |
batch-size |
5000 |
Points buffered before flush | |
batch-pending |
10 |
Pending batches | |
batch-timeout |
"10s" |
Maximum time before a flush | |
read-buffer |
0 |
UDP read buffer (0 = OS default) |
|
parse-multivalue-plugin |
"split" |
split or join multi-value plugins |
|
[[opentsdb]] |
enabled |
false |
Enable the OpenTSDB listener |
bind-address |
":4242" |
Listener address | |
database |
"opentsdb" |
Target database | |
retention-policy |
"" |
Target retention policy | |
consistency-level |
"one" |
Write consistency level | |
tls-enabled |
false |
Serve TLS | |
certificate |
"/etc/ssl/influxdb.pem" |
TLS certificate | |
private-key |
"" |
TLS key; empty = combined with the certificate | |
insecure-certificate |
false |
Allow insecure certificate file permissions | |
ignore-cert-sanity-checks |
false |
Load certificates failing sanity checks | |
client-auth-type |
unset | Mutual TLS client-auth mode | |
client-ca |
unset | Client CA bundle: { paths = [...], include-system = false } |
|
log-point-errors |
true |
Log every malformed point | |
batch-size |
1000 |
Points buffered before flush (telnet only) | |
batch-pending |
5 |
Pending batches | |
batch-timeout |
"1s" |
Maximum time before a flush | |
[[udp]] |
enabled |
false |
Enable the line-protocol UDP listener |
bind-address |
":8089" |
Listener address | |
database |
"udp" |
Target database | |
retention-policy |
"" |
Target retention policy | |
precision |
"n" |
Input precision: "", n, u, ms, s, m, h |
|
batch-size |
5000 |
Points buffered before flush | |
batch-pending |
10 |
Pending batches | |
batch-timeout |
"1s" |
Maximum time before a flush | |
read-buffer |
0 |
UDP read buffer (0 = OS default) |
|
writers |
1 |
Parallel writers |
[continuous_queries]
| Key | Default | Description |
|---|---|---|
enabled |
true |
Run continuous queries |
log-enabled |
true |
Log CQ executions |
query-stats-enabled |
false |
Write CQ stats to the monitoring store |
run-interval |
"1s" |
How often CQs are checked |
[tls]
| Key | Default | Description |
|---|---|---|
ciphers |
Go defaults | Allowed cipher suites (Go names) |
min-version |
Go default | Minimum TLS version, e.g. "tls1.2" |
max-version |
Go default | Maximum TLS version, e.g. "tls1.3" |
HTTPS on the addon: pgcli never enables
[http] https-enabled. Use the nginx addon for TLS termination, or expose only loopback. The[tls]section only tunes cipher/version negotiation for listeners that are already TLS-enabled.
12.30 - Pg_exporter
pg addon install pgexporter runs pg_exporter — Pigsty’s PostgreSQL metrics exporter — as a managed container from the official docker.io/pgsty/pg_exporter image. Where the node_exporter addon describes the machine, pg_exporter describes a database: it connects to a PostgreSQL server and publishes ~460 pg_* metrics (activity, locks, replication, bloat, WAL, per-database stats…) at /metrics. Like Prometheus, VictoriaMetrics, Grafana and node_exporter it is a top-level addon: it is attached to no instance of its own — instead it points at a target it monitors.
Its reason to exist alongside node_exporter is unified collection: the target can be a pgcli-managed instance or any reachable PostgreSQL server pgcli does not manage. That is the whole point — one exporter shape collects metrics from databases inside and outside pgcli alike.
Three target modes, mutually exclusive — pass exactly one.
--pg-name <inst>monitors a pgcli-managed instance; its admin DSN is re-derived frompg.yamlon every start (so a rotated password or changed port never goes stale). For a server pgcli does not manage, the target is given as ONE URL — and which URL you pass decides whether pgcli touches the database:
--admin-dsn postgres://…— an admin-privileged URL. pgcli uses it once to create a low-privilege monitor role on the target, then discards it: only its credential-free address (host/port/database) is stored, and host/port/database for the exporter come from it. The exporter thereafter connects as the monitor role.--dsn postgres://…— dial-as-is. The exporter connects with exactly this URL’s own credentials and pgcli runs no SQL on the target. Use it when a suitable role already exists, or when pgcli must not touch the database at all.
--dsnand--admin-dsnnever appear together — there’s no second URL to give, because the target’s host/port/database come from whichever one you pass.
The exporter never dials as an admin in the local and
--admin-dsnmodes: pgcli creates the low-privilege monitor role (apg_monitormember,CONNECTION LIMIT 5) and hands the container that role’s URL. To create it, pgcli needs admin rights once: for--pg-namethe admin credentials are derived automatically; for--admin-dsnyou supply an admin URL that runs the singleCREATE ROLE/GRANTbatch and is never stored inpg.yaml. The generated monitor password is printed once and retrievable viapg addon password pgexporter. In the dial-as-is--dsnmode pgcli creates nothing — it is your job (or a prior--admin-dsninstall) that the role already exists.
There is NO authentication and no TLS on the exporter endpoint, and pg_exporter also serves admin endpoints (
/reload,/explain,/stat) on the same port — anyone who can reach it can scrape metrics and trigger reloads. Because a Prometheus scraping it is usually on another host,Listendefaults to0.0.0.0; the only protection is the network boundary. Restrict it to your monitoring network, or--listen 127.0.0.1for a same-host Prometheus.
Platform: Linux is the tested platform (host networking, so the exporter binds the host port directly and can dial a local instance’s
127.0.0.1target). On macOS the container runs on the pgcli-net bridge and cannot reach a local instance’s127.0.0.1DSN — there, use--dsnpointed at a reachable address (the remote path works; the local path does not).
Parameters
| Flag | Default | Description |
|---|---|---|
--name |
pgexporter |
Instance name (map key) |
--pg-name |
— | Monitor a pgcli-managed instance by name (admin DSN re-derived each start; mutually exclusive with --dsn and --admin-dsn) |
--admin-dsn |
— | Remote, create-role mode: an admin-privileged URL for a server pgcli does not manage. Used once to create the monitor role, then discarded — only its credential-free address (host/port/database) is stored, and never in a form carrying the admin password. The exporter connects as the monitor role. Mutually exclusive with --dsn and --pg-name |
--dsn |
— | Remote, dial-as-is mode: the exporter connects with THIS URL’s own credentials and pgcli runs no SQL on the target — for a role that already exists or a database pgcli must not touch. Mutually exclusive with --admin-dsn and --pg-name |
--monitor-user |
pgexporter |
Name of the low-privilege role pgcli creates (pg_monitor member; plain identifier only). Use a distinct value per instance when several exporters monitor the same target |
--image |
docker.io/pgsty/pg_exporter:1.5.0 |
Image override |
--port |
auto (base 9630) | Host port; auto-allocated from pgexporter_start_port |
--listen |
0.0.0.0 |
Bind address — every interface by default (cross-host Prometheus); use 127.0.0.1 for loopback-only |
--config-file |
— | Path to a custom pg_exporter collector YAML, mounted read-only at /etc/pg_exporter.yml (the binary’s baked-in default set is used when unset) |
--extra-arg |
— | Extra CLI flag passed to the binary (repeatable); appended last so last-wins overrides earlier flags. E.g. --extra-arg --auto-discovery=false |
--force |
false | Recreate even when running — to apply changed port / listen / target / extra-args |
pg_exporter is stateless — it stores nothing, so there is no data directory. pg addon remove deletes only the container (the monitor role on the target is kept, since it may be shared or hand-managed); reinstalling always starts fresh.
Install
Monitor a pgcli-managed instance (the monitor role is created automatically):
The command derives the local admin DSN, creates the monitor role on demo, pulls the image if needed, starts the container pointed at the monitor URL, and prints the scrape info:
Monitor a database outside pgcli’s management, letting pgcli create the monitor role — pass an admin URL alone; the target’s host/port/database come from it:
The --admin-dsn runs the one-time CREATE ROLE/GRANT pg_monitor batch and is then discarded — only the credential-free address postgres://db.example.com:5432/app is kept in pg.yaml. Thereafter the exporter connects only as pgexporter, and pg addon password pgexporter --name ext returns that role’s password.
If the monitor role already exists and you’d rather pgcli not touch the target at all, pass a --dsn with usable credentials instead (mutually exclusive with --admin-dsn — you give one URL, not two):
Here pgcli runs no SQL: the exporter dials exactly this URL with its own credentials.
(To create the role by hand instead, the minimum is CREATE ROLE pgexporter LOGIN CONNECTION LIMIT 5 PASSWORD '…'; GRANT pg_monitor TO pgexporter; — plus GRANT SELECT (oid) ON pg_subscription on PostgreSQL 10–13.)
Verify the exporter is serving real numbers for the target:
pg_up 1 means the monitor role connected. Cross-check pg_version_info against pg exec demo -c "SHOW server_version" — and confirm the role landed on the target with pg exec demo -c "\du pgexporter" (or query pg_roles / pg_auth_members for the pg_monitor grant). With the default --auto-discovery, metrics carry a datname label per database on the target.
Repeated installs and one monitor user
The monitor-role SQL is idempotent, so re-running install never damages the target’s role: a plain reinstall on a running, unchanged instance is a no-op (no SQL runs), and a --force reinstall ALTERs the existing role — its OID and grants survive and the pg_monitor membership is never duplicated.
What a --force / config-changed reinstall does do is rotate the generated password (that instance stays fine — the new password is stored and injected together — but it overwrites a password you may have set by hand or reused; pg addon password pgexporter retrieves the current one). Because every such install writes a fresh password onto the role, two exporter instances sharing one target and the default pgexporter user collide: installing the second rotates the shared role’s password, and the first keeps its now-stale one — its open pool works, but the next reconnect fails and it drops to pg_up 0. When you run more than one exporter against the same target, give each its own role:
Wire it into Prometheus
The exporter is only useful once something scrapes it. A pgcli Prometheus instance does, via a one-line --scrape job:
To add this exporter to a pgcli Prometheus that is already running and already scraping other jobs, use --scrape-append — it folds the pg job into the stored scrape list (merging same-named jobs, appending unseen ones) and SIGHUP-reloads, so it never wipes the existing targets:
(A plain --scrape replaces the whole job set, so re-running install with it would drop any other jobs p1 was scraping.) Then in Prometheus, up{job="pg"} is 1 and count(pg_activity_count) is positive. See the Prometheus addon for the full --scrape / --scrape-append mechanics.
Reuse the health probes
pg_exporter’s /primary and /replica return 200 only when the target is in that role, so they double as HAProxy-style liveness probes for read/write routing — see the HAProxy addon for wiring a pgcli HAProxy to point-check a PostgreSQL. /up (target reachable at all) and /read are the other probes.
Re-running install on a running instance is a no-op without --force (config fields you pass are saved; the container keeps serving). With --force the container is recreated — and the role SQL re-runs (rotating the monitor password) in --pg-name mode, or in --admin-dsn mode only when a fresh --admin-dsn is passed (without one, the stored monitor password is reused, since pgcli deliberately kept no admin rights). A --dsn install never runs SQL at all. Changing port / listen / extra-args needs --force (container recreation) — only the collector config is hot-reloadable (see below).
Custom collectors (--config-file) and reload
pg_exporter ships a full default collector set baked into the binary and plans it at runtime by server version / role / extensions, so no config file is needed to get useful metrics — most installs omit --config-file. When you do want a custom set, point --config-file at a pg_exporter collector YAML; it is mounted read-only at /etc/pg_exporter.yml and selected via PG_EXPORTER_CONFIG. After editing the file on the host, hot-reload it in place with SIGHUP (no container restart):
Reloading an instance started without --config-file is a harmless no-op — pgcli tells you so (the built-in default set has nothing on disk to re-read). URL / port / listen changes are not covered by SIGHUP; recreate with install --force.
Custom flags (--extra-arg)
pg_exporter has a rich flag surface beyond the env-configured URL. Toggle features via --extra-arg (repeatable):
Extra args are appended last to the command line, so they override pgcli’s injected flags via last-wins. Common ones: --auto-discovery=false (stop adding a datname per database), --exclude-database (skip databases), --tag (add constant labels), --namespace (prefix every metric). After --force, confirm with curl -s http://127.0.0.1:9630/metrics that a --auto-discovery=false target reports only the postgres database’s labels.
Listening and exposure
Listen defaults to 0.0.0.0: on Linux the container shares the host network namespace, so /metrics is reachable on every interface at the assigned port. This is deliberate — a Prometheus scraping it is usually on another machine. But there is no authentication, and pg_exporter also serves /reload, /explain and /stat on the same port, so anyone who can reach it can read config and trigger reloads, not just scrape. For a same-host Prometheus pass --listen 127.0.0.1 (recreate with --force); otherwise restrict the port at the firewall. (Under the macOS bridge build, pgcli widens the bind to 0.0.0.0 automatically so the published port stays reachable through the podman VM.)
Ports
Each pg_exporter instance owns a single port — the metrics endpoint — allocated from a pool with base pgexporter_start_port (default 9630, pg_exporter’s conventional port), incrementing for each additional instance and skipping ports taken by any managed container or used_ports. The allocation is saved to pg.yaml, so a reinstall never re-hands-out a port another addon holds. 9630 shares no number with node_exporter (9100), Prometheus (9090), VictoriaMetrics (8428), Grafana (3000) or Alertmanager (9093); an explicit --port still wins.
(Each extra instance on the same target needs its own --monitor-user — see Repeated installs and one monitor user.)
Configuration
The three target fields are mutually exclusive — set exactly one: target_instance (a pgcli-managed instance, DSN re-derived every start), admin_dsn (a remote server pgcli created a monitor role on — pgcli keeps only its credential-free address; the admin URL you passed is never persisted), or dsn (a remote server dialed as given, stored verbatim with its own credentials, and no role SQL ever runs). monitor_dsn / monitor_password are what pgcli generates and injects into the container (absent in dsn mode, where the exporter dials the dsn itself). extra_args is a list of flags appended last; config_file is an optional host path mounted read-only.
List
The Target line is redacted in remote mode (the DSN’s password never appears). The monitor password is not shown — retrieve it with pg addon password pgexporter.
Password
This is the password of the pgexporter role the exporter connects with — not a PostgreSQL admin password.
Start and stop
Containers run with podman’s unless-stopped restart policy, so they come back with podman after a host reboot. start/stop manage them explicitly:
start re-derives the monitor DSN first (for a --pg-name target, so a rotated instance password or changed port takes effect on recreation).
Auto-start on Boot
This flags the instance in pg.yaml and installs/refreshes a boot service (a systemd --user unit on Linux, a LaunchAgent on macOS) that runs pg start --autostart. A boot-time start only brings the container up with the stored monitor credentials — it does not re-run the role-creation SQL (that happens at install). See Autostart for the full mechanism, status and disabling.
Remove
pg_exporter keeps nothing on disk, so --clean-data has no effect beyond an informational note. The monitor role created on the target is not dropped — it may be shared or hand-managed. To remove it yourself, first revoke its per-database grant (a bare DROP ROLE fails while the pg_subscription column grant exists in any database it touched): REVOKE SELECT (oid) ON pg_catalog.pg_subscription FROM "pgexporter"; in each monitored database, then DROP ROLE "pgexporter";.
Logs
Troubleshooting
pg_up 0/ nopg_*metrics — the monitor role can’t connect. Check/upreturns 200, that the target’spg_hba.confadmits the container’s source address, and that the stored password matches the role (pg addon password pgexporter; if they diverged, reinstall to rotate it). On a--dsntarget, confirm the DSN’s host/port is reachable from the exporter host (a127.0.0.1DSN means the target is local to the exporter).pg_upwent 0 right after installing a second exporter on the same target — both share the defaultpgexporterrole, and the second install rotated its password, staling the first. Recreate the first (install … --force) to re-sync it, or give each instance its own--monitor-user(see Repeated installs and one monitor user).- Metrics present but thin/empty — a missing
GRANT pg_monitoryields a connected exporter with partial collectors. Reinstall (the role setup is idempotent) and confirm membership:pg exec <inst> -c "SELECT r.rolname, m.rolname FROM pg_auth_members a JOIN pg_roles r ON r.oid=a.member JOIN pg_roles m ON m.oid=a.roleid WHERE r.rolname='pgexporter'". - Prometheus shows the target DOWN — the exporter is running (
pg addon list), but the scrape address/port don’t match (--scrape pg=<host>:<port>), or a loopback--listen 127.0.0.1exporter is being scraped from another host. --dsn and --admin-dsn are mutually exclusive— you passed two remote URLs; give exactly one:--admin-dsnto have pgcli create the monitor role (its host/port/database become the target), or--dsnto dial a URL with credentials you already manage.has an admin_dsn target but no stored monitor password— a--forcereinstall of an--admin-dsninstance after the secret was lost (never stored by pgcli). Re-pass--admin-dsn postgres://…so pgcli can create the role again.- A flag / discovery change didn’t take effect —
--extra-argand target/URL changes apply at container creation; reinstall with--force. Only the collector config (--config-file) reloads via SIGHUP. - macOS local mode serves nothing — a bridge container can’t reach the host’s
127.0.0.1PG; use--dsnat a reachable address on Mac. - Port already allocated — free it with
pg addon removefirst, or pick another with--port N.
Known limitations
- No authentication, no TLS, and admin endpoints exposed. pg_exporter serves plain-text
/metricsplus/reload,/explain,/stat; the only protection for a0.0.0.0bind is the network boundary. Front with nginx or restrict by firewall if it must leave a trusted network. - The monitor DSN (with its password) is stored in
pg.yaml. That is the low-privilegepg_monitorrole’s credential, not an admin one; the--admin-dsnused to create it is never persisted. Protectpg.yamlaccordingly. - The official image is
scratch-based — it carries no system CA bundle, so a TLS (sslmode=require)--dsnto an external server may fail certificate verification; supply trust via the image or connect over a trusted network. - Stateless — no data dir, no retention; nothing survives beyond the running process. Reinstall always starts fresh, and the target-side monitor role is never auto-dropped.
- macOS local-instance mode is not usable (bridge container can’t reach host loopback); remote
--dsnmode works there. Linux is the tested platform. - pgcli does not validate
--extra-argvalues; a bad flag surfaces only in the container logs on startup.
12.31 - influx2tsdb
pg addon install influx2tsdb runs influx2tsdb-proxy 1.3.2 — an InfluxDB 1.x
HTTP API adapter (Line Protocol writes and InfluxQL queries) that stores data in
PostgreSQL with the TimescaleDB extension — as a managed container from pgcli’s own
ghcr.io/mars-base/pgcli/pgcli-influx2tsdb image. Unlike the InfluxDB addon,
there is no TSM engine: every point lives in a TimescaleDB hypertable, and the proxy
itself is stateless.
The add-on is Linux/amd64 only (the image is single-arch, built from the upstream
release binary on debian:12-slim). The proxy container can be recreated at any time;
the data survives because PostgreSQL holds it.
The HTTP API has no authentication. The upstream proxy speaks plain HTTP, binds all interfaces, and does not check InfluxDB credentials. Use the nginx addon for TLS and access control, or firewall the port. The endpoint cannot be used safely on an untrusted network.
When to use it — and what you give up
Native InfluxDB 1.x keeps time-series data in its own in-memory index + TSM files and serves InfluxQL directly. influx2tsdb instead translates every request into SQL and stores rows in PostgreSQL/TimescaleDB. That translation and a relational storage engine cost throughput and latency: for pure time-series ingest and query load, native InfluxDB is faster than this proxy. The gap grows on large aggregate scans and high-cardinality workloads.
The trade-off is that time-series data becomes ordinary database data:
- ACID transactions and consistency. Points land in the same transactional store as relational data; a write batch commits or fails atomically, and time-series can be joined with business tables in one query.
- SQL ecosystem. TimescaleDB exposes hypertables as tables: joins, views, CTEs, window functions, ORMs and BI tools all work. InfluxQL is only the ingestion/query front end, not a walled garden.
- Mature backup, PITR, replication and HA.
pg_dump/pgBackRest, WAL archiving, streaming replicas, Patroni and the rest of PostgreSQL operations apply unchanged — you do not run a second storage system and a second DR process. - One set of operations, auth and audit. Users, roles, TLS, monitoring and compliance controls are PostgreSQL’s; there is no second database to harden.
- Flexible lifecycle. Retention and compression are native TimescaleDB policies
(below), and you can still
UPDATE/DELETEarbitrary rows, migrate schemas and reshape tables like any SQL data. - Reuse of existing PostgreSQL infrastructure and DBA skills. If PostgreSQL is already operated professionally, the marginal cost of a time-series workload is low.
Pick the InfluxDB addon when InfluxQL/TSM performance and a faithful InfluxDB 1.x behavior model matter most; pick influx2tsdb when keeping everything in PostgreSQL is the priority.
Prerequisites
-
A PostgreSQL database with the TimescaleDB extension. The proxy tries
CREATE EXTENSION IF NOT EXISTS timescaledbon startup, but the extension package must be installed in the instance. On a pgcli instance: -
The target must be running before the proxy starts. A local target is checked up front (
pg start -i tsdb); the proxy exits immediately if PostgreSQL is unreachable.
Install
Local target — the DSN is derived from the pgcli instance on every start, so a changed port or password stays in sync:
Remote or unmanaged target — pass the full DSN:
The DSN (including its password) is stored in pg.yaml and is part of the container
command line, so it is visible via podman inspect; protect the config file and
prefer a dedicated least-privilege PostgreSQL role.
Second instances take the next free port from influx2tsdb_start_port (default
8087):
Re-running install without --force is a no-op. --force rewrites the target/port
settings and recreates the container; data is untouched because the proxy has none.
Parameters
| Flag | Default | Description |
|---|---|---|
--name |
influx2tsdb |
Instance name (config map key) |
--pg-name |
— | Resolve the target from a locally managed pgcli instance; mutually exclusive with --dsn |
--dsn |
— | Explicit PostgreSQL/TimescaleDB DSN for a remote or unmanaged server |
--database |
target DB name | Default InfluxDB database (the proxy’s -db); becomes a PostgreSQL schema |
--port |
auto (base 8087) | InfluxDB-protocol HTTP host port; 0 = auto-assign |
--pool-size |
10 |
PostgreSQL connection pool size (-pool) |
--verbose |
false | Log translated SQL and query details (-verbose) |
--version |
1.3.2 |
Pinned image release |
--force |
false | Rewrite the settings and recreate the stateless container |
There is no --data-dir and no --listen: the proxy keeps no local state, uses host
networking, and always binds all interfaces.
Data model
Each InfluxDB database maps to a PostgreSQL schema inside the target database:
CREATE DATABASE "demo"→CREATE SCHEMA "demo"- a measurement becomes a TimescaleDB hypertable in that schema
- tags become text columns; fields become typed columns;
timeis the hypertable’s partition column - proxy metadata lives in the target’s
publicschema:_influx_databases,_influx_meta,_retention_policy
Because the data is plain PostgreSQL, you can inspect and join it with pg exec:
Write and query
The API follows InfluxDB 1.x. Writes return 204; malformed line protocol returns
400.
Common statements supported by the current release:
| Category | Statements |
|---|---|
| Database | CREATE DATABASE, DROP DATABASE, SHOW DATABASES |
| Retention policy | CREATE / ALTER / DROP / SHOW RETENTION POLICIES |
| Write / delete | line protocol writes, DELETE FROM, DROP MEASUREMENT |
| Metadata | SHOW MEASUREMENTS / TAG KEYS / TAG VALUES / FIELD KEYS (the last three need FROM <measurement>) |
| Query | SELECT with tag/time filters, mean/sum/min/max/count, first/last, GROUP BY time(...) and tags, subqueries |
Using the InfluxDB 1.x command-line client
The official influx CLI (1.13.1) works directly against the proxy. pgcli runs
it in a short-lived container; use remote mode with the proxy’s host and port
(the proxy has no auth, so no credentials are needed):
Databases and retention policies:
Writes (line protocol):
Queries and metadata:
Deletes and output formats:
Bulk import (influx 1.x import format):
Verified working: database and retention-policy DDL, INSERT (line protocol),
SELECT (including GROUP BY time(...), tag/time filters and -format column/csv/json), SHOW MEASUREMENTS, SHOW TAG KEYS / FIELD KEYS / TAG VALUES with FROM, DELETE, DROP MEASUREMENT, and -import.
Differences from native InfluxDB:
- Metadata commands require
FROM <measurement>:SHOW TAG KEYS,SHOW FIELD KEYSandSHOW TAG VALUESwithoutFROMreturnmissing FROM clause(native InfluxDB makes it optional);WITH KEY =~ /regex/is not supported either. SHOW SERIESis not supported (unsupported query: SHOW SERIES).- A default retention policy cannot be dropped directly (same as native
InfluxDB); set another policy as
DEFAULTfirst. -precision ns/u/ms/s/m/hall work;rfc3339does not and still prints nanosecond integers.
Time precision caveats
-
Timestamps are always nanoseconds. In E2E testing the current release ignored the
precision=query parameter on/write; send nanosecond timestamps (the InfluxDB default) to avoid shifted data. -
Both time-literal forms work. In testing,
WHERE time ...accepted both'YYYY-MM-DD HH:MM:SS'and RFC3339 literals ('2023-11-14T22:13:20Z', including milliseconds or a+08:00offset), and Grafana’s interpolated$timeFilterepoch-millisecond form (for example1793083539315ms) worked too: -
now() - 1d-style expressions work.
Known upstream issue: DROP MEASUREMENT metadata cache
After DROP MEASUREMENT, the proxy deletes the measurement’s rows in _influx_meta
but keeps its in-memory schema cache. Re-creating the same measurement name then
does not reinsert metadata, so SHOW MEASUREMENTS / SHOW TAG KEYS /
SHOW FIELD KEYS can go empty for it while SELECT still works. Use unique
measurement names in automation, or restart the proxy after dropping and recreating a
measurement. The bundled test script does the former.
Grafana integration
Grafana’s built-in InfluxDB data source works directly against this proxy (verified end-to-end on Grafana 13.2.4): choose InfluxDB as the data source type and InfluxQL as the query language (not Flux), point the URL at the proxy, and Grafana reads the underlying TimescaleDB hypertables.
| Field | Value |
|---|---|
| Type | InfluxDB |
| Query Language | InfluxQL |
| URL | http://<proxy-host>:8087 |
| Database | tsdb_db (or your default database) |
| User / Password | empty |
| HTTP Method | GET (default) |
The data source’s “Save & test” health check passes; queries generated by the
Grafana query editor and panels ($timeFilter, epoch=ms, fill(null),
GROUP BY time) returned correct series in testing, and SHOW MEASUREMENTS / TAG KEYS / TAG VALUES / FIELD KEYS are supported, so the measurement/tag/field
selectors in the query builder work.
Notes:
- The proxy has no auth and no TLS; use the nginx addon in front when Grafana and the proxy are not on a trusted network.
- Only the InfluxQL subset is supported; unsupported expressions may error or return empty results, and Flux is not available (it needs the InfluxDB v2 API).
- The proxy ignores the write
precision=parameter; Grafana reads withepoch=ms, so display is unaffected.
Provisioning through the Grafana HTTP API
You do not need the web UI for automation: Grafana’s HTTP API can create the data source and verify the full “write through the proxy, read through the Grafana API” path (the commands below were verified on Grafana 13.2.4).
The write returns 204; step 4 returns the last(value) series with the time
column in epoch milliseconds, matching epoch=ms.
Notes:
access: "proxy"makes Grafana forward the query server-side; the browser never talks to the proxy directly, so CORS does not apply.version: "InfluxQL"andhttpMode: "GET"injsonDatamatch the mode panels use, so set them explicitly at creation time.- Delete the data source with
curl -u "admin:$GRAFANA_PW" -X DELETE "$G/api/datasources/uid/$UID"(returns200).
Retention policies, compression and TimescaleDB jobs
CREATE / ALTER / DROP RETENTION POLICY is supported with InfluxDB semantics
(per-database, one default policy, minimum duration 15m, INF/0s = infinite).
At the storage layer the proxy immediately calls TimescaleDB’s own policy functions
for every hypertable in that database’s schema:
The cleanup itself is automatic and needs no pg_cron. The jobs run inside
TimescaleDB’s own background-job scheduler and are visible in
timescaledb_information.jobs:
A 1-day policy produces a job like:
Operational details:
- The proxy is not the scheduler. TimescaleDB executes the jobs; stopping or removing the proxy container does not stop retention while PostgreSQL is running. Conversely, if the proxy is down when a new hypertable is created, its policies are applied on the next sync.
- Sync cadence.
CREATE / ALTER / DROP RETENTION POLICYtriggers an immediate sync; a background loop re-applies policies to all hypertables every 5 minutes (for new tables and for DDL skipped on a lock timeout). - Job cadence. The proxy requests a schedule interval: retention every 5 minutes
when the duration is under 24h, otherwise hourly; compression every minute under
24h, otherwise hourly. Note the upstream 1.3.2 quirk: the interval is chosen by
scanning policies across all databases in map order, so with multiple databases
the effective interval can differ from the derivation (a 1h retention policy was
observed scheduled hourly). Read
schedule_intervalfromtimescaledb_information.jobswhen exact cadence matters; every sync re-applies the derived interval. - Only the default policy is enforced. All hypertables in the schema use the
database’s default retention duration (or the first finite policy if none is
default). Non-default policies are metadata; the proxy does not split data into
per-policy schemas or apply measurement-specific retention. With no finite policy
(or an
INFdefault) data grows without bound. - Deletion is chunk-granular. TimescaleDB drops whole chunks, so expired data may
linger for up to one chunk interval; steady-state size is roughly “retention
duration + at most one chunk interval” and stops growing after that. The proxy
derives the chunk interval from
SHARD DURATIONor from the retention duration (1h minimum, retention/24, capped at 1 day). - Changing a policy to
INFremoves the TimescaleDB retention job at the next sync; data then stops expiring. - Compression is applied automatically. Tags become
compress_segmentby, time descending becomescompress_orderby, andcompress_afteris derived from the retention duration (retention/4, capped at 1 day;15mminimum).
Verify from PostgreSQL:
Security and exposure
- The proxy has no credentials; anyone who can reach the port can read and write every database exposed by the target DSN.
- Bind it to a trusted network only, or put the nginx addon in front for TLS and IP/basic-auth restrictions. The proxy port itself has no TLS.
- The PostgreSQL password appears in
pg.yamland in the container’s argv. Use a dedicated role limited to the target database, and protect the config file.
Lifecycle and autostart
remove never deletes data: it lives in PostgreSQL. --clean-data is accepted for
consistency but has nothing to delete.
Autostart recreates the stateless proxy at boot. For a local target, pgcli starts the instance first and then re-derives the DSN:
Backup, restore and HA
There is nothing to back up on the proxy side. Use PostgreSQL’s normal tooling:
pg backup <instance>/ pgBackRest /pg_dump— all schemas, hypertables and thepublic._influx_*metadata tables are included.- PITR / WAL archiving / streaming replicas / Patroni — work unchanged. On a failover
or move, point the proxy at the new primary (
--dsn) and recreate it, or, for a local instance, restart it after the instance’s DSN changes.
The proxy container is disposable: pg addon install influx2tsdb --force rebuilds it
from the same target.
Test script
test/addon/test_influx2tsdb.sh exercises the common time-series operations: ping,
single/batch writes with tags and field types, count, time-range and tag filters,
mean/sum/min/max, GROUP BY time() and tags, first/last, SHOW metadata,
optional PostgreSQL row-count/hypertable checks, DELETE, DROP MEASUREMENT,
database lifecycle, retention-policy CRUD, and malformed-write handling.
The script uses unique per-run measurement and database names because of the
DROP MEASUREMENT metadata-cache issue described above.
test/addon/test_influx2tsdb_retention.sh focuses on retention automation and
covers:
- the automatic
policy_retention/policy_compressionjobs, withdrop_after/compress_aftermatching the derived values; CALL run_job(<id>)actually dropping expired chunks while recent data survives;- expired rows inserted directly into PostgreSQL while the proxy is stopped still being deleted (proving the schedule does not depend on the proxy);
- switching the default policy to
INFremoving the retention job while the compression job stays and falls back to 1 day; DROP DATABASEcleaning up all jobs for that schema;- asserting that
pg_cronis not installed.
The script reaches PostgreSQL through pg exec, so it needs --instance or
--pg-dsn, and it stops and restarts the local proxy during the run (unless
--skip-proxy-stop).
Limitations
- Linux/amd64 only.
- No authentication and no TLS in the proxy; front it with nginx or isolate it.
- InfluxQL subset. The release implements the statements listed above, not the
full InfluxQL language; for example,
SHOW SERIESis not supported andSHOW TAG KEYS / FIELD KEYS / TAG VALUESrequireFROM. Unsupported constructs return an error or an empty result. Flux is not available. - Timestamps in nanoseconds; the
precision=write parameter is ignored by the current release. Query output precision followsepoch=ms, which Grafana sends. - Per-database retention only. One default policy applies to every hypertable in the schema.
- Performance is below native InfluxDB for time-series workloads (see above).
- The proxy stores its metadata tables (
_influx_databases,_influx_meta,_retention_policy) in the target database’spublicschema.
CLI flag reference
pg addon install influx2tsdb
| Flag | Default | Description |
|---|---|---|
--name NAME |
influx2tsdb |
Instance name |
--pg-name NAME |
— | Local pgcli instance as the target; exclusive with --dsn |
--dsn DSN |
— | postgres://user:pass@host:port/db target; exclusive with --pg-name |
--database DB |
target DB name | Default InfluxDB database (-db) |
--port N |
auto (base 8087) |
HTTP host port; 0 = auto-assign |
--pool-size N |
10 |
PostgreSQL pool size |
--verbose |
false | Log translated SQL |
--version V |
1.3.2 |
Pinned image release |
--force |
false | Rewrite settings and recreate the container |
pg addon start|stop|remove influx2tsdb
| Command | Flags | Description |
|---|---|---|
pg addon start influx2tsdb |
--name NAME |
Start (recreate) the container; checks a local target first |
pg addon stop influx2tsdb |
--name NAME |
Stop the container |
pg addon remove influx2tsdb |
--name NAME, --clean-data |
Remove container and config entry; data stays in PostgreSQL |
pg addon influx2tsdb status
| Flag | Default | Description |
|---|---|---|
--name NAME |
influx2tsdb |
Instance to inspect (state, ping, target, database, port) |
pg logs addon influx2tsdb
| Flag | Default | Description |
|---|---|---|
--name NAME |
influx2tsdb |
Instance whose container logs to show |
-n N, --tail N |
50 |
Number of lines |
-f, --follow |
false | Follow |
pg autostart enable|disable
| Flag | Description |
|---|---|
--influx2tsdb |
Select the proxy add-on |
--name NAME |
Instance to toggle (default influx2tsdb) |
Global flags
| Flag | Description |
|---|---|
-c, --config FILE |
Config file (default ~/.pgcli/pg.yaml) |
-o, --output FILE |
Output config path |
-i, --instance NAME |
PostgreSQL instance selector — not used here; use --name |
12.32 - ClickHouse
pg addon install clickhouse runs ClickHouse 26.8 LTS — a single-node columnar OLAP database — as a managed container from the official docker.io/clickhouse/clickhouse-server multi-arch image. Like VictoriaMetrics and InfluxDB, it is a top-level addon: it does not front a PostgreSQL instance.
ClickHouse speaks two protocols on two ports: HTTP (curl, JDBC/ODBC, many drivers) and the native TCP protocol (clickhouse-client). pgcli runs it with host networking, so both are plain host ports. Authentication is always enabled — the install creates an admin account and stores its password in pg.yaml.
The admin password is generated at install time and can be read back at any time with
pg addon password clickhouse [--name NAME]. The HTTP and native listeners require authentication on every request; there is no anonymous access.--listendefaults to127.0.0.1— pass0.0.0.0explicitly when other hosts must connect, and front it with nginx for TLS.
Parameters
| Flag | Default | Description |
|---|---|---|
--name |
clickhouse |
Instance name (map key; also the default subcommand name) |
--version |
26.8.36.3 |
Upstream image tag, pulled directly from docker.io/clickhouse/clickhouse-server |
--data-dir |
<base_dir>/addon/clickhouse/<name>/data |
Data directory bind-mounted at /var/lib/clickhouse; must be an absolute path |
--listen |
127.0.0.1 |
Bind address for the HTTP and native listeners. 0.0.0.0 exposes both on the network |
--http-port |
auto (base 8123) |
Host HTTP port; 0 = auto-assign from clickhouse_http_start_port |
--tcp-port |
auto (base 9000) |
Host native-protocol port; 0 = auto-assign from clickhouse_tcp_start_port (shared with the S3 API pool) |
--admin-user |
admin |
Admin account created by the image entrypoint |
--admin-password |
generated | Admin password. On --force the stored password is kept unless this is set |
--config-option K=V |
— | Extra server setting as a dotted XML path (logger.level=debug, max_server_memory_usage=2147483648); repeatable; replaces the stored override map |
--force |
false | Re-render the config and secret, then recreate the container; keeps data, the stored password and the stored overrides |
--image does not apply to ClickHouse — use --version.
The generated config only pins listeners, ports, logging and the backup disk; every other server setting keeps the upstream default. Overrides are stored in pg.yaml under addons.clickhouse.<name>.settings and rendered into the generated XML, so they survive restarts and container recreation.
Install
A second instance takes the next free HTTP/TCP ports from its pools (for example 8124/9001) and can use a custom data dir:
Re-running install without --force is a no-op that reports the current state. --force re-renders the config, recreates the container and keeps the data dir, the stored admin password and the stored settings; pass --config-option only when you want to replace the override map.
The image is pulled directly from Docker Hub; no pgcli-built image is involved. --version accepts any safe upstream tag, but the tag must exist in the registry — the pull fails otherwise. pgcli pins the current LTS line (26.8.36.3) by default.
Ports
| Endpoint | Default | Protocol | Pool |
|---|---|---|---|
| HTTP | 8123 |
HTTP (queries, /ping, many drivers) |
clickhouse_http_start_port, default 8123 |
| Native | 9000 |
ClickHouse native TCP (clickhouse-client) |
clickhouse_tcp_start_port, default 9000, shared with the S3 API pool |
Both pools share one “already assigned” set, so an explicit port in one pool cannot collide with an auto-assigned port in the other. --http-port and --tcp-port must differ, and every HTTP/TCP port must be unique across all ClickHouse instances. Ports already listening on the host are skipped during auto-assignment.
When --listen is a wildcard (0.0.0.0, ::), pgcli’s own client commands dial 127.0.0.1; the printed endpoints follow the same rule. A LAN --listen bind also keeps loopback enabled inside the container so the in-container clickhouse-client always works.
What pgcli creates
Everything for one instance lives under <base_dir>/addon/clickhouse/<name>/:
| Host path | Container path | Notes |
|---|---|---|
config.d/ |
/etc/clickhouse-server/config.d |
Read-only; replaces the image’s stock config.d (which would otherwise bind every interface). zz-pgcli.xml is the generated override |
data/ |
/var/lib/clickhouse |
Database data and system tables |
logs/ |
/var/log/clickhouse-server |
clickhouse-server.log and clickhouse-server.err.log (also visible via podman logs) |
backups/ |
/var/lib/clickhouse-backups |
The managed backups disk used by backup/restore |
secrets/admin_password |
/run/secrets/admin_password |
Mode 0600, read by the entrypoint via CLICKHOUSE_PASSWORD_FILE |
The generated zz-pgcli.xml sets:
listen_host(exactly what--listenchose) pluslisten_tryhttp_port/tcp_portlogger.level,logger.log,logger.errorlog,logger.console, rotation (size,count)- a local disk named
backupsat/var/lib/clickhouse-backups, allowed forBACKUP/RESTORE
The container runs with --network host, --restart unless-stopped, a 30-second stop timeout and nofile=262144. Data ownership inside the container is uid/gid 101 (the image drops privileges after its root entrypoint chowns the mounts).
Credentials
The image entrypoint creates admin (or the name given with --admin-user) with full privileges and access management enabled (CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1), so CREATE USER / GRANT issued by this CLI work. The password is stored in pg.yaml (mode 0644 by default — protect that file) and duplicated into the 0600 secret file mounted into the container.
Every pgcli command that talks to ClickHouse passes credentials as environment variables expanded inside the container; the password never appears on the podman command line or the host process list. SQL is supplied on stdin.
Connecting
Use the printed endpoints from any host tool:
clickhouse-client is not installed on the host by pgcli — use the addon’s own query/shell commands below, or point an external client at the native port. /ping answers Ok. without authentication and is what pgcli polls during start.
Shell and query
query runs clickhouse-client as the admin user and writes the result to stdout, so it can be piped or redirected. The statements are joined from the positional arguments; --file reads a file (mount-free: the file is read by the host and piped on stdin), and - reads from stdin. The client is invoked without --multiquery, so pass one statement per call.
The instance must be running for query, database, user, grant, backup and restore; shell opens regardless but prints a warning when the container is stopped.
Databases
create takes a bare engine name (default Atomic; engine parameters are not exposed). drop is destructive — it asks for confirmation unless --yes is given. database list shows name, engine and data_path from system.databases. db is an alias for database.
Users and privileges
Users are created with IDENTIFIED WITH sha256_password. --host-any is the default when no host flag is given; --host-local, --host-ip (IP or CIDR) and --host-name add HOST clauses. --readonly appends SETTINGS readonly = 1. A generated password is printed exactly once and is not stored in pg.yaml — save it. --if-not-exists makes user create idempotent.
--read is SELECT, --write is INSERT, --ddl expands to CREATE, ALTER, DROP, TRUNCATE, OPTIMIZE, --all grants ALL, and --privileges takes a comma-separated list (uppercased and de-duplicated with the flag selections). Without --database the grant targets * (all databases); --table requires an explicit database. --revoke emits REVOKE ... FROM. grant list runs SHOW GRANTS FOR.
All of these run as the instance’s admin account; the CLI never stores per-user passwords or credentials.
Configuration
Server settings are addressed by dotted XML paths; the leaf value is the text of that element in the generated zz-pgcli.xml.
config set KEY=VALUE ...accepts one or more pairs, validates them, rewrites the XML andpg.yaml, then restarts a running container so the change takes effect. Pass--no-restartto defer it to the next start.config unset KEY ...removes overrides (one or more) and follows the same restart rule. Unsetting an unknown key is an error.config getprints the effective value of a custom override or a managed setting;config listshows managed settings first, then the custom overrides sorted by key.config showrenders the same XML that is written toconfig.d/zz-pgcli.xml; it does not touch the file or the container.--config-optionat install time replaces the whole override map; omit it to keep the stored one.
logger.level is special-cased: it folds into the managed <logger> block instead of producing a second <level> element. Valid levels are trace, debug, information, warning, error, fatal and none.
pgcli-managed keys are rejected, because overriding them would duplicate generated XML or silently contradict the stored paths/ports: listen_host, listen_try, http_port, https_port, tcp_port, interserver_http_port, interserver_http_host, mysql_port, postgresql_port, path, tmp_path, user_files_path, format_schema_path, access_control_path, backups.*, user_directories, profiles, users, storage_configuration.disks, storage_configuration.policies, and every logger.* key except logger.level. Keys containing spaces or values containing control characters are rejected too.
Typical settings:
Server settings cannot be changed with a hot reload by this CLI; every effective change recreates or restarts the container (ClickHouse reads this generated file at startup).
pgcli validates only the key shape and the managed-key rules — the value is checked by ClickHouse itself at startup. A value it rejects (for example a background_pool_size below what the merge/mutation sanity check allows) aborts startup, and the container’s unless-stopped policy turns that into a restart loop. Recover without losing data:
Backup and restore
Backups use ClickHouse’s SQL BACKUP command against the local backups disk (/var/lib/clickhouse-backups). They run online and the instance must be running. Only the managed local disk is exposed — S3 destinations are not configured.
--databaseis required for both commands: it names the database recorded in the backup (there is no “all databases” form in v1).--outis relative to the backups dir unless absolute; absolute paths must stay inside that dir, and the file must end in.zip. The default name is<database>-YYYYMMDD-HHMMSS.zip(UTC).restoretakes a file inside the backups dir, plus the recorded database name. Restoring into a database that already exists fails (ClickHouse refuses to overwrite); use--as NEWto restore alongside it.- Restore itself is online, but it needs free space and a running instance;
backupslists.zipfiles sorted newest first.
The backups directory is a normal host directory — copy the .zip files off-host for real disaster recovery. There is no scheduled backup; wire pg addon clickhouse backup into your own timer if you need one.
Lifecycle
start re-renders the generated config and password file from pg.yaml before starting, so a stopped instance picks up config set --no-restart changes and a changed admin password. status reports running (ping ok) when the HTTP /ping endpoint answers.
Without --clean-data, remove leaves <instance dir> (data, config, logs, backups, secret) and a custom --data-dir on disk; reinstalling under the same name revives the data and reuses the kept admin password (pass --admin-password to override). --clean-data deletes both the instance dir and a custom data dir (files owned by uid 101 are removed through podman unshare), but never removes the pulled image.
Auto-start after reboot:
Boot start also re-renders the config and secret first, then starts the existing container. pg start starts every autostart-enabled ClickHouse instance before nginx, so an nginx backend pointing at ClickHouse’s HTTP port is up when the proxy starts.
Production notes
- Single node only. This addon has no replication, no sharding and no HA. ClickHouse ReplicatedMergeTree/Keeper topologies are out of scope; backups are the disaster-recovery path.
- No TLS listener. The HTTP and native ports are plaintext. To expose ClickHouse over the network with TLS, put the nginx addon in front of the HTTP port (or use an external proxy); do not expose the native port directly to untrusted networks.
- Loopback by default.
--listendefaults to127.0.0.1. Authentication is always on, but a network bind still widens the attack surface; use0.0.0.0only on trusted networks. - Password storage. The admin password lives in
pg.yaml(0644by default); protect that file and preferpg addon password clickhouseover copying it around. - Resource use. The container has no CPU/memory limit from pgcli; ClickHouse’s own memory budget and the recommended settings are covered in Memory limits below.
- Upgrades.
--force --version <new>pulls a new tag and recreates the container on the same data dir. ClickHouse supports forward upgrades only — never start an older server against data written by a newer version. Take a backup before upgrading. - Data ownership. Data files are owned by uid 101. Use
--clean-datarather than deleting the directory by hand; pgcli handles the permission fallback. - Platform. Linux only (host networking); the official image is multi-arch (amd64/arm64).
Memory limits
pgcli never passes --memory/--cpus to podman, so the container has no cgroup memory cap; ClickHouse enforces its own budget instead:
- Server-wide.
max_server_memory_usageis the process-wide limit in bytes. Its default0keeps ClickHouse’s ratio rule,max_server_memory_usage_to_ram_ratio(default0.9of host RAM). That is fine on a dedicated host and risky on a shared one. On a shared host set the absolute value explicitly and leave headroom for the OS page cache and other services; on a dedicated host 50–70% of RAM is a common starting point. - Concurrency.
max_concurrent_queriesandbackground_pool_sizeare also server-level settings and work withconfig set. The server-wide limit covers all concurrent queries together, so plan forpeak per-query memory × concurrencyinside that budget. - Query-level.
max_memory_usage,max_bytes_before_external_group_by,max_bytes_before_external_sortandmax_threadsare user/profile settings, not server XML, soconfig setdoes not apply them. Set them for one session withSETin aclickhouse-clientstarted frompg addon clickhouse shell(one-shotquerycalls do not share session state), or define them on a SQL-created user withCREATE USER ... SETTINGS .... - Spill instead of OOM.
max_bytes_before_external_group_by/max_bytes_before_external_sortlet heavy aggregations and sorts spill to disk above the threshold, trading latency for a lower memory ceiling.JOINis usually the biggest consumer — size for the right-hand table or avoid large joins.
Check the live value:
Example — cap the whole server at 8 GiB and allow 20 concurrent queries:
Values are bytes; config set restarts a running container unless --no-restart is passed. For reference, a 2 vCPU / ~2 GiB test VM handles small OLAP workloads with max_server_memory_usage=805306368 (768 MiB); JOIN-heavy or high-cardinality aggregation workloads over large data need proportionally more memory.
CLI flag reference
pg addon install clickhouse
| Flag | Default | Description |
|---|---|---|
--name NAME |
clickhouse |
Instance name (map key); [A-Za-z0-9][A-Za-z0-9_-]*, max 48 chars |
--version TAG |
26.8.36.3 |
Upstream image tag from docker.io/clickhouse/clickhouse-server |
--data-dir DIR |
<base_dir>/addon/clickhouse/<name>/data |
Absolute host data dir bind-mounted at /var/lib/clickhouse |
--listen ADDR |
127.0.0.1 |
Bind address for both listeners |
--http-port N |
auto (base 8123) |
HTTP host port; 0 = auto-assign |
--tcp-port N |
auto (base 9000) |
Native host port; 0 = auto-assign; must differ from --http-port |
--admin-user USER |
admin |
Admin account name; [A-Za-z0-9_.-]{1,64} |
--admin-password PW |
generated | Admin password; on --force the stored value is kept unless supplied |
--config-option K=V |
— | Dotted XML path setting; repeatable; replaces the stored override map |
--force |
false | Re-render config/secret and recreate the container, keeping data, password and settings |
pg addon clickhouse status / shell / query [SQL...]
| Argument/flag | Default | Description |
|---|---|---|
--name NAME |
clickhouse |
Instance to operate on |
SQL... |
— | query only: SQL joined from positional arguments |
--database DB |
— | query only: adds --database DB |
--file FILE |
— | query only: read SQL from a host file (- = stdin) |
- (argument) |
— | query only: read SQL from stdin |
pg addon clickhouse database create|list|drop
| Argument/flag | Default | Description |
|---|---|---|
NAME |
— | Database name (create/drop, positional) |
--engine NAME |
Atomic |
Bare engine name (no parameters) |
--comment TEXT |
— | Database comment |
--if-not-exists |
false | create only |
--if-exists |
false | drop only |
--yes |
false | drop only: skip the confirmation prompt |
db is an accepted alias for database.
pg addon clickhouse user create|list|drop
| Argument/flag | Default | Description |
|---|---|---|
NAME |
— | User name (create/drop, positional) |
--password PW |
generated | Password; generated and printed once when omitted |
--default-database DB |
— | DEFAULT DATABASE clause |
--host-any |
implied | Allow any host (applied when no host flag is given) |
--host-local |
false | LOCAL (loopback) only |
--host-ip IP|CIDR |
— | Repeatable IP host clause |
--host-name NAME |
— | Repeatable NAME host clause |
--readonly |
false | Add SETTINGS readonly = 1 |
--if-not-exists |
false | create only |
--if-exists |
false | drop only |
--yes |
false | drop only: skip the confirmation prompt |
pg addon clickhouse grant USER / grant list USER
| Argument/flag | Default | Description |
|---|---|---|
USER |
— | Target user (positional) |
--read |
false | Grant SELECT |
--write |
false | Grant INSERT |
--ddl |
false | Grant CREATE, ALTER, DROP, TRUNCATE, OPTIMIZE |
--all |
false | Grant ALL |
--privileges LIST |
— | Extra comma-separated privileges, merged with the flags above |
--database DB |
all (*) |
Scope to a database |
--table TABLE |
all tables | Scope to a table; requires --database |
--revoke |
false | Emit REVOKE ... FROM instead of GRANT ... TO |
--with-grant-option |
false | Append WITH GRANT OPTION (grant only) |
pg addon clickhouse backup / restore FILE / backups
| Argument/flag | Default | Description |
|---|---|---|
FILE |
— | restore only: .zip file inside the backups dir |
--database DB |
required | Database to back up / the database name recorded in the backup |
--out NAME |
<db>-<UTC timestamp>.zip |
backup only: output path inside the backups dir |
--as NEW |
— | restore only: restore under a new database name |
pg addon clickhouse config list|get|set|unset|show
| Argument/flag | Default | Description |
|---|---|---|
KEY=VALUE... |
— | set only: one or more dotted XML settings |
KEY... |
— | get/unset: one setting key (get takes exactly one) |
--name NAME |
clickhouse |
Instance to inspect or modify |
--no-restart |
false | set/unset only: apply on the next start instead of restarting now |
Lifecycle, credentials, logs, autostart
| Command | Flags | Description |
|---|---|---|
pg addon start clickhouse |
--name NAME |
Start (create if missing), wait for /ping |
pg addon stop clickhouse |
--name NAME |
Stop the container, keep data |
pg addon remove clickhouse |
--name NAME, --clean-data |
Remove container and config entry; --clean-data also deletes the instance dir and a custom data dir |
pg addon password clickhouse |
--name NAME |
Print the admin password |
pg logs addon clickhouse |
--name NAME, -n N (default 50), -f |
Container logs |
pg autostart enable|disable |
--clickhouse, --name NAME |
Toggle boot start for one instance |
Global flags
| Flag | Description |
|---|---|
-c, --config FILE |
Config file (default ~/.pgcli/pg.yaml) |
-o, --output FILE |
Output config path (default the same file) |
-i, --instance NAME |
PostgreSQL instance selector — not used by ClickHouse; use --name instead |
12.33 - Kafka
pg addon install kafka runs Apache Kafka 4.4.0 as a managed container from the official docker.io/apache/kafka multi-arch image. Kafka 4.x is KRaft-only: the metadata controller is built into the broker process (process.roles=broker,controller, combined mode) and ZooKeeper is not installed or used.
Like ClickHouse, Kafka is a top-level addon — it does not front a PostgreSQL instance. pgcli renders server.properties, pins a KRaft cluster id, bind-mounts the data directory and runs the container on the host network. A single command installs a single combined node; adding --cluster, --node-id and --peer installs one node of a multi-host static controller quorum, with every host running its own pg addon install kafka (pgcli never SSHes). See Multi-host cluster.
There is no authentication. Kafka listens with PLAINTEXT by default, and
--listendefaults to127.0.0.1. TLS is opt-in (--tls) and encrypts traffic, but it does not authenticate clients (ssl.client.auth=none), and the plaintext listener stays enabled for compatibility. Expose it only on a trusted private network or behind a firewall/proxy. Anyone who can reach either listener can read and write every topic.
Parameters
| Flag | Default | Description |
|---|---|---|
--name |
kafka |
Instance name (map key; also the default subcommand name) |
--version |
4.4.0 |
Upstream image tag, pulled directly from docker.io/apache/kafka |
--data-dir |
<base_dir>/addon/kafka/<name>/data |
Data directory bind-mounted at /var/lib/kafka/data; must be an absolute path |
--listen |
127.0.0.1 |
Bind address for the PLAINTEXT, SSL (when --tls) and CONTROLLER listeners. 0.0.0.0 exposes all enabled listeners on the network; in cluster mode it must be 0.0.0.0 or the advertised address |
--advertise-host |
127.0.0.1 |
Host clients are told to use. Must be a concrete IP (never 0.0.0.0); in single-node mode it is also the controller voter address. In cluster mode it is this node’s controller voter address too, so it must be a concrete non-loopback IP and is required on a fresh cluster install |
--broker-port |
auto (base 9092) |
Host broker port; 0 = auto-assign from kafka_broker_start_port |
--controller-port |
auto (base 19093) |
Host KRaft controller port; 0 = auto-assign from kafka_controller_start_port. Cluster installs must pass it explicitly: every peer embeds it in its voter list |
--cluster NAME |
— | Join a multi-host cluster with this name. Every host must use the same name; the KRaft cluster id is derived from it deterministically |
--node-id N |
1 single-node |
This node’s 1-based KRaft node id. Required on a fresh cluster install, unique within the cluster; rejected in single-node mode |
--peer ID@HOST:PORT |
— | Another controller voter. Repeat once per other node (this node is folded in automatically); pass the full peer list on every host. The host must be an IP and the port must match that node’s --controller-port |
--tls |
false | Add an SSL listener next to the plaintext broker, using a certificate from pgcli’s self-signed CA. The PLAINTEXT listener stays enabled |
--tls-pem |
— | Serve TLS with this combined PEM (certificate chain + private key) instead of the generated certificate. Implies --tls; the file is validated and copied to config/tls/server.pem. PKCS#8, PKCS#1 and SEC1 keys are accepted. In cluster mode the certificate must cover this node’s --advertise-host |
--tls-port |
auto (base 9093) |
Host SSL listener port; 0 = auto-assign from kafka_tls_start_port; requires --tls |
--cluster-id |
derived (cluster) / generated | In cluster mode the id is derived from --cluster and an explicit value must equal it; in single-node mode it adopts an explicit 22-character KRaft cluster id, else adopts meta.properties or generates one |
--config-option K=V |
— | Extra server.properties setting (num.partitions=3, log.retention.hours=168); repeatable; replaces the stored override map |
--force |
false | Re-render server.properties and recreate the container; keeps data, the stored cluster id and node id; required to add or remove TLS. Cluster identity fields (cluster, node id, cluster id, peers, advertise host, controller port) cannot be changed even with --force |
--image does not apply to Kafka — use --version.
The controller default is 19093, not 9093, because 9093 belongs to the Alertmanager pool (the TLS pool starts at 9093 but skips ports owned by other addons). pgcli renders the topology keys itself; --config-option can only add settings outside the managed set (roles, node id, listeners, advertised listeners, protocol map, controller listeners/voters, log.dirs, the SSL keystore/client-auth keys, internal-topic replication factors, default.replication.factor and min.insync.replicas are rejected).
Install
A second instance takes the next free broker/controller ports from their pools (for example 9093/19094, unless those ports are in use):
Re-running install without --force is a no-op that reports the current state. --force re-renders the config and recreates the container, keeping the data dir, the stored cluster id and node id. Changing --cluster-id on an instance whose data dir is already formatted is refused — the data belongs to the original cluster id.
The image is pulled directly from Docker Hub; no pgcli-built image is involved. --version accepts any safe upstream tag, but the tag must exist in the registry — the pull fails otherwise.
Multi-host cluster
A cluster is a static KRaft quorum: every node carries the complete controller.quorum.voters list, and membership cannot be changed online. Install one node per host with the same --cluster name, a unique --node-id, this host’s --advertise-host, an explicit --controller-port, and a --peer entry for every other node. Run the command on each host yourself; pgcli does not fan out over SSH.
For three hosts 192.0.2.11, 192.0.2.12 and 192.0.2.13:
How the identity is assembled:
- Cluster ID. Derived from
--clusterwith a deterministic hash, so every host computes the same value without passing--cluster-id. If you pass--cluster-idit must equal the derived value. - Voters.
ID@HOST:PORTentries are sorted by node id; this node’s entry comes from--node-id,--advertise-hostand--controller-port, and peers supply the rest. Two voters may not share a controller endpoint. - Controller port. It is part of every peer’s voter list, so a fresh cluster install requires an explicit
--controller-port(default and recommended value:19093). Auto-assignment could pick a different port on another host. - Ports and addresses. All nodes may use the same broker/controller ports because they are different hosts.
--listenmust be0.0.0.0(or this node’s advertised address); loopback is rejected.
The first two installs print a warning that the controller quorum is not ready yet — that is expected until the last voter is installed. The readiness wait is 15 seconds in cluster mode instead of 90. Once all nodes are up:
Topic defaults follow the quorum size: --replication-factor 0 means min(3, voters) (3 for three nodes), with min.insync.replicas = min(2, RF). A two-voter cluster is allowed but has zero fault tolerance — losing either node loses the metadata quorum — so pgcli prints a warning. Use three or another odd number for HA.
Cluster identity is immutable after install, even with --force: --cluster, --node-id, --cluster-id, the --peer set, --advertise-host and --controller-port are all rejected if they change. Ports, TLS, version, --data-dir and --config-option remain mutable with --force. To change the topology, remove the node (on that host) and reinstall; there is no online controller add/remove in this addon.
Ports
| Endpoint | Default | Protocol | Pool |
|---|---|---|---|
| Broker | 9092 |
Kafka client protocol (PLAINTEXT) | kafka_broker_start_port, default 9092 |
| Controller | 19093 |
KRaft metadata quorum (PLAINTEXT) | kafka_controller_start_port, default 19093 |
| SSL (TLS) | 9093 (when --tls) |
Kafka client protocol over TLS | kafka_tls_start_port, default 9093 |
All three pools share one “already assigned” set with each other and pre-mark explicit ports from the other streaming addons (Prometheus, ClickHouse, InfluxDB, influx2tsdb and the object stores), so neither side can steal the other’s port. Explicit Alertmanager ports are also pre-marked by the Kafka allocator (and Kafka’s ports by the Alertmanager pool), because both default near 9093. Broker, controller and TLS ports must differ and must be unique across all Kafka instances. Ports already listening on the host are skipped during auto-assignment.
When --listen is a wildcard, pgcli’s own topic/health commands dial 127.0.0.1; the printed local controller endpoint follows the same rule. In single-node mode the static voter is AdvertiseHost:ControllerPort, so if you set a LAN --advertise-host you must also set --listen to 0.0.0.0 (or that same LAN address) or the controller cannot reach itself.
In cluster mode the controller port is not just a local bind — it is the address every other host writes into its controller.quorum.voters. Use the same --controller-port on every node (unless you deliberately give each node a different one, in which case every --peer entry must carry the correct port for that node) and make sure the port is free on all hosts before you start. A mismatch means the voter lists differ and the quorum never forms. The broker port is advertised per node, so it may differ between hosts, but the same 9092 everywhere keeps client bootstrap lists simple.
Replication defaults
Replication follows the number of static voters, so neither mode needs manual
server.properties edits:
| Voters | Topic default RF | min.insync.replicas |
Tolerates (with acks=all) |
|---|---|---|---|
| 1 | 1 | 1 | no broker failure |
| 2 | 2 | 2 | no broker failure (both replicas must stay in sync) |
| 3+ | 3 | 2 | one broker down |
- The same values set
default.replication.factor,min.insync.replicasand the internal topics (offsets,transaction.state.log, share coordinator), so a new cluster can commit offsets and transactions before any user topic exists. pg addon kafka topic create NAMEuses the topic default; override it per topic with--replication-factor N. The RF cannot exceed the number of brokers holding the data.- RF stays capped at 3 in larger clusters: extra replicas add write cost and latency without improving the failure budget that
min.insync.replicas=2allows. - With
min.insync.replicas=2, a partition whose ISR drops below 2 rejectsacks=allwrites (the producer default since Kafka 3.0).pg addon kafka topic describe NAMEshows the ISR.
TLS
Enable the SSL listener with --tls:
install and status then print both endpoints and the CA path:
The plaintext listener stays enabled, so existing clients keep working. TLS only takes effect on creation or with --force; --tls-port without --tls is rejected. Disable it again with --tls=false --force — the generated files stay under config/tls/ and are reused if you re-enable TLS.
The certificate is issued by pgcli’s self-signed CA (the same helper MinIO/silo/rustfs use) for localhost, every host NIC address and --advertise-host. Clients must trust config/tls/ca.crt.
Cluster TLS
Every node issues its own CA and leaf, so a client that may connect to any broker needs all of them in one trust file:
The SSL listener is per node: enable --tls on all nodes for a uniform SSL bootstrap list. If only some nodes have TLS, those nodes advertise an SSL://... endpoint while the others advertise only PLAINTEXT://...; clients using security.protocol=SSL can still reach the TLS nodes, but the plaintext-only nodes remain plaintext. Enable TLS one node at a time (--tls --force) so the controller quorum keeps two of three voters.
--tls only adds a client-facing SSL listener. The CONTROLLER quorum and inter-broker traffic stay on PLAINTEXT, so enabling or disabling TLS never breaks quorum.
Bring your own certificate (pg cert)
Kafka’s PEM keystore reads one file containing the certificate chain followed by the private key. pg cert writes the two parts separately, so merge them before installing:
With an intermediate chain, put the leaf first: cat leaf.crt intermediate.crt key.key > kafka-server.pem.
At install time pgcli checks that the file contains a certificate and exactly one unencrypted key, that the key matches the certificate, that the certificate is currently valid, allows server authentication and is not a CA certificate. It also checks the SANs against --advertise-host: in cluster mode a certificate that does not cover this node’s advertised IP is a hard error (the other voters and clients reach this node by that address); in single-node mode it is only a warning. PKCS#8 (BEGIN PRIVATE KEY, what pg cert writes), PKCS#1 (BEGIN RSA PRIVATE KEY) and SEC1 (BEGIN EC PRIVATE KEY) keys are accepted; encrypted keys are rejected.
pgcli copies the merged file to config/tls/server.pem (mode 0600, owned by uid 1000) and does not generate ca.crt or public.crt/private.key. The source file is left untouched. Automatic renewal does not apply: replace the source PEM and re-run the install with --force to put the new certificate into a fresh container.
For a self-signed pg cert certificate, the certificate file itself is what clients trust — use kafka.crt wherever the examples below say ca.crt. If the certificate is signed by a public or private CA, point clients at that CA chain instead, and include any intermediate certificates in kafka-server.pem.
In a cluster, issue a separate certificate per node that covers that node’s advertised IP, and pass it only to that node’s install:
If a node’s certificate does not cover its --advertise-host, the cluster install is refused before the container is recreated.
To switch an instance back to pgcli’s generated certificate, reinstall with --tls --tls-pem '' --force; an empty --tls-pem clears the stored path.
Java client (client.properties):
librdkafka-based clients:
Verify the listener with OpenSSL:
Produce and consume over TLS from inside the container (the TLS dir is mounted read-only with the config dir):
- TLS encrypts the connection only:
ssl.client.auth=none, so clients present no certificate and anyone who can reach the SSL port can still connect. The listen address remains the only security boundary. - Connect by IP (or a name present in the certificate). A DNS name that is not a host NIC address is not in the SAN list and fails verification.
- The leaf certificate is regenerated when it is within 30 days of expiry (on install, start or
--force). The CA is kept, so clients do not need a new trust file. This applies only to pgcli-generated certificates; with--tls-pemyou own renewal.
What pgcli creates
Everything for one instance lives under <base_dir>/addon/kafka/<name>/:
| Host path | Container path | Notes |
|---|---|---|
config/server.properties |
/mnt/shared/config (directory, read-only) |
Generated topology; the image entrypoint merges it into the runtime config |
config/tls/ |
/mnt/shared/config/tls (read-only) |
Only with --tls: server.pem (cert chain + key, 0600, owned by uid 1000), plus ca.crt (0644), public.crt and private.key when pgcli generated the certificate. With --tls-pem, only server.pem is written — a normalized copy of your combined PEM |
data/ |
/var/lib/kafka/data |
Log segments, meta.properties and KRaft metadata |
The generated server.properties sets:
With --tls the listener lines and security section become:
For a three-node cluster (node 2 of prod shown; --listen 0.0.0.0):
The voter list is identical on all three hosts and sorted by node id; this node’s node.id is the only line that differs. Replication factors are min(3, voters) and ISR is min(2, RF).
plus replication settings for the internal topics and auto-created topics: with one voter, offsets.topic.replication.factor=1, transaction.state.log.replication.factor=1, transaction.state.log.min.isr=1, the share-coordinator equivalents, default.replication.factor=1 and min.insync.replicas=1. Overrides are appended after the managed block in sorted key order.
The container runs with --network host, --restart unless-stopped and a 30-second stop timeout, and is started with -e CLUSTER_ID=<id> so the image formats the data dir with exactly the stored id (the image would otherwise inject its own fixed default).
The official image runs as uid/gid 1000 (appuser). Before the first boot pgcli hands the data directory to that id with a one-time podman unshare chown -R 1000:1000. Once meta.properties exists the storage is already owned correctly, so later starts and --force re-creations do not recurse over the log tree. With --tls, only the combined config/tls/server.pem is chowned to uid/gid 1000 (the PEM keystore is read as appuser); the config dir, CA and raw private key stay host-owned, and the directory remains traversable for the read-only mount.
Topics
--partitionsdefaults to the broker’snum.partitions(upstream default1).--replication-factor 0(the default) means “the instance’s RF” —1on a single node,min(3, voters)in a cluster (so3on a three-node cluster).--config KEY=VALUEis repeatable and passes topic-level settings through tokafka-topics.sh.deleteasks for confirmation unless--yesis given; deleting a topic deletes all of its messages.retention NAME [DURATION]shows or setsretention.ms. Durations acceptms/s/m/h/d/wsuffixes (30m,12h,7d,4w), plain milliseconds, or-1/infinite/foreverto keep messages indefinitely. Without a duration it prints the topic’s dynamic overrides; an empty result means the broker default applies. Retention is time-based and deletes old segments even if they were never consumed.- The instance must be running for every topic command.
Produce and consume with the Kafka scripts inside the container (the image keeps
them in /opt/kafka/bin, which is not on PATH):
--bootstrap-server is what external clients use with the printed broker endpoint. For a host-side client against the default loopback listener, 127.0.0.1:9092 is enough.
In a cluster, every broker advertises its own host, so a client can bootstrap from any node and is redirected to the partition leader. Producers and consumers may run on different hosts:
Testing from a host with kcat
kcat is a small native producer/consumer
that is convenient for smoke tests from outside the container (apt install kcat on Debian/Ubuntu, brew install kcat on macOS). Bootstrap from any node —
the client follows the advertised leaders. The examples use the e2e topic
created above (3 partitions, RF=3):
For TLS, point kcat at the CA bundle from Cluster TLS and
select the SSL protocol (the TLS listener is on port 9093):
-o beginning/-o -1start at the first / last message;-eexits at EOF and-c Nstops after N messages;-qprints only message bodies.- With
-b, use the advertised broker address; the client is redirected to the partition leader, so there is no per-partition configuration. - These examples do not use a consumer group: kcat reads the partitions
directly and commits no offsets, so nothing appears in
pg addon kafka group list. Add-G NAMEto consume as a group (topics become positional arguments) — see Consumer groups.
Consumer groups
kafka-consumer-groups.sh is wrapped for the two everyday queries:
listprints the group ids that have committed offsets on this cluster.describeprints the group state, its members and per-partitionCURRENT-OFFSET/LOG-END-OFFSET/LAG, plus the consumer id and host. A lag that keeps growing means the consumers are not keeping up.- A group only appears after its first offset commit;
group.idis set by the client, not by pgcli. To create one for a smoke test, consume with--group:
From a host, the same thing with kcat uses -G; in group mode topics are
positional arguments, not -t:
The console-consumer and kcat examples in the sections above omit the group
on purpose (read-and-exit smoke tests), so they never create one.
- The instance must be running for both commands. A consumer that stops without
committing leaves the group behind in the
Emptystate; its offsets and lag are still visible until the offsets expire.
Status and quorum
cluster status runs kafka-metadata-quorum.sh --bootstrap-controller <local controller> describe --status; on a healthy single node it prints the cluster id, a leader and one voter. In a cluster it reports the leader and every voter with its log lag and whether it is caught up — run it on any node:
N voters tolerate (N-1)/2 stopped nodes — three nodes tolerate one, two nodes tolerate none. Stopping a majority loses the metadata quorum: brokers keep their existing partitions readable/writable only as far as min.insync.replicas allows, but no leader election or metadata change is possible until quorum returns.
install and start wait up to 90 seconds for kafka-broker-api-versions.sh to answer on the local broker endpoint (15 seconds in cluster mode, where the full quorum only forms after the last voter is installed). A broker that never becomes ready fails the command and prints the last log lines; data is preserved.
Configuration
Extra server.properties entries can be set at install time:
--config-option replaces the stored override map (it does not merge). The overrides are persisted in pg.yaml under addons.kafka.<name>.settings and re-rendered on every start, so they survive restarts and container recreation. There is no separate config set command in this release; change an override by re-installing with --force and the complete map you want.
Keys may contain letters, digits, dots, underscores and hyphens. Values must not contain NUL, CR or LF. pgcli-managed keys are rejected rather than silently overridden.
Lifecycle
Without --clean-data, remove leaves <instance dir> (config + data) and a custom --data-dir on disk. Reinstalling under the same name adopts the formatted storage: the cluster.id and node.id in meta.properties are reused automatically instead of generating a new cluster. This is why a reinstall can revive the existing log.
--clean-data deletes the instance directory and a distinct custom data dir (files owned by uid 1000 are removed through podman unshare), but never removes the pulled image. Pointing an instance at a data dir that belongs to a different cluster id or node id is refused before the container is created.
In a cluster, remove only affects this host — pgcli never contacts the other nodes, and their static voter lists still reference this node. pg addon remove prints the quorum impact first. With three voters, removing (or stopping) one leaves the cluster available; removing a second loses quorum. Re-adding the node later requires the same --cluster, --node-id and --peer list; after --clean-data the data dir is empty and the broker formats it again with the same derived cluster id.
Auto-start after reboot is also per host — enable it on every node:
Boot start re-renders server.properties first and is idempotent (pg start also brings up autostart-enabled Kafka instances).
Production notes
- Static quorum, no online resize.
--clusterinstalls one node of a fixed KRaft quorum. There is no online controller add/remove: to grow or shrink the cluster you must reinstall nodes with a new voter list. Use three (or another odd number ≥ 3) voters for HA; two voters are allowed but tolerate no failure. - Single node is still the default. Without
--clusterthere is no replication and no HA: a stopped broker means no reads or writes for that instance. - KRaft combined mode. Broker and controller share one process. This is fine for development and small deployments; the upstream project recommends isolated controller/broker roles for larger critical clusters, which this addon does not provide.
- No authentication; TLS is transport-only. The plaintext listener is always enabled;
--tlsadds an encrypted listener butssl.client.auth=nonemeans clients are not authenticated. Anyone who can reach either port can read and write every topic. In cluster mode the CONTROLLER quorum and inter-broker traffic are also PLAINTEXT —--tlsonly covers client connections. Keep the default loopback bind, or expose the cluster only on a trusted private network. Put real authentication (SASL/mTLS) in front of Kafka with your own proxy/gateway if needed. - No ZooKeeper. Kafka 4.x is KRaft-only; there is no ZooKeeper container to manage, and
zookeeper.connectis rejected as a managed key. - Copies are not backups. Topic replication protects availability, not against accidental deletion or corruption. Stop the instance and copy the data dir for an offline backup of one node; there is no backup/restore command in this release, and an offline copy of a single cluster node is not a complete cluster backup.
- Upgrades.
--force --version <new>pulls a new tag and recreates the container on the same data dir. Kafka does not guarantee downgrading data written by a newer version — take an offline copy before upgrading.--versionmust be a real upstream tag. - JVM heap. The image default is
-Xms1G -Xmx1G. Raise it with--heapwhen a node handles many partitions/connections, but leave most RAM to the OS page cache — Kafka reads mostly from page cache, and heaps above roughly 6–8 GB usually hurt more than they help. In a cluster each node takes its own--heap; changing it recreates that node’s container (--force), one node at a time. - Data ownership. Data files are owned by uid/gid 1000. Use
--clean-datainstead of deleting the directory by hand; pgcli handles the permission fallback. - Ports. Broker defaults to
9092; the controller defaults to19093to avoid the Alertmanager default9093. Both are ordinary host ports under host networking. - Platform. Linux (host networking); the official image is multi-arch (amd64/arm64).
CLI flag reference
pg addon install kafka
| Flag | Default | Description |
|---|---|---|
--name NAME |
kafka |
Instance name; [A-Za-z0-9][A-Za-z0-9_-]*, max 48 chars |
--version TAG |
4.4.0 |
Upstream docker.io/apache/kafka tag |
--data-dir DIR |
<base_dir>/addon/kafka/<name>/data |
Absolute host data dir bind-mounted at /var/lib/kafka/data |
--listen ADDR |
127.0.0.1 |
Bind address for the broker, SSL (when enabled) and controller listeners; cluster mode requires 0.0.0.0 or the advertised address |
--advertise-host IP |
127.0.0.1 |
Advertised broker host (and this node’s controller voter host); never a wildcard; required explicitly on a fresh cluster install |
--broker-port N |
auto (base 9092) |
Broker host port; 0 = auto-assign |
--controller-port N |
auto (base 19093) |
Controller host port; 0 = auto-assign; must be explicit on a fresh cluster install because every peer embeds it |
--cluster NAME |
— | Multi-host cluster name; identical on every host; derives the KRaft cluster id |
--node-id N |
1 single-node |
1-based node id, unique within the cluster; required on a fresh cluster install; rejected in single-node mode |
--peer ID@HOST:PORT |
— | Another node’s controller voter; repeat for every other node; same peer list on every host |
--tls |
false | Add an SSL listener; the plaintext listener stays enabled |
--tls-pem FILE |
— | Combined PEM (chain + key) for the SSL listener; implies --tls; relative paths are resolved to absolute; must cover --advertise-host in cluster mode |
--tls-port N |
auto (base 9093) |
SSL listener host port; 0 = auto-assign; requires --tls |
--cluster-id ID |
derived (cluster) / generated | KRaft cluster id; in cluster mode it must equal the id derived from --cluster |
--heap SIZE |
image default (1G) |
JVM heap, passed as KAFKA_HEAP_OPTS=-XmsSIZE -XmxSIZE (512m, 2g, 4g); per node in a cluster; changing it needs --force |
--config-option K=V |
— | Extra server.properties setting; repeatable; replaces the stored override map |
--force |
false | Re-render config and recreate the container, keeping data and ids; required to add or remove --tls; cannot change cluster identity fields |
pg addon kafka status / shell
| Argument/flag | Default | Description |
|---|---|---|
--name NAME |
kafka |
Instance to operate on |
-- COMMAND... |
/bin/bash |
shell only: run a command instead of an interactive shell |
pg addon kafka topic create|list|describe|delete|retention
| Argument/flag | Default | Description |
|---|---|---|
NAME |
— | Topic name (create/describe/delete/retention, positional) |
DURATION |
— | retention only: new retention.ms (30m, 12h, 7d, 4w, plain milliseconds, or -1/infinite); omit it to show the current overrides |
--partitions N |
broker default (1) |
create only: partition count |
--replication-factor N |
0 = instance RF (1 single-node, min(3, voters) in a cluster) |
create only: replication factor |
--config K=V |
— | create only: topic config; repeatable |
--exclude-internal |
false | list only: hide Kafka’s internal topics |
--yes |
false | delete only: skip the confirmation prompt |
pg addon kafka group list|describe
| Argument/flag | Default | Description |
|---|---|---|
GROUP |
— | Consumer group id (describe only, positional) |
--name NAME |
kafka |
Instance whose local broker is queried |
pg addon kafka cluster status
| Argument/flag | Default | Description |
|---|---|---|
--name NAME |
kafka |
Instance whose local controller is queried |
Lifecycle, logs, autostart
| Command | Flags | Description |
|---|---|---|
pg addon start kafka |
--name NAME |
Start (create if missing), wait for readiness |
pg addon stop kafka |
--name NAME |
Stop the container, keep data |
pg addon remove kafka |
--name NAME, --clean-data |
Remove container and config entry; --clean-data also deletes the instance dir and a custom data dir |
pg logs addon kafka |
--name NAME, -n N (default 50), -f |
Container logs |
pg autostart enable|disable |
--kafka, --name NAME |
Toggle boot start for one instance |
Global flags
| Flag | Description |
|---|---|
-c, --config FILE |
Config file (default ~/.pgcli/pg.yaml) |
-o, --output FILE |
Output config path (default the same file) |
-i, --instance NAME |
PostgreSQL instance selector — not used by Kafka; use --name instead |
12.34 - RabbitMQ
pg addon install rabbitmq runs RabbitMQ 4.4.0 as a managed container from the official docker.io/library/rabbitmq image (the -management variant, which bundles the web UI and HTTP API). Like Kafka and ClickHouse, RabbitMQ is a top-level addon: pgcli renders rabbitmq.conf, owns the administrator account, bind-mounts the data directory and runs the container on the host network.
The addon runs either a single node or one member of a multi-host cluster. Clustering uses etcd peer discovery (rabbitmq_peer_discovery_etcd): you install one member per host with the same --cluster name and the list of etcd endpoints, and the members find each other on first boot — no static peer list and no manual join_cluster. Federation, shovel, MQTT and STOMP are not configured; advanced administration (vhosts, users, permissions, policies and exchanges) is done through the management UI or pg addon rabbitmq shell + rabbitmqctl. Common queue operations are wrapped by pg addon rabbitmq queue (see Queues and messages). In cluster mode declare creates quorum queues by default (see Clustering).
Authentication is always on. AMQP and the management UI require the administrator account pgcli creates (default
admin, generated 20-character password).--listendefaults to127.0.0.1. When you expose it on a network, treat the password as the security boundary: TLS encrypts traffic but does not authenticate clients (verify_none,fail_if_no_peer_cert = false).
Parameters
| Flag | Default | Description |
|---|---|---|
--name |
rabbitmq |
Instance name (map key; also the default subcommand name) |
--version |
4.4.0 |
Upstream image tag, pulled directly as docker.io/library/rabbitmq:<version>-management |
--admin-user |
admin |
Administrator account created on first boot and reconciled on every start |
--admin-password |
generated (20 chars) | Administrator password; stored in pg.yaml and in a 0600 secret file. Rejected in cluster mode — use --secrets-from-etcd or --admin-password-file |
--admin-password-file FILE |
— | Read the administrator password from FILE (one line). Required for the first install of a cluster member unless --secrets-from-etcd is used; also accepted for standalone instances |
--data-dir |
<base_dir>/addon/rabbitmq/<name>/data |
Data directory bind-mounted at /var/lib/rabbitmq; must be an absolute path |
--listen |
127.0.0.1 standalone, 0.0.0.0 cluster |
Bind address shared by AMQP, AMQPS (when --tls) and the management listeners. 0.0.0.0 exposes all enabled listeners. Must not be loopback in cluster mode |
--cluster NAME |
— | Join/create the RabbitMQ cluster NAME. Empty = standalone. All members must use the same name |
--node-name NODE@HOST |
— | This member’s Erlang node name (for example rabbit@192.0.2.11). Required for the first install of a cluster member; must be unique. An IP host needs no name resolution; a hostname must resolve on every member |
--etcd m1,m2 |
— | Local etcd addon member name(s) to use as discovery endpoints. Mutually exclusive with --etcd-endpoints (which wins) |
--etcd-endpoints host:port,... |
— | External etcd endpoints for discovery; repeatable or comma-separated. Plain HTTP only; http:// is accepted and stripped |
--erlang-cookie-file FILE |
— | Read the shared Erlang cookie from FILE. Required for the first install of a cluster member unless --secrets-from-etcd is used; the value is stored and cannot change afterwards |
--secrets-from-etcd |
false | Read both shared secrets from the etcd keys written by pg addon rabbitmq secret generate (recommended; no file copying between hosts). Cannot be combined with --erlang-cookie-file or --admin-password-file |
--amqp-port |
auto (base 5672) |
Plaintext AMQP port; 0 = auto-assign from rabbitmq_start_port |
--management-port |
auto (base 15672) |
Management HTTP UI/API port; 0 = auto-assign from rabbitmq_management_start_port |
--tls |
false | Add AMQPS and management HTTPS next to the plaintext listeners, using a certificate from pgcli’s self-signed CA |
--tls-cert FILE |
— | Bring-your-own server certificate (PEM chain). Must be passed together with --tls-key; implies --tls. Must cover --listen unless the listen address is 0.0.0.0 |
--tls-key FILE |
— | Bring-your-own private key (PKCS#8/PKCS#1/SEC1), paired with --tls-cert |
--tls-port |
auto (base 5671) |
AMQPS port; 0 = auto-assign from rabbitmq_tls_start_port; requires --tls |
--config-option K=V |
— | Extra rabbitmq.conf setting (heartbeat=60, log.file.level=debug); repeatable; replaces the stored override map |
--force |
false | Re-render the config and recreate the container, keeping data and the stored password. In cluster mode --cluster, --node-name and the Erlang cookie are immutable; --etcd-endpoints may change |
--image does not apply to RabbitMQ — use --version. --version accepts any safe upstream tag, but the tag must exist in the registry; the image reference is always docker.io/library/rabbitmq:<version>-management.
Install
⚠️ A standalone instance declares durable classic queues, but their messages are not persistent by default. Classic message persistence is a per-message property that the publisher must set on every publish (--property delivery_mode=2 with pg addon rabbitmq queue publish, or delivery_mode=2 in the application’s AMQP client); there is no queue argument or policy that enables it. Without it, a broker restart keeps the queue but drops its messages. Cluster mode uses quorum queues, which persist every message automatically.
A second instance takes the next free port from every pool (for example AMQP 5673, management 15673, EPMD 4370, distribution 25673):
Re-running install without --force is a no-op that reports the current state. With --force, pgcli re-renders rabbitmq.conf, rewrites enabled_plugins, recreates the container and re-applies the administrator credentials; the data directory and password are kept.
The password is printed once at install time and can be read back at any time:
Management UI and API
The management web UI is served on the management port:
Run pg addon rabbitmq status --name rmq to print the exact Management: URL for the instance — it follows --listen and --management-port. Open that URL and sign in with the admin account; retrieve the password with pg addon password rabbitmq --name rmq. In cluster mode every member serves the same UI, and Overview → Nodes shows the whole cluster.
The same endpoint serves the HTTP API:
The UI is the supported path for everything the CLI does not wrap: vhosts, users, permissions, policies and exchange definitions. Common queue operations are available as pg addon rabbitmq queue ... commands (see Queues and messages).
Ports
| Endpoint | Default | Pool |
|---|---|---|
| AMQP (plaintext) | 5672 |
rabbitmq_start_port, default 5672 |
| AMQPS (TLS) | 5671 (when --tls) |
rabbitmq_tls_start_port, default 5671 |
| Management HTTP UI/API | 15672 |
rabbitmq_management_start_port, default 15672 |
| Management HTTPS | 15671 (when --tls) |
rabbitmq_management_tls_start_port, default 15671 |
| Erlang EPMD | 4369 |
rabbitmq_epmd_start_port, default 4369 |
| Erlang distribution | 25672 |
rabbitmq_dist_start_port, default 25672 |
Each pool is allocated independently: instances are sorted by name, an explicit port is never overwritten, and a port already listening on the host is skipped. Ports must be unique across all RabbitMQ instances.
EPMD and distribution are infrastructure ports, not client endpoints. A standalone instance runs with RABBITMQ_NODENAME=rabbit@localhost, ERL_EPMD_ADDRESS=127.0.0.1 and its own EPMD/distribution ports, and rabbitmq.conf pins distribution.listener.interface = 127.0.0.1 — Erlang distribution is never exposed on the network, and multiple standalone instances coexist on one host without colliding with a host Erlang installation.
A cluster member is different: every member uses the fixed EPMD port 4369 and distribution port 25672, and distribution listens on --listen (default 0.0.0.0). As a consequence, at most one clustered RabbitMQ instance can run per host, and 4369, 25672 must be reachable from the other members (see Clustering).
The rabbitmq_prometheus plugin is not enabled. pgcli mounts its own enabled_plugins file containing only rabbitmq_management (rabbitmq_management and rabbitmq_peer_discovery_etcd in cluster mode), so every instance behaves the same and none opens a second unauthenticated metrics listener.
Clustering
A cluster is formed by etcd peer discovery, not by a static peer list. Each host runs one member with the same --cluster name and an etcd endpoint list; on first boot the member registers itself in etcd and joins whichever nodes are already registered.
Prerequisites:
- etcd 3.4+ reachable from every member on plain HTTP. A single-member etcd is enough to test, but a production discovery store should itself be redundant. pgcli does not manage TLS or authentication for this etcd connection in this release — keep it on a trusted network. You can run it as the pgcli etcd addon (
pg addon install etcd ...) or use an external etcd with--etcd-endpoints. - Unique node names.
--node-name rabbit@hostmust be unique in the cluster. The host part may be a hostname or an IPv4 address; loopback and IPv6 are rejected. With an IP the members connect directly and need no name resolution at all — recommended when there is no internal DNS. With a hostname every member must resolve it (DNS or/etc/hosts): etcd discovery registers onlyrabbit@host, it does not provide addresses. FQDNs and IPs make RabbitMQ use Erlang long names automatically. - Shared secrets. All members need the same Erlang cookie and the same administrator password. Generate them once with
pg addon rabbitmq secret generate; it writes local0600files and publishes their contents to etcd, and every member then installs with--secrets-from-etcd. pgcli stores both values inpg.yamland the0600secret file, sostartand autostart do not need etcd or the original files. The older--erlang-cookie-file/--admin-password-filepair still works when you prefer to distribute files yourself. These are cluster-level secrets: AMQP clients need neither. - Same version. Use the same
--versionon every member. - Same cluster name. A different
--clustervalue creates a separate cluster sharing the etcd store; pgcli isolates them withcluster_formation.etcd.cluster_name.
On the management host, generate and publish the shared secrets once:
The values are never printed. The command writes /tmp/rmq.cookie and /tmp/rmq.admin (mode 0600) and stores the same contents at these etcd keys (not files):
The two local files have distinct purposes:
| File | Purpose |
|---|---|
/tmp/rmq.cookie |
The shared Erlang cookie. Cluster nodes use it only for the Erlang distribution handshake between members (EPMD 4369, distribution 25672). AMQP and management clients never use it. |
/tmp/rmq.admin |
The shared broker administrator password, used to create/reconcile the admin account. It is an installation input, not a client-side credential: applications should connect with their own RabbitMQ users. To sign in as the administrator interactively, read the stored value with pg addon password rabbitmq --name mq instead of keeping the file. |
Both files are bootstrap/distribution artifacts. Each member stores the values in pg.yaml and its own 0600 secret file during installation, and start/autostart never read the /tmp files, so they can be deleted once every member is installed. When you distribute secrets manually instead of using --secrets-from-etcd, these are the files you copy to each host (contents must be identical) and pass as --erlang-cookie-file / --admin-password-file; the paths may differ per host. Never generate a new Erlang cookie after members are installed — it is immutable and members with mismatched cookies cannot join.
Install three members from the member hosts (only --node-name differs; using each host’s IP avoids /etc/hosts/DNS):
Every member reads the same values, so no file copying is required. If you prefer manual distribution, skip secret generate and pass the two --*-file flags instead.
Etcd stores these values as plaintext. Anyone who can reach the etcd client port can read the cluster credentials, so restrict port 2379 to the member hosts and the management host. Do not put the secrets in a shared etcd that other tenants can read.
The first member starts a cluster of one and prints a hint; the others discover it through etcd and join automatically. Discovery runs only while Mnesia is empty — an already-joined member restarts by contacting its last-seen peers.
Discovery keys live under
so several clusters can share one etcd as long as their --cluster names differ.
Queue semantics
- In cluster mode
pg addon rabbitmq queue declaredefaults to--type quorum. Quorum queues are replicated on every member (the default quorum size is 3, capped by the number of members) and are the only replicated queue type in RabbitMQ 4.x. --type classicstill works but classic queues are not replicated: a classic queue lives on the node where it was declared and is unavailable if that node is down. Use classic only for non-critical or transient workloads.--durable=falseand quorum +--auto-deleteremain rejected (see Queues and messages).- Every node can serve clients and the management API shows the whole cluster, so clients may connect to any member. For production, configure several endpoints or put a TCP load balancer in front.
Inspect and retire members
cluster status prints rabbitmqctl cluster_status, including every running node. cluster forget removes a stopped, permanently retired member from the cluster metadata; it refuses to forget the local node. After forgetting a member, rebalance quorum queues so their replicas are spread over the remaining nodes:
A member that is only temporarily down needs no action: restart it and it rejoins from its last-seen peers. Do not call cluster forget while the node is still meant to come back — the old node will not be able to rejoin and must be wiped (or forgotten from the other side and re-added with a fresh data directory).
Cluster constraints
--cluster,--node-nameand the Erlang cookie are immutable. Re-runninginstall --forcewith any of them changed fails;--etcd-endpointscan be updated.- A standalone instance cannot be converted to a cluster member (or vice versa): remove it with
--clean-dataand install again. First-time cluster installation also requires an empty data directory. --advertise-host,--node-id,--peerand--joindo not apply to RabbitMQ; the node identity is--node-name. Plaintext--admin-passwordis rejected in cluster mode.- The administrator password is a cluster-wide credential. All members must store the same value. To rotate it, publish a new one with
pg addon rabbitmq secret generate --force --keep-cookie ...(or use--admin-password-file ...) and reruninstall --force --secrets-from-etcd(or--admin-password-file ...) on every member. Never regenerate the Erlang cookie after members are installed: it is immutable and a changed value makesinstall --forcefail. - The etcd secret keys under
/pgcli/rabbitmq/clusters/<name>/secrets/are plaintext. Restrict the etcd client port to the member and management hosts. These secrets are for the cluster, not for application clients; AMQP clients need neither the cookie nor the administrator password (create separate RabbitMQ users for applications). - Cluster interconnect traffic (Erlang distribution on
25672) is not encrypted.--tlsprotects client listeners only. Keep4369and25672on a private network, and allow them only between members.
TLS
TLS is additive: --tls keeps the plaintext AMQP and management listeners and adds AMQPS plus management HTTPS.
install and status then print both sets of endpoints and the CA path:
The generated certificate is issued by pgcli’s self-signed CA (the same helper MinIO/silo/rustfs and Kafka use) and covers localhost, 127.0.0.1, ::1, every host NIC address and a concrete --listen address. Clients must trust config/tls/ca.crt:
AMQP URL with TLS (e.g. for Python/pika or Go clients):
- TLS is transport-only.
ssl_options.verify = verify_noneandfail_if_no_peer_cert = falsemean clients do not present certificates; anyone who can reach an enabled port and knows the password still gets in. The listen address and password remain the security boundary. - The leaf certificate is regenerated when it is within 30 days of expiry (on install, start or
--force). The CA is kept, so clients do not need a new trust file. This applies only to pgcli-generated certificates. --tls-portwithout--tlsis rejected. Disable TLS with--tls=false --force; the generated files stay underconfig/tls/and are reused if TLS is re-enabled.
Bring your own certificate (pg cert)
--tls-cert and --tls-key must be supplied together and imply --tls. Before any image pull or container work, pgcli validates that:
- the certificate parses and is currently valid;
- it allows server authentication and is not a CA certificate;
- the private key matches the certificate and is unencrypted;
- the certificate covers a concrete
--listenaddress (a mismatch is a hard error; use--listen 0.0.0.0or add the address as a SAN).
The files are copied into the instance’s config/tls/ as public.crt and private.key, and the certificate copy also serves as ca.crt. A self-signed pg cert leaf is its own trust anchor, so clients can trust rmq.crt directly. If the certificate is signed by another CA, trust that CA’s chain instead: ca.crt contains only what you supplied, which may not include the root.
With BYO TLS the source files are left untouched and pgcli does not renew the certificate — replace the source files and re-run install --force (or --tls=false --force to go back to plaintext).
Queues and messages
queue list, purge and delete run rabbitmqctl inside the container and do
not need the management HTTP password. The other queue commands — declare,
info, publish, get, bind, unbind, bindings — use the management HTTP
API with the stored administrator credentials; the instance must be running and
the management port must be reachable from the host.
listprintsname,type,messages,ready,unackedandconsumers; an empty vhost printsno queues.declarecreates a durable classic queue by default on a standalone instance, and a durable quorum queue by default on a cluster member (see Clustering). RabbitMQ 4.x removed transient (non-durable, non-exclusive) queues, so--durable=falseis rejected before the request is sent.--type classicor--type quorumoverrides the default explicitly; quorum queues must be durable and cannot be auto-delete. Repeatable--argument KEY=VALUEsets queue arguments; values that parse as JSON keep their JSON type (--argument x-max-priority=10).- Message retention.
--message-ttl 30m(or a number of milliseconds) setsx-message-ttl: a message that waits in the queue longer is dropped, or dead-lettered when the queue hasx-dead-letter-exchange. A single message can carry its own TTL with--property 'expiration="60000"'. - Automatic deletion.
--auto-deletedeletes the queue when its last consumer disconnects.--expires 24hsetsx-expires: the queue itself — including any messages still in it — is deleted once it has been unused for that long. A queue counts as used only when it has a consumer, is redeclared, or is accessed withbasic.get; publishing alone does not reset the timer. The value must be positive (0is rejected by RabbitMQ). Both can also be set with--argument. infoshows type, state, durable, auto-delete, message counters, consumers, memory, policy, arguments and node.--jsonprints the raw management API document. Counters are updated by RabbitMQ’s stats collector, so they can lag a few seconds behind publish/get.publishsends one message. Exactly one of--payloador--payload-fileis required (use-to read stdin).--exchangedefaults toamq.default(the default exchange, which routes to the queue named by the routing key);--routing-keydefaults to the queue name. Repeatable--property KEY=VALUEsets message properties, e.g.--property delivery_mode=2 --property 'content_type="application/json"'. An unroutable message prints a warning but is not an error.- Standalone persistence. A standalone instance declares a durable
classic queue (not replicated), but durability applies to the queue, not
its messages. Classic message persistence is a per-message property set by
the publisher on every publish: pass
--property delivery_mode=2toqueue publish, or setdelivery_mode=2in the application’s AMQP client. No queue argument or policy can enable it (--argument delivery_mode=2has no effect). Without it, a broker restart (stop/start, container recreation or a host reboot) keeps the queue but drops the messages. With it, messages survive as long as the instance data dir is kept (neverremove --clean-data); a lost disk is unrecoverable. Cluster quorum queues persist and replicate every message regardless of delivery mode. getpeeks by default (ack_requeue_true);--consumeacknowledges and removes the returned messages (ack_requeue_false).--countdefaults to 1;--jsonprints the raw API response.bind/unbindrequire--exchangeand refuseamq.default(the default exchange is bound implicitly).--routing-keydefaults to the queue name. Repeatable--argumentsupports argument-based (headers) bindings.unbindwithout--argumentremoves every binding from that exchange with the given routing key; with--argumentit removes only the exactly matching binding.bindingslists a queue’s bindings and their arguments (--jsonfor the raw document).purgeremoves every message but keeps the queue;deleteremoves the queue itself. Both ask for confirmation unless--yesis given.--if-empty/--if-unused(delete only) forward RabbitMQ’s conditional checks: the delete fails instead of removing a non-empty queue or one with consumers.- Every queue command accepts
--nameand--vhost(default/) and requires the instance to be running.
Retention examples
A queue declared without retention flags keeps messages indefinitely: there is no default TTL. Messages leave the queue only when they are consumed, purged, dead-lettered, or the queue itself is deleted. Concrete limits:
--message-ttl and --expires only apply when the queue is first declared.
To change retention on an existing queue without deleting it, use a policy:
Policies match queue names by regular expression, apply to new and existing
queues, and take precedence over the equivalent x-* queue arguments. A policy
can also be created in the management UI under Admin → Policies.
The same retention limits can be applied without redeclaring a queue through a
RabbitMQ policy (message-ttl, expires, max-length,
max-length-bytes); pgcli does not wrap policies yet — use the management UI or
rabbitmqctl set_policy.
pg addon rabbitmq shell opens /bin/bash in the container; rabbitmqctl and
rabbitmq-diagnostics are available there for everything the CLI and API do not
cover.
Status and lifecycle
install, start and --force wait up to 90 seconds for rabbitmqctl -q await_startup on a standalone instance. A cluster member waits up to 180 seconds, which also covers the initial peer-discovery and Mnesia bootstrap; each attempt is capped at 10 seconds because with peers present the command can succeed silently and still never exit. If the idle watchdog ends such an attempt, pgcli probes the AMQP port instead — the listener opens at the end of boot, so a reachable port means ready. After readiness pgcli reconciles the administrator account with rabbitmqctl change_password / add_user / set_user_tags administrator / set_permissions.
In cluster mode status additionally prints the cluster name, the Erlang node name and the etcd endpoints; pg addon rabbitmq cluster status shows the live view from rabbitmqctl cluster_status.
Without --clean-data, remove keeps the data dir, the generated config and the 0600 password secret. Reinstalling under the same name reuses that password, so clients keep working after a remove/reinstall cycle.
Auto-start after reboot:
Boot start re-renders rabbitmq.conf, starts the container and re-applies the admin credentials. pg start also brings up autostart-enabled RabbitMQ instances.
What pgcli creates
Everything for one instance lives under <base_dir>/addon/rabbitmq/<name>/:
| Host path | Container path | Notes |
|---|---|---|
config/rabbitmq.conf |
/etc/rabbitmq/rabbitmq.conf (read-only) |
Rendered listeners, management, admin tags/permissions and distribution settings |
config/enabled_plugins |
/etc/rabbitmq/enabled_plugins (read-only) |
[rabbitmq_management]. standalone, [rabbitmq_management,rabbitmq_peer_discovery_etcd]. in cluster mode |
config/tls/ |
/etc/rabbitmq/tls (read-only, only with --tls) |
ca.crt (0644), public.crt (chain, 0644), private.key (0600, chowned to uid 999); ca.key stays host-side and is not mounted |
data/ |
/var/lib/rabbitmq |
Mnesia, message store, definitions and logs; cluster mode adds .erlang.cookie (0400, chowned to uid 999) |
secrets/admin_password |
not mounted | 0600, reused by reinstall |
The generated rabbitmq.conf for a plaintext instance:
With --tls the file additionally carries listeners.ssl.default, the ssl_options.* block and the management.ssl.* block. A cluster member instead binds to 0.0.0.0 (or the explicit --listen address) and appends the discovery block:
No secret is written into rabbitmq.conf. The administrator password travels in the container environment (RABBITMQ_DEFAULT_USER / RABBITMQ_DEFAULT_PASS, which only seed an empty Mnesia; pgcli reconciles it with rabbitmqctl on every start), as do RABBITMQ_NODENAME and RABBITMQ_USE_LONGNAME. In cluster mode pgcli additionally writes the shared Erlang cookie to data/.erlang.cookie before the container starts (mode 0400, owned by uid 999): the official 4.4 image ignores RABBITMQ_ERLANG_COOKIE, and a node left to generate its own random cookie cannot complete the distribution handshake with its peers.
The container runs with --network host, --restart unless-stopped and a 30-second stop timeout. It runs as the image’s uid/gid 999 (rabbitmq); pgcli hands only the TLS private key and the cluster cookie file to that uid (through podman unshare on rootless, os.Chown when running as root). The data directory is chowned by the image entrypoint itself.
Configuration
Extra rabbitmq.conf entries can be set at install time:
--config-option replaces the stored override map (it does not merge). Overrides are persisted in pg.yaml under addons.rabbitmq.<name>.settings and re-rendered on every start. Keys may contain letters, digits, dots, underscores and hyphens; values must not contain NUL, CR or LF.
pgcli-managed keys are rejected rather than silently overridden: listeners.*, ssl_options.*, management.*, prometheus.*, cluster_formation.*, default_user, default_pass, default_user_tags.*, default_permissions.*, loopback_users.* and distribution.listener.*.
Password and account semantics
RABBITMQ_DEFAULT_USER/RABBITMQ_DEFAULT_PASSonly seed an empty Mnesia database on first boot. That is why pgcli reconciles the account on every start withrabbitmqctl change_password(falling back toadd_userwhen the user does not exist), then sets theadministratortag and full.* / .* / .*permissions on vhost/.- Changing
--admin-useron an existing instance creates/reconciles the new account but does not delete the previous one. Remove stale accounts in the management UI or withrabbitmqctl delete_user. - Changing
--admin-password(or reviving a stored secret) is applied on the nextinstall --forceorstart. In a cluster the password is shared: every member must store the same value, so rotate it on all members. - In cluster mode the shared secrets come either from
--secrets-from-etcd(recommended; keys written bypg addon rabbitmq secret generate) or from--admin-password-fileplus--erlang-cookie-file. Plaintext--admin-passwordis rejected. Both values are then persisted inpg.yamland the0600secret file. - Passwords are stored both in
pg.yamland in the0600secret file;pg addon password rabbitmq --name NAMEprints the stored value.
Production notes
- Single node or one cluster member. A standalone instance has no HA: one stopped container means the broker is down. In cluster mode availability comes from quorum queues replicated across members; classic queues are not replicated and disappear with their node. Federation, shovel, MQTT and STOMP are not configured.
- Cluster networking. Open
4369(EPMD) and25672(Erlang distribution) only between members on a private network. Distribution traffic is not encrypted even with--tls; TLS covers client listeners only. One clustered instance per host. - Discovery is plaintext. etcd endpoints are contacted over plain HTTP without authentication. Anyone who can reach etcd can register a node in the cluster or read the shared secret keys written by
pg addon rabbitmq secret generate; isolate it on a trusted network. etcd TLS/auth is not supported in this release. - No Prometheus plugin.
enabled_pluginsis pinned to management (plus etcd discovery in cluster mode). Scrape metrics through the management API or enable a plugin manually in the container if you really need it — but note that every instance would then want the same default metrics port. - TLS is not client authentication. Use
--listen 127.0.0.1or a private network/firewall in front of the broker. The management UI is a full administrative surface; do not expose it casually. - TLS is additive. Plaintext
amqp://andhttp://stay enabled when--tlsis on; clients that need encryption must select theamqps:///https://endpoints explicitly. - Upgrades.
--force --version <new>pulls a new tag and recreates the container on the same data dir. RabbitMQ does not guarantee downgrading data written by a newer version — take an offline copy before upgrading.--versionmust be a real upstream tag. - Backups. Copy the data directory while the container is stopped, or use the management API’s definitions export for users/vhosts/policies. There is no
pg addon rabbitmq backupcommand in this release. - Data ownership. Data files are owned by uid/gid 999. Use
--clean-datainstead of deleting the directory by hand; pgcli handles the permission fallback throughpodman unshare. - Platform. Linux (host networking); the official image is multi-arch (amd64/arm64). On a rootful host, run pgcli as root so it can hand the TLS private key to uid 999; on rootless it uses
podman unshare.
CLI flag reference
pg addon install rabbitmq
| Flag | Default | Description |
|---|---|---|
--name NAME |
rabbitmq |
Instance name; [A-Za-z0-9][A-Za-z0-9_-]*, max 48 chars |
--version TAG |
4.4.0 |
Upstream docker.io/library/rabbitmq tag; the -management suffix is added automatically |
--admin-user USER |
admin |
Administrator account; [A-Za-z0-9_.-]{1,64} |
--admin-password PASS |
generated | Administrator password (no NUL/CR/LF); rejected in cluster mode |
--admin-password-file FILE |
— | Read the password from FILE; required for the first install of a cluster member unless --secrets-from-etcd is used |
--data-dir DIR |
<base_dir>/addon/rabbitmq/<name>/data |
Absolute host data dir bind-mounted at /var/lib/rabbitmq |
--listen ADDR |
127.0.0.1 (0.0.0.0 in cluster mode) |
Bind address for AMQP, AMQPS, management HTTP and management HTTPS; must not be loopback in cluster mode |
--cluster NAME |
— | Cluster name; empty = standalone |
--node-name NODE@HOST |
— | Erlang node name for this cluster member; required on first install |
--etcd M1,M2 |
— | Local etcd addon members used for discovery |
--etcd-endpoints HOST:PORT,... |
— | External etcd endpoints used for discovery; takes precedence over --etcd |
--erlang-cookie-file FILE |
— | Read the shared Erlang cookie from FILE; required on first install of a cluster member unless --secrets-from-etcd is used |
--secrets-from-etcd |
false | Read both shared secrets from the etcd keys written by secret generate; excludes the two --*-file flags |
--amqp-port N |
auto (base 5672) |
Plaintext AMQP port; 0 = auto-assign |
--management-port N |
auto (base 15672) |
Management HTTP port; 0 = auto-assign |
--tls |
false | Add AMQPS and management HTTPS; plaintext listeners stay enabled |
--tls-cert FILE |
— | BYO certificate chain (PEM); must be paired with --tls-key; implies --tls |
--tls-key FILE |
— | BYO private key; must be paired with --tls-cert |
--tls-port N |
auto (base 5671) |
AMQPS port; 0 = auto-assign; requires --tls |
--config-option K=V |
— | Extra rabbitmq.conf setting; repeatable; replaces the stored override map |
--force |
false | Re-render config and recreate the container, keeping data and password; cluster/node/cookie are immutable |
pg addon rabbitmq status / shell / cluster ...
| Argument/flag | Default | Description |
|---|---|---|
status --name NAME |
rabbitmq |
Show state, endpoints, TLS, admin, EPMD/dist, paths; cluster name/node/etcd when clustered |
shell --name NAME |
rabbitmq |
Open /bin/bash in the container |
cluster status --name NAME |
rabbitmq |
Print rabbitmqctl cluster_status |
cluster forget NODE --name NAME [--yes] |
— | Forget a stopped, retired member; refuses the local node and asks for confirmation unless --yes |
pg addon rabbitmq secret generate
| Flag | Default | Description |
|---|---|---|
--cluster NAME |
— | Cluster whose secrets are published (required) |
--etcd M1,M2 |
— | Local etcd addon members holding the secrets |
--etcd-endpoints HOST:PORT,... |
— | External etcd endpoints holding the secrets; takes precedence over --etcd |
--cookie-file FILE |
/tmp/rmq.cookie |
Local copy of the Erlang cookie (0600); used by cluster nodes only, clients do not need it |
--admin-password-file FILE |
/tmp/rmq.admin |
Local copy of the administrator password (0600); an installation input, not a client credential |
--force |
false | Overwrite existing files and etcd keys |
--keep-cookie |
false | With --force: keep the existing Erlang cookie and rotate only the admin password (used after members are installed) |
pg addon rabbitmq queue ...
Every queue subcommand accepts --name NAME (default rabbitmq) and
--vhost VHOST (default /).
| Command | Argument/flag | Default | Description |
|---|---|---|---|
list |
— | — | Show queues with message and consumer counts |
declare QUEUE |
--durable |
true | Declare a durable queue (false is rejected by RabbitMQ 4.x) |
--auto-delete |
false | Delete the queue when its last consumer disconnects | |
--type TYPE |
classic standalone, quorum cluster |
classic or quorum |
|
--message-ttl DURATION |
— | Set x-message-ttl (e.g. 30m, 24h, or milliseconds) |
|
--expires DURATION |
— | Set x-expires: delete the queue after this much idle time (must be > 0) |
|
--argument K=V |
— | Extra queue argument; repeatable; JSON values keep their type | |
info QUEUE |
--json |
false | Print the raw management API document |
publish QUEUE |
--payload TEXT |
— | Message body (mutually exclusive with --payload-file) |
--payload-file FILE |
— | Read the body from a file; - reads stdin |
|
--exchange NAME |
amq.default |
Target exchange | |
--routing-key KEY |
queue name | Routing key | |
--property K=V |
— | Message property; repeatable; a standalone classic message needs delivery_mode=2 set on every publish to survive a broker restart (no queue-level switch exists); JSON values keep their type |
|
get QUEUE |
--count N |
1 | Maximum messages to fetch |
--consume |
false | Ack and remove the messages (default: peek and requeue) | |
--json |
false | Print the raw management API response | |
bind QUEUE |
--exchange NAME |
— | Source exchange (required; amq.default rejected) |
--routing-key KEY |
queue name | Routing key | |
--argument K=V |
— | Binding argument; repeatable | |
unbind QUEUE |
--exchange NAME |
— | Source exchange (required) |
--routing-key KEY |
queue name | Routing key | |
--argument K=V |
— | Remove only the exactly matching binding; omit to remove all matches | |
bindings QUEUE |
--json |
false | Print the raw management API document |
purge QUEUE |
--yes |
false | Skip the confirmation prompt |
delete QUEUE |
--yes |
false | Skip the confirmation prompt |
--if-empty |
false | Fail instead of deleting a queue with messages | |
--if-unused |
false | Fail instead of deleting a queue with consumers |
Lifecycle, logs, autostart
| Command | Flags | Description |
|---|---|---|
pg addon start rabbitmq |
--name NAME |
Start (create if missing), wait for readiness, reconcile the admin account |
pg addon stop rabbitmq |
--name NAME |
Stop the container, keep data |
pg addon remove rabbitmq |
--name NAME, --clean-data |
Remove container and config entry; --clean-data also deletes the instance dir and a custom data dir |
pg logs addon rabbitmq |
--name NAME, -n N (default 50), -f |
Container logs |
pg autostart enable|disable |
--rabbitmq, --name NAME |
Toggle boot start for one instance |
pg addon password rabbitmq |
--name NAME |
Print the stored administrator password |
Global flags
| Flag | Description |
|---|---|
-c, --config FILE |
Config file (default ~/.pgcli/pg.yaml) |
-o, --output FILE |
Output config path (default the same file) |
-i, --instance NAME |
PostgreSQL instance selector — not used by RabbitMQ; use --name instead |
12.35 - RocketMQ
pg addon install rocketmq runs Apache RocketMQ 5.x as a managed addon from the official docker.io/apache/rocketmq image. Like Kafka, ClickHouse and RabbitMQ it is a top-level addon that does not front a PostgreSQL instance.
Each instance is three containers on the host network: a NameServer, a broker and a proxy. The same shape is used in both modes, so ports, commands, logs and lifecycle behave identically:
- Standalone — one NameServer with no controller, one async master broker with no replicas, and a proxy. This is the quickest way to send and receive messages. The 4.x master/slave and DLedger broker deployment modes are not used.
- Multi-host cluster — every NameServer embeds a DLedger (Raft) controller, the brokers run in controller mode, and one broker is elected master while another takes over automatically when it fails. The proxy is the client entry point. This is RocketMQ 5.x’s auto-failover deployment mode.
There is no authentication and no TLS. RocketMQ listens on host networking, and cluster ports are fixed. Anyone who can reach the NameServer, broker or proxy ports can read and write every topic. Run it only on a trusted private network or behind a firewall that restricts
9876,9877,10911,10912,8080and8081. There is also no web UI and no ACL in this addon.
Parameters
| Flag | Default | Description |
|---|---|---|
--name |
rocketmq |
Instance name (map key; also the default subcommand name) |
--version |
5.5.0 |
Upstream image tag, pulled as docker.io/apache/rocketmq:<version>. Only 5.x releases are accepted |
--data-dir |
<base_dir>/addon/rocketmq/<name>/data |
Instance data root: the broker store is <dir>/broker and, in cluster mode, the controller store is <dir>/controller. Must be absolute except for base_dir-relative paths |
--advertise-host |
127.0.0.1 standalone; required cluster |
Host advertised to clients and peers. Cluster mode requires a concrete non-loopback IPv4 address; hostnames, IPv6 and 0.0.0.0 are rejected. Set it to the host’s LAN IP for standalone instances that remote clients use |
--cluster NAME |
— | Switch on multi-host cluster mode. Every host must use the same name; it becomes the DLedger controller group and the broker cluster/group name. Cluster identity is immutable after install |
--controller-id ID |
— | This node’s controller member id (n0, n1, …). Required on a fresh cluster install; must be unique in the group and match [A-Za-z][A-Za-z0-9]{0,31}. Rejected in standalone mode |
--peer ID@HOST |
— | Another controller member. Repeat once per other node, with a bare non-loopback IPv4 host and no port (the controller port is fixed at 9877). Pass the full list of the other members on every host; the local peer is added automatically. Rejected in standalone mode |
--namesrv-port |
auto (9876) standalone |
NameServer host port. 0 = auto-assign from the RocketMQ pool. Standalone only; cluster members are fixed at 9876 |
--broker-port |
auto (10911) standalone |
Broker listen port; HA is broker_port + 1 and the VIP port is broker_port - 2. Standalone only; cluster members are fixed at 10911 |
--proxy-port |
auto (8080) standalone |
Proxy remoting port. Standalone only; cluster members are fixed at 8080 |
--grpc-port |
auto (8081) standalone |
Proxy gRPC port for 5.x SDKs. Standalone only; cluster members are fixed at 8081 |
--controller-port |
— | Embedded controller port. Not accepted in standalone mode; cluster members are fixed at 9877 |
--heap |
512m |
Broker JVM heap, applied as JAVA_OPT_EXT=-Xms<size> -Xmx<size> |
--namesrv-heap |
256m |
NameServer JVM heap |
--proxy-heap |
256m |
Proxy JVM heap |
--config-option K=V |
— | Extra broker.conf setting (flushDiskType=SYNC_FLUSH, brokerPermission=4); repeatable and replaces the stored override list. pgcli-managed keys are rejected (identity, listeners, store paths, controller settings, replication settings, autoCreateTopicEnable) |
--force |
false | Re-render the three configs and recreate the three containers, keeping the broker store and controller store. Cluster identity and fixed ports cannot be changed even with --force |
--image, --listen, --tls, --tls-pem, --tls-cert, --tls-key, --node-id, --cluster-id and --join do not apply to RocketMQ and are rejected. Use --version to select the image.
Install a standalone instance
The install pulls the image if needed, renders the configs, creates the directories, starts namesrv → broker → proxy, and waits for the NameServer, broker registration and both proxy listeners:
To let clients on other hosts connect, advertise the host’s LAN address (the proxy config and broker both use it):
A second standalone instance takes the next free port from each pool; it does not reuse the first instance’s ports:
On a small VM, lower all three heaps to keep the three JVMs inside memory:
Re-running install without --force is a no-op that reports the current state. With --force, pgcli rewrites namesrv.conf, broker.conf and rmq-proxy.json, recreates the three containers and keeps the broker and controller stores.
Multi-host cluster
Cluster mode uses RocketMQ 5.x’s controller mode: the controller is embedded in every NameServer as a DLedger (Raft) group, and controller-mode brokers elect a master and fail over automatically. Every host runs its own pg addon install rocketmq; pgcli never SSHes to other hosts.
Requirements:
- The same
--clustername on every host. - A unique
--controller-idper host (n0,n1,n2, …). - That host’s
--advertise-hostas a concrete non-loopback IPv4 address. --peer ID@HOSTentries for the other controller members. Pass the full peer set on every host; the local peer is added automatically.- The fixed cluster ports free on every host:
9876,9877,10911,10912,8080and8081. One clustered RocketMQ instance per host. - At least 3 members for fault tolerance. The DLedger quorum is the majority, so 2 members tolerate no failure; pgcli prints a warning for a 2-member cluster.
Install the members one host at a time. For three hosts 10.0.0.11, 10.0.0.12 and 10.0.0.13:
The first two installs are expected to finish with a warning such as:
This is normal: the controller quorum only forms once a majority of NameServers is up. The install still exits successfully and leaves the containers running. After the third member is installed, the controller elects the broker master and the broker registers.
Check every member:
status shows the three container states, the controller id, the full peer list and — when the cluster is reachable — how many brokers are registered and how many are masters. cluster status prints the raw clusterList table; the controller’s BID column (labelled BrokerId in older clients) is 0 for the current master and non-zero for replicas:
The column layout and row order are produced by mqadmin and may differ slightly between 5.x releases; pgcli only relies on the cluster name and broker id columns.
Failover
When the master broker stops, the controller elects another in-sync replica after a heartbeat timeout. Clients that connect through the proxy or NameServer retry automatically. To watch a failover:
enableElectUncleanMaster=false is rendered, so a replica that is not in the in-sync state set is not elected. If the controller quorum itself loses its majority (for example two of three NameServers are down), no master can be elected and writes stop until the quorum returns. Stopping or removing a member does not rewrite the peer lists on the remaining hosts; pgcli never contacts the other nodes.
Ports
| Port | Role | Standalone | Cluster |
|---|---|---|---|
9876 |
NameServer client/RPC | auto, base 9876 |
fixed |
9877 |
Embedded DLedger controller | not used | fixed |
10911 |
Broker listen | auto, base 10911 |
fixed |
10912 |
Broker HA replication | broker_port + 1 |
fixed |
10909 |
Broker VIP | broker_port - 2 |
fixed |
8080 |
Proxy remoting | auto, base 8080 |
fixed |
8081 |
Proxy gRPC | auto, base 8081 |
fixed |
Standalone instances can coexist on one host because every port is drawn from a pool and reserved together with the derived HA/VIP ports. In cluster mode all of these ports are fixed because the controller membership and broker addresses are baked into every node’s config.
Clients
The proxy is the single client entry point in both modes and runs in CLUSTER mode:
- 5.x gRPC SDK (the recommended 5.x client): connect to
<advertise-host>:8081. - Remoting SDK (4.x-style clients): connect to
<advertise-host>:8080, or directly to the broker on<advertise-host>:10911. - NameServer addresses (
9876, controller9877) are internal cluster plumbing. Do not expose them to remote clients.
A standalone instance defaults to 127.0.0.1, so a client on another host needs an install with --advertise-host set to the host’s reachable IP. In cluster mode every member requires a non-loopback IPv4 --advertise-host and the proxy on each host advertises that host.
Topics and messages
Topic auto-creation is enabled (autoCreateTopicEnable=true) for compatibility, but explicit creation is recommended so queue counts are deterministic:
topic create creates the topic across the cluster (-c <cluster-or-name>) with --queues read and write queues (default 3). topic list hides RocketMQ’s internal topics (%RETRY%…, %DLQ%…, TBW102, SELF_TEST_TOPIC, transaction/schedule/trace topics, the 5.x rmq_sys_* / RMQ_SYS_* topics, *_REPLY_TOPIC and the topic named after the cluster); pass --system to show everything. topic describe prints the per-broker queue and offset table from topicStatus.
Send a message and look it up by key:
send prints the sendMessage result, including the message id, send status and queue. Pull messages back with a bounded debug consumer:
consume runs mqadmin consumeMessage in a throwaway container and prints messages to the terminal. It drains the topic’s queues and exits; --timeout (default 30s) is a hard safety limit enforced inside the container, and Ctrl-C stops early. It is a pull-only debug viewer: it does not delete messages and does not commit consumer offsets.
--count is mqadmin’s message budget (default 10); mqadmin accounts it per queue, so a run can print slightly fewer messages than requested. --count 0 removes the budget. --queue/--offset read one queue and use the instance’s broker name (--broker overrides it). --begin/--end accept epoch milliseconds or yyyy-MM-dd#HH:mm:ss:SSS; mqadmin converts the boundaries to queue offsets, so the end of the window can include the first message at or after it.
Deleting a topic removes all of its messages and asks for confirmation unless --yes is passed:
Message retention
Consumption never deletes messages. The broker removes whole commitlog segments (the storage unit shared by all topics on that broker) when a segment is older than fileReservedTime hours and the clock reaches one of the deleteWhen hours, or earlier when disk usage exceeds diskMaxUsedSpaceRatio percent. Retention is broker-wide, not per topic.
Show the running broker’s effective values and change them:
Defaults are 72 hours, 04 o’clock and 75 percent. set requires at least one of --hours (1–8760), --delete-when (one or more hours 0–23 separated by ;) and --disk-ratio (1–100). It applies the change to the running broker with updateBrokerConfig and writes the same values to pg.yaml and the rendered broker.conf, so the setting survives pg addon stop/start without a recreate. An expired segment is not deleted at the exact hour; the broker removes it during the next scheduled cleanup. Raising the ratio above the current disk usage stops disk-pressure deletions.
Retention is stored per broker. In a cluster, run the command on every member host; each broker cleans only its own commitlog.
Delivery and durability
Standalone. The broker is the only node (brokerId=0, brokerRole=ASYNC_MASTER) with no replicas. Messages are written to the broker store under data/broker, so a graceful stop/start keeps them. The rendered disk flush policy is RocketMQ’s default ASYNC_FLUSH; a host power loss can therefore lose the most recent writes. For stronger durability, render SYNC_FLUSH (at the cost of throughput):
Cluster. Brokers replicate in controller mode. pgcli renders allAckInSyncStateSet=true and minInSyncReplicas=2 for 3 or more members (1 for a 2-member cluster), so a produce succeeds only when every in-sync replica has accepted the message. Disk flush is still ASYNC by default; use flushDiskType=SYNC_FLUSH when a power loss must not lose acknowledged writes across all replicas.
Replication protects against a broker failure while the controller quorum is healthy. It does not make the controller store disposable: data/controller holds the DLedger state and epoch files. Deleting it, wiping a member, or reinstalling with --clean-data destroys that member’s controller state and can break the quorum. A member that is removed and rejoined must start with a fresh data directory and the same cluster/controller/peer identity.
pg addon remove rocketmq --clean-data deletes the instance directory (configs, broker store, controller store and logs) and a distinct custom data directory. Without --clean-data, the data and config directories are kept.
Configuration, upgrades and immutability
- Mutable with
--force: version, JVM heaps, broker.conf overrides and, for standalone instances, the listener ports.--config-optionreplaces the stored override map, so pass every override again on a forced reinstall. - Immutable even with
--force: in cluster mode--cluster,--controller-id,--advertise-host,--peerand the fixed ports. These values are embedded in every peer’s configuration; changing them in place is rejected. - Topology changes require a rebuild: standalone ↔ cluster is not an in-place change. Remove the instance with
--clean-dataand install it again in the desired mode. - Custom data directory: pointing
--data-dirat a new path makes the instance use a new, empty store; pgcli does not migrate existing data.
Managed broker.conf keys (rejected in --config-option) include brokerClusterName, brokerName, brokerId, brokerRole, brokerIP1/brokerIP2, listenPort, haListenPort, namesrvAddr, enableControllerMode, controllerAddr, every controller* key, storePathRootDir, storePathCommitLog, storePathEpochFile, autoCreateTopicEnable, allAckInSyncStateSet, minInSyncReplicas, enableControllerInNamesrv, enableElectUncleanMaster and notifyBrokerRoleChanged.
Lifecycle, logs and autostart
Stopping walks the containers in reverse (proxy → broker → namesrv); starting walks them forward (namesrv → broker → proxy). In cluster mode, start does not wait for the full controller quorum: when a majority is not yet reachable the broker stays in its waiting state and the command still succeeds. Check pg addon rocketmq status and cluster status after the other members are up.
Autostart is available like the other top-level addons:
It re-renders the three configs and starts the containers in order. For a cluster member, autostart does not wait for the controller quorum, so members can boot in any order. pg addon start (the global autostart starter) behaves the same way.
Limitations
- No authentication, TLS or ACL. Restrict the network; the proxy and NameServer are open to anyone who can reach them.
- No web UI. Use
pg addon rocketmq status,cluster status, topic commands and the logs. - No online scaling or rolling upgrade. Cluster membership and fixed ports are immutable; member changes require a planned rebuild. The controller store must not be deleted while the member participates in a quorum.
- 2-member clusters have no fault tolerance. Use 3 or more members for production.
- Linux only. The addon relies on host networking and the official image’s
uid/gid 3000. - No message migration, consumer-group management or broker/storage rebalancing. Use the standard RocketMQ administration tools through
pg addon rocketmq shell.
13 - Clone
Create a new instance whose data is copied from an existing one, streamed directly — no temp file on disk.
What happens
- Pre-flight check — the source is verified before anything is created:
- Local instance: container must be running
--dsn: an authenticatedSELECT 1must succeed (catches wrong password, unreachable host)
- Create — a new instance entry is added to the config with a random password, its own container name, data directory and auto-assigned port
- Start — the new instance is started (same workflow as
pg start) - Stream — source data is piped to the target with live transfer progress shown once per second
Notes
- The source instance must be running (or the
--dsntarget reachable); a bad source fails immediately with no side effects - The new instance name must not already exist in config
--dsnand--instanceare mutually exclusive: with--dsnthe connection string determines host, port and database- The new instance gets a fresh random password — find it in the clone output or
pg status -i <name> - Logical copy only (schema + data); for large databases a physical approach may be faster
14 - Data Import/Export
Export and import databases to dump files. Supports custom format (recommended) and plain SQL, with automatic gzip compression. Also supports piping between instances.
Format comparison:
| Feature | Custom (.dump) |
SQL (.sql) |
|---|---|---|
| Import speed | Faster (binary format) | Slower (text format) |
| File size | Smaller (compressed) | Larger (plain text) |
| Human-readable | No | Yes |
| Selective restore | Yes (specific tables) | No |
| Best for | Migration, backup, large databases | Version control, CI seed data, manual editing |
Format detection: Uses magic bytes (content-based) with extension fallback.
- Files starting with
PGDMP→ custom format .sqlor.sql.gz→ plain SQL format.gzextension or gzip magic bytes (0x1f 0x8b) → automatic decompression- Extension is used as fallback if content detection fails
Remote databases (–dsn): Connect to any PostgreSQL instance using a connection string.
- No local PostgreSQL installation needed
- Works with local-to-remote, remote-to-local, and remote-to-remote migrations
- Supports all the same flags as local instances (
-o,-d,--clean,-v,--compress)
Note on existing data: Importing into a database with existing tables will fail unless you use the --clean flag, which drops objects before restoring. Use --clean when importing into a database that already contains data.
Use cases:
- Migrate data between instances:
pg export -i proj01 | pg import -i proj02 - Cross-host migration:
pg export -i proj01 | pg import --dsn postgres://user:pass@remote:5432/db - Share database with team:
pg export -i proj01 -o dump.dump.gz(compressed, smaller file) - Backup before major changes:
pg export -i proj01 -o pre-migration.sql.gz - CI/CD pipelines: export test data, import into fresh test databases
15 - Health Inspection
pg inspect answers one question in a single command: is this database healthy, and what should I fix first? It samples the standard PostgreSQL statistics views once and produces a deterministic report — a 0-100 health score plus findings graded CRITICAL / WARNING / NOTE / GOOD, each with evidence, a remediation hint, and the caveats that keep a confident recommendation from being a confidently wrong one.
It is strictly read-only: every statement runs inside a READ ONLY transaction, so it never writes, never takes locks beyond a plain read, and is safe to run against production at any time.
When to use it
- Weekly / on-call checkups — run it against every instance and read only what scores below 100; the report tells you whether anything regressed without staring at dashboards.
- “The database feels slow” — before diving into EXPLAIN plans, get the 30-second overview: cache-hit ratio, top statements by total time (with
pg_stat_statements), bloat, and session pileups. - After installs and upgrades — confirm nothing dangerous slipped into the settings (e.g.
fsync = off) and autovacuum is actually keeping up. - Before capacity decisions — see which indexes are never scanned (and how much space dropping them would reclaim) and whether connections are drifting toward
max_connections. - Audit and handoff —
--htmlwrites one self-contained file you can mail or archive;--jsonfeeds scripts and monitoring agents.
Problems it catches
| Problem | What you’d otherwise miss |
|---|---|
| Transaction-ID wraparound approaching the freeze wall | A forced-downtime vacuum emergency |
Connection saturation near max_connections |
Failed logins under load |
| Low cache-hit ratio | Undersized shared_buffers / effective_cache_size |
| Unused indexes over 1 MiB | Wasted space and write amplification |
| Table bloat, never-vacuumed or autovacuum-disabled tables | Slowly degrading scans and vacuum storms |
| Heavy sequential scans / non-HOT updates on big tables | Missing indexes and hot update patterns |
| Long-running and idle-in-transaction sessions | Blocked cleanup and lock waits |
| Dangerous setting overrides off compiled-in defaults | Silent durability or tuning mistakes |
Every check degrades independently — a database missing pg_stat_statements still gets a full report, with one note explaining the empty section.
Usage
16 - Configuration Tuning
pg tune turns a machine’s hardware specs and a workload class into a coherent set
of postgresql.conf parameters. It works against both kinds of deployment pgcli
manages: plain instances (written through the managed tuning block in
postgresql.conf) and Patroni HA clusters (written to the DCS via edit-config,
which rolls the change out to every member).
By default it only prints a recommendation — nothing is touched. --apply
writes it; --revert removes a previously applied tune.
When to use it
- A new instance after install — the container defaults are safe, not fast. One
pg tune -i <name> --applygets the memory partition, WAL sizing and planner cost constants into the right ballpark for the hardware. - After an up/down-size — RAM and CPU counts changed; re-run the same command and the derived values follow.
- When
pg inspectflags settings — the inspection report can point at undersizedshared_buffers/ low cache-hit ratios;pg tuneis the fix-it path.
Inputs
The specs default to a probe of the host the command runs on (total RAM from
/proc/meminfo on Linux, CPU count from the runtime). Overrides:
| Flag | Meaning |
|---|---|
--type |
workload class: web, oltp, dw, desktop, mixed (default) |
--hd |
storage class: ssd (default), hdd, san, nvme |
--ram |
total memory, e.g. 16G — use on shared hosts where the probe over- or under-reports |
--cpus |
CPU count — mandatory for cross-host Patroni clusters; compute against the smallest member |
--conns |
max_connections override (default: workload preset 200/300/40/20/100) |
--db-size |
database size vs RAM, e.g. 200G (<RAM → fits, ≤4×RAM → mid, else larger) |
Workload choice in one line: mostly short queries from an app → web/oltp;
analytics and big scans → dw; a single-user app co-located with the DB →
desktop; unsure → mixed.
The target server itself is probed for server_version_num and for every
parameter’s current value, so the table shows CURRENT vs RECOMMENDED side by side
and version-gated parameters (jit, io_method, …) only appear where they exist.
Applying
Managed instances (-i <name>)
--apply records the parameters in the instance’s config and writes them into
the managed tuning block of postgresql.conf. The block is re-applied on every
pg start, so the tuning survives restarts and reinstalls of the container.
Parameters set via ALTER SYSTEM (i.e. present in postgresql.auto.conf) are
skipped with a count — auto.conf always wins.
Changes to postmaster-level parameters (shared_buffers, wal_buffers,
max_connections, …) need a restart: interactively you’re asked y/N;
non-interactively pgcli only prints the pg stop && pg start hint and never
bounces the database on its own. Everything else is applied with a config
reload immediately.
Patroni clusters (--ha <scope>)
Here the parameters go into the cluster DCS config via
patronictl edit-config — the only sanctioned way to change postgresql.conf
values in a Patroni world. Patroni then applies them on every member, rolling
restarting for postmaster-level changes. Because that is disruptive, both
--apply and --revert ask for confirmation first; --auto-restart skips the
prompt (for scripts). If you decline, nothing is written to the DCS.
The recommendation table is computed from the host pgcli runs on — on a
cross-host cluster the members may differ, so pass --ram/--cpus matching the
smallest member.
Validation and --apply-conf
--apply-conf names a key = value file (postgresql.conf style: # comments,
quoted values, units allowed). Without --apply the file is written (the
header line records the inputs so the result is reproducible); with --apply the
file is read and becomes the parameter source.
Every apply path validates first. Each pair is checked against the target’s
pg_settings: unknown names (e.g. io_method on PG 15), non-boolean bools,
values outside an enum’s set, and integers/reals out of [min_val, max_val] are
listed per-parameter and the apply aborts wholesale — a partial write is the
worst outcome for a tuning batch.
--dsn: recommendations only
Remote targets are probe-and-suggest only; --apply/--revert are refused, since
writing a stranger’s server from a tuning heuristic is a decision pgcli will not
make for you.
--web-tune: the interactive site
Starts an HTTP server that serves the interactive tuning calculator. The page has a form for every input (version, OS, workload, RAM+unit, CPUs, connections, storage, db size), live recompute, and postgresql.conf / ALTER SYSTEM output with a copy button. No network access, no dependencies; the host’s RAM/CPU/OS prefill the form. The embedded calculator is a JS port of the Go algorithm; the page ships Go-computed golden fixtures and self-checks them on load, showing a red banner if the two implementations ever disagree.
Press Ctrl+C (or send SIGTERM) to stop the server gracefully.
How the numbers are derived
The formulas are faithful and deterministic (internal unit kB, flooring at each derivation step):
shared_buffers= RAM/4 (desktop: /16);effective_cache_size= 3/4 RAM.maintenance_work_mem= RAM/16 (dw: /8), capped at 8 GB (2047 MB on Windows ≤PG17).work_mem= (RAM − shared_buffers) / ((max_connections + max_worker_processes) × 3), then scaled by workload (dw/mixed ½, desktop ⅙) and db-size (fits ×1.3, larger ×0.9), floor 4 MB.wal_buffers= 3% of shared_buffers, clamped to [32 kB, 16 MB].random_page_cost4 for hdd and for dw-on-SSD (with a warning about catastrophic index scans), 1.1 otherwise;effective_io_concurrencyby storage class (Linux only).- Parallelism from CPU count (omitted below 4 cores);
autovacuum_max_workersat ≥16/≥32 cores; version gates:jit=off≥12,wal_compressionon/lz4 ≥10/≥15,io_method/io_workers≥18 (io_methodis alwaysworker—io_uringadditionally needs a--with-liburingbuild and kernel support, and a mismatch makes the server fail to start, so it is not auto-recommended), desktop-onlywal_level=minimal+max_wal_senders=0.
Per-parameter caveats (lz4 compile flag, the DW cost warning) are printed inline in the table; absurd machine sizes (<256 MB, >100 GB RAM) get a global warning.
17 - KV Cache
pg kv deploys a set of KV-cache functions to a PostgreSQL instance, turning it into a lightweight Redis alternative for pure key-value caching. It is not a full Redis replacement — no counters, no sorted sets, no pub/sub — just fast, atomic, TTL-based key-value storage with automatic expiration.
Two variants are available: BYTEA (binary values, generic caching) and JSONB (structured JSON with field-level updates and containment queries). Both can be deployed together or separately.
When to use it
- Session storage — store user sessions, OAuth tokens, or API keys with automatic expiration. Simpler than a Redis sidecar when your app already talks to PostgreSQL.
- Configuration cache — cache application settings or feature flags that change infrequently. JSONB variant supports partial field updates without rewriting the whole object.
- Rate limiting — track request counts or timestamps per user/IP. UPSERT atomicity prevents race conditions under concurrent writes.
- Temporary data — job queues, lock tokens, one-time-use codes. UNLOGGED TABLE means no WAL overhead; crash data loss is acceptable for ephemeral cache.
- Prototyping — avoid spinning up Redis for a side project. One command (
pg kv -i myinst --database myapp) and you have a working cache.
When NOT to use it
- High throughput (>500 QPS sustained) — use Redis or Memcached. PostgreSQL is not optimized for millions of tiny KV operations per second.
- Sorted sets, counters, pub/sub — these are Redis features PostgreSQL does not replicate. Use Redis or application-level logic.
- Large values (>1 MB per key) — BYTEA storage works but is inefficient. Use object storage (S3/MinIO) or filesystem.
- Persistence required — UNLOGGED TABLE means data is lost on crash or unclean shutdown. If cache loss is unacceptable, use a regular table or Redis with AOF.
Usage
The --database flag is required — pgcli does not auto-create databases, so the target must already exist. The command is idempotent: running it multiple times is safe (functions use CREATE OR REPLACE, tables use IF NOT EXISTS).
BYTEA variant
Generic binary key-value cache. Values are stored as BYTEA (arbitrary binary data). Use for strings, serialized objects, or raw bytes.
Tables
Functions
| Function | Signature | Returns | Description |
|---|---|---|---|
kv_set |
(p_key TEXT, p_value BYTEA, p_ttl_seconds INT DEFAULT 300) |
VOID |
Write cache with UPSERT atomicity. Default TTL 300s. |
kv_set_with_result |
(p_key TEXT, p_value BYTEA, p_ttl_seconds INT DEFAULT 300) |
BOOLEAN |
Same as kv_set, returns TRUE on success, FALSE on error. |
kv_get |
(p_key TEXT) |
BYTEA |
Read cache (auto-filters expired). Returns NULL if not found or expired. |
kv_get_text |
(p_key TEXT) |
TEXT |
Read cache and convert to text (convert_from UTF-8). Returns NULL if not found. |
kv_del |
(p_key TEXT) |
VOID |
Delete cache entry. |
kv_cleanup_batch |
(max_rows INT DEFAULT 1000) |
INT |
Delete up to max_rows expired entries. Returns actual count deleted. |
Examples
JSONB variant
Structured JSON cache with field-level updates and containment queries. Values are stored as JSONB with a GIN index for efficient @> queries.
Tables
The GIN index serves user-written ad-hoc queries (WHERE value @> '{"status":"active"}'). Function APIs use primary key lookups, so the index is not consulted for json_get/json_set.
Functions
| Function | Signature | Returns | Description |
|---|---|---|---|
json_set |
(p_key TEXT, p_value JSONB, p_ttl_seconds INT DEFAULT 300) |
VOID |
Write JSON cache with UPSERT atomicity. Default TTL 300s. |
json_set_with_result |
(p_key TEXT, p_value JSONB, p_ttl_seconds INT DEFAULT 300) |
BOOLEAN |
Same as json_set, returns TRUE on success. |
json_get |
(p_key TEXT) |
JSONB |
Read entire JSON object. Returns NULL if not found or expired. |
json_get_field |
(p_key TEXT, p_field TEXT) |
TEXT |
Read specified field as text (->> operator). Returns NULL if key or field missing. |
json_get_field_json |
(p_key TEXT, p_field TEXT) |
JSONB |
Read specified field as JSONB (-> operator). Returns NULL if key or field missing. |
json_contains |
(p_key TEXT, p_query JSONB) |
BOOLEAN |
Check if cached JSON contains the query subset (@> operator). Returns FALSE if key missing. |
json_update_field |
(p_key TEXT, p_field TEXT, p_new_value TEXT, p_ttl_seconds INT DEFAULT NULL) |
BOOLEAN |
Partial update of specified field. Smart type conversion (numbers/booleans/arrays/objects/strings). p_ttl_seconds NULL preserves original expiration; non-NULL resets it. p_new_value SQL NULL returns FALSE (prevents accidental field deletion). Returns FALSE if key missing. |
json_del |
(p_key TEXT) |
VOID |
Delete JSON cache entry. |
json_cleanup_batch |
(max_rows INT DEFAULT 1000) |
INT |
Delete up to max_rows expired entries. Returns actual count deleted. |
Examples
Concurrency and atomicity
Both variants use ON CONFLICT (UPSERT) for atomic writes. Concurrent kv_set calls on the same key do not race — one wins, the other overwrites. No locks are held beyond the single row.
Read operations (kv_get, json_get) are side-effect free and never block. Expired entries are filtered by WHERE expires_at > NOW() — they are not physically deleted until kv_cleanup_batch runs.
Expiration and cleanup
Entries expire based on TTL (default 300 seconds). Expired entries are not automatically deleted — they remain in the table until kv_cleanup_batch or json_cleanup_batch is called.
Recommended cleanup strategies:
- Application-level cron — call
kv_cleanup_batch(1000)every 5-10 minutes from your app or a cron job. - pg_cron extension — if installed, schedule
SELECT kv_cleanup_batch(1000)viacron.schedule. - Lazy cleanup — call cleanup after every N writes (e.g.,
if (rand() < 0.01) kv_cleanup_batch(1000)).
The max_rows parameter bounds each cleanup to prevent long-running transactions. With 1000 rows per batch, cleanup takes <10ms even with millions of expired entries.
Limitations
- No persistence — UNLOGGED TABLE means data is lost on crash. Acceptable for cache, unacceptable for primary data.
- No replication — UNLOGGED TABLEs are not replicated by logical replication (they are replicated by physical streaming replication, but only the table structure, not the data).
- No partitioning — all keys are in a single table. For very large caches (>10M keys), consider sharding by key prefix or using Redis.
- No TTL indexing — expired entries are not indexed separately. Cleanup scans
expires_at < NOW()with a regular B-tree index.
Verification
After deployment, verify the functions are present:
Expected output for --type all:
Testing
A test script is provided in scripts/test-kv-cache.sh. It covers all 15 functions (6 BYTEA + 9 JSONB), including edge cases (NULL handling, TTL preservation, expired entry cleanup).
The script exits with code 0 if all tests pass, 1 if any fail. Use it in CI or after upgrades to confirm the functions work as expected.
Uninstallation
There is no pg kv remove command. Functions and tables remain in the database until manually dropped:
The CASCADE clause removes dependent functions automatically.
18 - Toolbox
Tools and guides that extend pgcli beyond core instance management.
- ZFS Storage — Install ZFS and manage pools, datasets and snapshots
- PGMQ Queues — Build durable message queues inside PostgreSQL
- SQLite + Litestream — Manage host-local SQLite databases with continuous S3 backups
- TimescaleDB — Time-series hypertables and time_bucket analytics on PostgreSQL
18.1 - ZFS Storage
pg zfs turns a Linux host into a ZFS storage server: install the packages, inspect
disks, create redundancy-aware pools, and manage datasets and snapshots. It talks
directly to the kernel — no state is written to pg.yaml, and it does not need a
PostgreSQL instance.
The command family is deliberately resource-oriented: pg zfs pool create,
pg zfs dataset create, pg zfs snapshot rollback. Anything not wrapped is
reachable through pg zfs raw.
Supported systems
pg zfs install detects the distribution from /etc/os-release and installs
everything needed, including package repositories:
| Distribution | ZFS source | Version tested |
|---|---|---|
| Debian 12 (bookworm) | Debian contrib, DKMS |
2.1.11 |
| Debian 13 (trixie) | Debian contrib, DKMS |
2.3.9 |
| Ubuntu 24.04 (noble) | Ubuntu universe |
2.2.2 from the Ubuntu kernel package |
| Ubuntu 26.04 | Ubuntu universe |
2.4.1 |
| Fedora 44 | OpenZFS release RPM, DKMS | 2.4.4 |
| Rocky Linux 10 | OpenZFS release RPM + EPEL, DKMS | 2.2.11 |
These are the versions the upstream repositories shipped when the installer was
tested; the repositories decide which release you get. pg zfs status always
prints the installed userland and kernel-module versions.
Other distributions are detected and given manual instructions rather than installed automatically. Only Linux is supported.
Install
The command is zero-configuration and idempotent:
- Verifies privileges (root, or working
sudo -n). - Enables the required repository (
contribon Debian,universeon Ubuntu, the OpenZFS release repository on Fedora and Rocky). - Installs ZFS, kernel headers/module and the ZED daemon.
- Loads the module and enables the ZFS systemd units for boot.
- Prints userland and kernel-module versions.
Running it again on a healthy host is a no-op. There are no --version,
--backports or --force switches — use the distribution package manager when
you need a specific ZFS build, then let pg zfs manage it.
Verify the result at any time:
RAID levels and ZFS
ZFS has no standard RAID2/RAID3 levels. The pool types map as follows:
--type |
ZFS layout | Redundancy | Analogy |
|---|---|---|---|
stripe (raid0) |
striped vdev | none | RAID0 |
mirror (raid1) |
mirror | 1+ copies | RAID1 |
raidz1 |
single parity | 1 disk | RAID5 |
raidz2 (raid2) |
double parity | 2 disks | RAID6 |
raidz3 (raid3) |
triple parity | 3 disks | triple-parity RAID |
The aliases raid0/raid1/raid2/raid3 are accepted for convenience.
Quick start
The pool is mounted at /tank, the dataset at /tank/backups. Rollback
discards every change made after the snapshot and asks for confirmation.
Reusing disks from an old pool
ZFS keeps its vdev partitions and labels on the disks, and export does not
remove them, so the safety check refuses such disks. Destroy the old pool and
clear the leftovers before creating a new one:
If labelclear fails on the whole disk, clear the member partitions instead
(/dev/vdd1, /dev/vde1). Skip step 1 when the old pool was already destroyed.
Disk safety
pg zfs disk list reports every whole disk with its mountpoints, current ZFS
membership and whether pgcli considers it safe to hand to ZFS.
Pool creation refuses:
- the root disk, or a disk with a mounted partition;
- partitions, loop, ROM and CD-ROM devices;
- disks carrying a filesystem (
ext4,xfs, swap, LVM, LUKS, mdraid, …); - disks with an existing ZFS label;
- read-only disks;
- duplicate devices, including duplicates reached through different paths;
- disks already used by another pool.
--force skips only the confirmation prompt. It is never passed to ZFS as
-f, and it does not bypass any safety check.
Creating pools
Automatic layouts
Positional devices select the layout:
| Disks | Layout |
|---|---|
| 1 | stripe (warning: no redundancy) |
| 2 | mirror |
| 3 | raidz1 |
| 4 or more | raidz2 |
Override with --type:
Use disks of the same capacity (and similar performance) within a vdev. ZFS does not reject mixed sizes, but a vdev’s usable capacity is limited by its smallest member, so the extra space on larger disks is wasted. Different vdevs in one pool may have different capacities.
Multiple vdevs
Use repeatable --data groups together with an explicit --type. Each group is
one vdev; the pool stripes across them:
--type applies inside each --data group, not to the pool as a whole: every
group becomes one vdev of that layout, and the pool stripes across the vdevs.
All groups share the same --type; pg zfs cannot mix, for example, one mirror
vdev and one raidz2 vdev in a single create. Use pg zfs raw zpool create for a
mixed topology.
Capacity is the sum of the vdev capacities. Redundancy is per vdev: losing more disks than one vdev can tolerate takes down the whole pool.
SLOG, special, cache and spares
--special follows --type (a mirror here). --log with two or more devices is
a mirror; a single device is a stripe and is warned about. --cache and
--spare take independent device lists.
A device may appear only once across data, special, log, cache and spare.
Add a spare to an existing pool with pool add:
Default properties
Unless --no-defaults is given:
| Object | Property | Value |
|---|---|---|
| pool | ashift |
12 |
| pool | autoexpand |
on |
| pool | autotrim |
on when every data disk is non-rotational and reports discard support |
| root dataset | compression |
zstd |
| root dataset | atime |
off |
| root dataset | xattr |
sa |
| root dataset | acltype |
posixacl |
| root dataset | dnodesize |
auto |
Override pool properties with --prop/-p (maps to zpool -o) and root dataset
properties with --fs-prop/-P (maps to zpool -O):
-o is pgcli’s global output flag, which is why the pool/dataset properties use
-p/-P. Inside pg zfs raw the native flags work normally.
Managing pools
Growth and repair:
attach targets mirror vdevs. Adding disks to a raidz vdev (raidz expansion)
requires OpenZFS 2.3 or newer; Debian 12 (2.1) and Ubuntu 24.04 (2.2) cannot do
it. Use pg zfs raw zpool attach on a recent release if you need it.
Unmounting and destroying a pool
pg zfs pool export unmounts every dataset and detaches the pool from the
host, but keeps all data on the member disks:
An exported pool is not imported at boot. Attach it again by listing the importable pools and importing it:
pg zfs pool destroy deletes the pool, every dataset and all data:
destroy only works on an imported pool. If the pool was exported, import it
first:
Without --force both commands ask for confirmation; EOF and the default
answer mean “no”. Non-interactive automation must pass --force. --force
only skips the prompt — it is never passed to ZFS as -f and does not bypass
safety checks. Export is reversible; destroy is not.
Persistence and boot
Pools and datasets survive reboots without any extra steps:
pg zfs installenableszfs-load-module,zfs-import-cache,zfs-mountandzfs-zed(when the unit exists).zpool createrecords the pool in/etc/zfs/zpool.cache; on bootzfs-import-cacheimports it.- ZFS does not use
/etc/fstab. Each dataset’smountpointis a ZFS property, andzfs-mountmounts them at boot. A pool’s root dataset mounts at/<pool>, and child datasets mount under it (for example/fast/data). - The pool state lives on the member disks, so data survives a crash or power loss even if the host did not shut down cleanly.
Verify the setup:
After a reboot pg zfs pool list should list the pool and findmnt /fast
should show it mounted.
On real hardware, create pools from stable paths such as /dev/disk/by-id/....
/dev/sdX names can change across boots, and a pool whose devices are missing
will not import. Run pg zfs pool export tank before moving disks to another
host; the destination can then adopt them with pg zfs pool import tank.
Datasets and snapshots
A dataset with children requires --recursive to destroy. Rollback discards all
changes made after the snapshot.
Rollback
pg zfs snapshot rollback tank/data@snap1 returns the dataset to the state
captured by the snapshot; every change made after it is discarded. If newer
snapshots exist, ZFS refuses the rollback unless --recursive is given, which
deletes those newer snapshots. Clones that depend on the snapshot also block the
rollback — remove them first, or use pg zfs raw zfs rollback -R (which
destroys the dependent clones). Non-interactive runs need --force to skip the
confirmation prompt.
Clone
pg zfs dataset clone tank/data@snap1 tank/clone creates a new, writable
dataset from the snapshot. Unchanged blocks are shared copy-on-write, so the
clone does not duplicate the full data. Pass --prop to set properties on the
new dataset:
While a clone depends on a snapshot, the snapshot cannot be destroyed. Promote
the clone (pg zfs raw zfs promote tank/clone) to make it independent of the
original dataset before removing the old snapshot.
Raw passthrough
raw bypasses every safety check and confirmation. It is the escape hatch for
options pgcli does not wrap — treat it exactly like running zpool/zfs as
root. Binary pipelines work too, for example:
Permissions
Every command that invokes zpool or zfs first checks privileges:
-
root: runs directly;
-
non-root with working
sudo -n: all ZFS commands run throughsudo -n; -
otherwise: fails immediately with
pgcli never prompts for a sudo password. pg zfs status and pg zfs disk list
still show static information without privileges, marking pool details as
unavailable.
Command reference
| Command | Purpose |
|---|---|
pg zfs install |
Install ZFS (zero-argument, idempotent) |
pg zfs status |
Distribution, package, module and pool overview |
pg zfs disk list [--all] |
Whole disks, mounts, ZFS membership, safety |
pg zfs pool create NAME DEVICE... |
Create a pool |
pg zfs pool list |
List pools |
pg zfs pool status [NAME] [--brief] |
Detailed status |
pg zfs pool add NAME DEVICE... |
Add vdevs |
pg zfs pool attach/detach NAME DEVICE [NEW] |
Grow/shrink a mirror |
pg zfs pool replace NAME OLD NEW |
Replace a disk |
pg zfs pool offline/online NAME DEVICE |
Take a disk out/return it |
pg zfs pool export/import [NAME] |
Move a pool between systems |
pg zfs pool scrub NAME [--stop] |
Verify data integrity |
pg zfs pool destroy NAME |
Destroy a pool and all data |
pg zfs dataset create/list/set/clone/destroy |
Dataset lifecycle |
pg zfs snapshot create/list/rollback/destroy |
Snapshot lifecycle |
pg zfs raw zpool/zfs ARGS... |
Unwrapped passthrough |
Troubleshooting
pg zfs install cannot find packages. Check that the archive repository is
reachable and that contrib/universe is enabled. The installer edits only
distribution archive sources (never third-party repositories) and leaves a
*.pgcli.bak next to each modified file.
DKMS build fails. Confirm headers for the running kernel are installed
(linux-headers-$(uname -r) on Debian/Ubuntu, kernel-devel-$(uname -r) on
Fedora/Rocky). The installer reports the build log location; it never reboots or
changes the kernel. On very small VMs, add swap before installing.
Secure Boot is enabled. DKMS modules must be signed. pgcli reports the condition but does not enroll keys; sign the module with your MOK key or disable Secure Boot for the test host.
Module is not loaded. modprobe zfs then pg zfs status. The installer
enables zfs-load-module, zfs-import-cache, zfs-mount and zfs-zed so pools
import automatically on boot.
A disk is rejected because it has partitions or an old label. If it really is free, clear the leftovers:
On OpenZFS 2.2 and newer, clearing the whole disk can fail with
failed to clear label for /dev/vdb when the label lives in the ZFS member
partition. In that case clear the partition instead, then wipe the table:
Never do this to a disk that belongs to an imported pool — pg zfs disk list
shows the owning pool, and the safety check refuses such disks.
SELinux (Fedora/Rocky). ZFS mountpoints use standard file contexts. If a
service cannot access a dataset, check ausearch/journalctl for AVC denials
instead of disabling SELinux globally.
Pools do not come back after reboot. systemctl status zfs-import-cache
and zpool import as root. Pools created from /dev/sdX names follow device
renames; use stable paths (/dev/disk/by-id/...) when creating pools on real
hardware.
18.2 - PGMQ Queues
pgmq turns PostgreSQL into a durable message queue: no Redis, RabbitMQ or
background worker, just tables and SQL functions in the pgmq schema.
Producers send JSON messages, consumers read them with a visibility timeout,
and messages stay in the queue until they are explicitly deleted or archived.
Install
pgcli installs the extension package from the Pigsty repository and runs
CREATE EXTENSION IF NOT EXISTS pgmq in the instance’s default database:
To use it from another database in the same instance:
Check the installed version:
Quick start
Run the same statements through pgcli:
Queues on disk
Each queue is a table in the pgmq schema:
| Object | Purpose |
|---|---|
pgmq.q_<name> |
live messages |
pgmq.a_<name> |
archive populated by pgmq.archive |
pgmq.meta |
queue registry used by list_queues and metrics |
A live message row contains msg_id, read_ct (number of deliveries),
enqueued_at, vt (visible again at) and message jsonb. Recent versions add
last_read_at and headers.
Because messages are rows, pgmq.send participates in transactions: it commits
or rolls back with the surrounding statement. Queue traffic is also ordinary
table traffic — high send/delete rates bloat the table like any other.
Sending messages
send returns the new msg_id; send_batch returns one id per message. Use
send_batch instead of looping over send — it is one round trip and the whole
batch is atomic.
Payloads must be valid JSON and are stored as jsonb: keys are reordered and
duplicates removed. Keep large blobs out of the queue.
Newer versions accept a headers jsonb argument for metadata such as a trace id
or a FIFO group key.
Reading and acknowledging
read(queue, vt, qty) returns up to qty visible messages and makes them
invisible for vt seconds. Reading does not remove anything, so each message
must be acknowledged:
pgmq.pop('jobs') reads and deletes in one call. Use it only when losing an
in-flight message on a consumer crash is acceptable.
Avoid a tight polling loop in long-running consumers:
This waits up to max_poll_seconds (5) for messages, checking every
poll_interval_ms (100) milliseconds, and returns as soon as a message arrives.
Visibility timeout and retries
The visibility timeout is PGMQ’s delivery model:
readsetsvt = now() + timeoutand incrementsread_ct.- The message stays invisible to other consumers until
vt. deleteorarchiveremoves it. If the consumer crashes or does not acknowledge in time, the message becomes visible again aftervtand is delivered to another consumer.
Delivery is therefore at-least-once across failures. Choose vt longer than
the worst-case processing time, and make consumers idempotent. For long jobs,
extend the lease without re-reading:
read_ct is a ready-made retry counter.
Archive and dead letters
delete is permanent. archive moves a message to pgmq.a_<queue> for
retention, audit or replay:
A simple dead-letter pattern is to archive messages that failed too many times:
Without a rule like this, a poison message is redelivered forever.
Monitoring
metrics reports queue_length, queue_visible_length, the age of the newest
and oldest messages, and total_messages. Older extension versions return a
smaller set of columns.
The queue table can be queried directly:
Alert when queue_length keeps growing or oldest_msg_age_sec climbs: either
consumers are down or they cannot keep up.
Maintenance
create_unloggedtrades crash safety for write speed. Use it only for data that can be regenerated.create_partitionedrequires thepg_partmanextension. It keeps a very high-throughput queue from bloating a single table; old partitions are dropped automatically.purge_queueempties a queue but keeps it;drop_queueremoves it entirely.- Queue names are limited to 47 characters.
With ordinary queues, heavy send/delete traffic produces dead tuples. Make sure autovacuum keeps up, or archive messages instead of deleting them and consider partitioned queues at high volume.
Permissions
PGMQ functions run with the caller’s privileges. An application role needs access to the schema, the functions and the queue tables:
Run pgmq.create as the queue owner; creating tables requires privileges the
application role usually should not have. New queues need their own grants, or
you can pre-configure default privileges.
Function reference
| Function | Purpose |
|---|---|
pgmq.create(name) |
Create a queue |
pgmq.create_unlogged(name) |
Create an unlogged queue |
pgmq.create_partitioned(name, ...) |
Create a partitioned queue |
pgmq.send(name, msg [, delay]) |
Send one message, returns msg_id |
pgmq.send_batch(name, msgs jsonb[]) |
Send a batch atomically |
pgmq.read(name, vt, qty) |
Read and hide messages |
pgmq.read_with_poll(name, vt, qty, ...) |
Long-poll read |
pgmq.pop(name [, qty]) |
Read and delete in one call |
pgmq.delete(name, id) / pgmq.delete(name, ids[]) |
Acknowledge and remove |
pgmq.archive(name, id) / pgmq.archive(name, ids[]) |
Move to the archive table |
pgmq.set_vt(name, id(s), seconds) |
Extend the visibility timeout |
pgmq.list_queues() / pgmq.metrics(name) |
Inspect queues |
pgmq.purge_queue(name) |
Delete all messages, keep the queue |
pgmq.drop_queue(name) |
Remove the queue and its archive |
Troubleshooting
extension "pgmq" is not available. The extension package is not in the
instance image, or the extension has no build for this database. Install it with
pg extension install pgmq -i <instance> and create it in the target database.
Messages are redelivered. Processing took longer than vt, or the consumer
exited before delete. Increase vt, call set_vt during long jobs, and make
processing idempotent.
Queue length never drops. Consumers are failing or too slow. Inspect
read_ct, application logs and metrics().oldest_msg_age_sec. PGMQ retries
failed messages forever unless they are archived or deleted.
Performance degrades over time. Default queues are ordinary tables. Check
dead tuples and autovacuum, batch sends and reads, and consider
create_partitioned for sustained high throughput.
permission denied for schema pgmq. Grant USAGE on the schema and
EXECUTE on the functions to the application role, plus table privileges on
each queue it uses.
18.3 - SQLite + Litestream
pg sqlite manages SQLite databases directly on a Linux host and can replicate
their WAL changes to S3-compatible object storage with
Litestream. SQLite runs as a normal host process — there
is no container, no Go SQLite driver and no cgo. The sqlite3 CLI executes all
SQL; Litestream runs as one detached process per database.
Replication is asynchronous (roughly one second of RPO with the default sync interval). It is continuous backup, not high availability: there is still a single writer, and a failover means restoring a backup somewhere else.
Only Linux is supported.
How it works
- Every managed database has an entry in
pg.yamland a per-name state directory under<base>/sqlite/<name>/. pg sqlite createenablesjournal_mode=WAL, which is persistent in the database header.pg sqlite replication enablerenders a dedicatedlitestream.yml(mode0600) and startslitestream replicatein the background. Litestream tails the WAL and uploads LTX objects to the configured bucket.- S3 credentials are written only to that
litestream.yml. They are never stored inpg.yamland are never printed by anypg sqlitecommand.
Install
The command is idempotent and installs two host binaries:
sqlite3from the distribution package manager if it is not already onPATH(apt-get, dnf, yum, pacman, zypper or apk). Only this step may need root; pgcli uses passwordlesssudo -nand fails with a clear message instead of prompting for a password.- Litestream
0.5.17, downloaded from GitHub and verified against the published SHA256 checksum. It goes to/usr/local/binwhen writable, otherwise to~/.local/bin.
Useful flags:
Litestream 0.3.x is not compatible with the generated configuration; the
installer ignores older binaries on PATH and installs 0.5.x.
Create and manage databases
create never truncates an existing file: pointing it at an existing SQLite
database adopts the file, enables WAL and leaves the contents untouched.
--path must be absolute and only moves the database file — configuration,
logs, pid and local Litestream state always stay under
<base>/sqlite/<name>/.
info reads journal_mode, page size/count and freelist, then runs
PRAGMA quick_check by default. Use --integrity full for a complete
integrity_check, or --integrity none to skip it on large databases.
remove unregisters the database and stops replication but leaves the database
file in place. Add --clean-data to delete the database, WAL sidecars, local
Litestream state and logs (interactive confirmation; --force skips it).
Remote S3 backups are never deleted automatically.
Enable S3 replication
The bucket must already exist — Litestream does not create buckets. Create it
with mc mb, the cloud console, or another S3 tool first.
Local MinIO for testing
pgcli can host the object store itself with the MinIO addon:
If port 9000 was already taken, use the endpoint reported by
pg addon list instead. Then point replication at the local store:
Any other MinIO or S3-compatible store uses the same flags with its own endpoint and credentials:
AWS S3 with the default credential chain (no keys on the command line):
| Flag | Default | Notes |
|---|---|---|
--s3-bucket |
required | Must already exist |
--s3-endpoint |
AWS default | Complete http(s)://host[:port], no path or credentials |
--s3-path |
sqlite/<name> |
Object key prefix; a leading / is stripped |
--s3-region |
us-east-1 |
|
--s3-access-key / --s3-secret-key |
default chain | Provide both or neither |
--s3-uri-style |
path |
path or virtual-host |
--s3-ca-file |
none | Passed to Litestream as SSL_CERT_FILE for private CAs |
--s3-skip-verify |
false | Disable TLS verification (self-signed endpoints) |
--sync-interval |
1s |
WAL upload interval |
--snapshot-interval |
24h |
Full snapshot interval |
--retention |
24h |
Snapshot retention |
--no-start |
false | Write configuration without starting Litestream |
--force |
false | Replace an existing configuration |
enable verifies that the database is in WAL mode, resolves a 0.5.x
Litestream binary (or uses --litestream-binary PATH), writes litestream.yml
with mode 0600, clears stale local Litestream state, starts the process and
checks that it survives startup. If it exits immediately, the last log line is
printed and the command fails.
Running enable again with identical non-secret settings while replication is
running is a no-op. Different settings are rejected unless --force is given;
--force stops the running process, rewrites the configuration, resets local
state and restarts.
Where the credentials live
| Path | Contents | Mode |
|---|---|---|
<base>/pg.yaml |
name, path, binary/version, endpoint, bucket, prefix, region, URI style, CA file, skip-verify — no keys | 0644 |
<base>/sqlite/<name>/<name>.db |
database file | app default |
<base>/sqlite/<name>/litestream.yml |
full S3 configuration including access/secret keys | 0600 |
<base>/sqlite/<name>/litestream.log |
process log | 0644 |
<base>/sqlite/<name>/litestream.pid |
running pid | 0644 |
<base>/sqlite/<name>/.<db>-litestream/ |
local LTX state | app default |
Back up or copy litestream.yml with the same care as any credential file.
replication disable removes it, so credentials must be supplied again on the
next enable.
Operate replication
status combines the process state with litestream status -json (local
transaction id and WAL size) and shows the newest error line from the log.
If the status query fails, the command still reports process state and exits
successfully.
Check remote backups
pg mc wraps the bundled MinIO client; its flags must come after --. Using
the alias created in the Local MinIO example, list what Litestream has uploaded
under the configured --s3-path:
A populated prefix looks like this:
0000/ holds per-transaction WAL frames; higher-numbered levels (0001/,
0009/, …) are merged and compacted files. An empty prefix right after
enable only means no write has been synced yet — insert a row, wait at least
one --sync-interval (default 1s), then list again. The local transaction id
shown by replication status should match the highest txid in these names.
disable keeps the database, local LTX state and logs, but removes the
replication block from pg.yaml and deletes litestream.yml. The remote
backups remain untouched.
Auto-start on boot
Litestream runs as a detached host process, so it does not survive a reboot on its own. Register the database with the pgcli boot service:
--name is required (SQLite has no default database name). Replication must
already be enabled and its litestream.yml must still exist — the toggle checks
both and fails with the usual re-enable hint otherwise. At boot,
pg start --autostart restarts Litestream after the object-storage addons, so
a same-host MinIO/silo/rustfs/garage/SeaweedFS backend is listening first. The
boot path is start-only: it never rewrites litestream.yml and never touches
the database file.
pg sqlite replication disable clears this flag automatically. See
Auto-start on Boot for the systemd/launchd service details.
Restore
Litestream can restore the latest state or a point in time. Two modes are available:
--timestamp and --txid are mutually exclusive; omitting both restores the
latest state. --timestamp is an absolute instant: write it in UTC with the
Z suffix (for example 2026-01-01T12:00:00Z) so the restore point does not
depend on the host timezone. --target cannot be combined with --restart,
and an existing target requires --force, which first moves the old target and
its -wal/-shm files into <target>.pre-restore-<UTC>/.
Point-in-time recovery walkthrough
Capture a transaction boundary before making the change you want to undo:
LTX object names are <min-txid>-<max-txid>.ltx. A restore by --txid includes
the state up to and including that transaction. Record the txid (or an RFC3339
UTC timestamp taken safely before the change), then restore a copy and verify
it:
The timestamp form follows the same flow:
Use --dry-run first when unsure: it prints the transaction range that would
be used and writes nothing. Restoring to --target does not stop replication,
so traffic can continue while you inspect the copy. As a sanity check, restore
the latest state (no --timestamp/--txid) and confirm that newer rows are
present; only then promote the verified copy with an in-place restore.
In-place restore
Without --target, the configured database is replaced:
The sequence is deliberately conservative:
- Litestream is stopped (a final sync runs) and other applications must have been stopped manually first.
- The current database and its WAL sidecars are moved to
<db>.pre-restore-<UTC>/(confirmation unless--force). - The backup is restored into a temporary directory next to the database, so a failed restore never touches the preserved copy.
- The restored database is checked and switched to WAL if necessary, then moved into place with an atomic rename on the same filesystem.
- Local Litestream state is reset. If reset fails, the stale state directory is removed.
- Replication stays stopped by default.
--restartstarts it again.
Restarting replication into the same --s3-path after a restore can mix the
restored timeline with the old remote data. Prefer re-enabling with a new
--s3-path, or make sure you understand the old timeline before using
--restart.
If the database file is lost entirely, in-place restore works without
--force; the per-database litestream.yml must still exist.
WAL and application settings
Only PRAGMA journal_mode=WAL is persisted in the database file by the CLI.
Tune the following per connection from the application:
Keep in mind:
- SQLite in WAL mode requires a local filesystem. Do not put the database on NFS or SMB.
- There is still one writer at a time. Long write transactions block other writers; long read transactions prevent WAL checkpointing and can make the WAL grow without bound.
- Litestream replicates the WAL, not an application-consistent snapshot. Use transactions for multi-statement invariants.
Troubleshooting
litestream exited during startup — run
pg sqlite replication logs <name> -n 50. The usual causes are a missing
bucket, wrong credentials or an endpoint that is not reachable from the host.
no Litestream 0.5.x binary found — run pg sqlite install. A 0.3.x
binary on PATH is reported but not used.
TLS errors against MinIO/RustFS — use the full http://host:port endpoint
for plain HTTP, or --s3-ca-file / --s3-skip-verify for self-signed HTTPS.
database ... uses journal_mode=delete — the database was not created by
pg sqlite create, or another tool changed the mode. Use
PRAGMA journal_mode=WAL; with no other connections open, then retry enable.
litestream.yml not found — start, status and restore require the
configuration written by enable; re-run enable (credentials are not stored
in pg.yaml).
Limits
- Linux only; the macOS host-binary paths are not implemented.
- Litestream is asynchronous backup, not synchronous replication or HA. The
default RPO is about one second, and a final sync on
stop/disablecan take up to 30 seconds. - One Litestream process and one WAL writer per database.
--clean-dataand in-place restore are destructive; they prompt by default, and the preserved.pre-restore-*directories are left for manual cleanup.
18.4 - TimescaleDB
TimescaleDB is a PostgreSQL extension for time-series workloads. It turns a regular table into a hypertable: rows are partitioned into time-based chunks automatically, while applications keep using ordinary SQL, indexes and transactions.
pgcli installs the Pigsty TSL build of TimescaleDB 2.31.0. Hypertables,
time_bucket, chunk management, columnstore/compression, continuous aggregates and retention policies are available. The TSL permits free internal use but prohibits offering a time-series database as a service.
Use cases
TimescaleDB fits append-heavy data where time is the main access dimension:
- IoT and device telemetry — per-device readings, latest values and time-window aggregates.
- Infrastructure and application monitoring — CPU, memory, latency and request metrics for dashboards and alerting.
- Industrial and energy metering — high-frequency sensor streams with a long retention window.
- Financial market data — ticks, quotes and trades; OHLC-style rollups with
time_bucketandfirst/last. - Product and event analytics — user events, sessions and API calls aggregated over recent time windows.
- Smart home and building automation — sensor history and state changes.
The typical shape is append-only rows keyed by (entity_id, time): queries
filter a recent time range, and old data is expired by dropping whole chunks.
It is a poor fit for OLTP workloads without a time dimension, key-value or document storage, and blob storage. The TSL build includes compression, continuous aggregates and retention policies, but the TSL does not allow offering a time-series database as a service.
Install
pgcli builds an extension image with the Pigsty TSL package, puts
timescaledb first in shared_preload_libraries, restarts PostgreSQL and runs
CREATE EXTENSION IF NOT EXISTS timescaledb in the instance’s default
database.
To use it from another database in the same instance:
Check the installed version and license:
The Pigsty package reports timescale.
Quick start
1. Create an instance
pg create writes the configuration and prints the admin password; pg start
brings up PostgreSQL and the backup container. The default database is
tsdb_db.
2. Install the extension
pgcli builds the extension image, preloads timescaledb, restarts PostgreSQL
and runs CREATE EXTENSION IF NOT EXISTS timescaledb in tsdb_db.
3. Create a hypertable
Connect to the instance:
Create a table and convert it to a hypertable:
Or create it in one step; the default chunk interval applies and can be changed afterwards:
Choose the chunk interval to fit the query and retention window. Existing chunks keep their interval when you change it later.
4. Write data
5. Daily operations
Append new rows, inspect chunks and jobs, and enable automatic maintenance:
Operational commands:
See Compression and columnstore, Retention policies, Continuous aggregates and Managing chunks for the full set.
InfluxDB-compatible API: the influx2tsdb addon
If existing tooling speaks InfluxDB rather than SQL, the influx2tsdb addon runs a managed, stateless proxy that serves the InfluxDB 1.x HTTP API (Line Protocol writes and InfluxQL queries) on top of a TimescaleDB database:
- each InfluxDB database becomes a PostgreSQL schema, and each
measurement becomes a hypertable with
timeas the partition column; CREATE / ALTER / DROP RETENTION POLICYis translated into TimescaleDB’s ownadd_retention_policy/add_compression_policyjobs — nopg_cronis needed, and chunk cleanup continues while the proxy is stopped;- the proxy holds no data, so backup, PITR, replication and HA remain ordinary PostgreSQL operations.
The trade-off is throughput: every request is translated to SQL, so pure time-series ingest and query load is slower than native InfluxDB. The proxy has no authentication or TLS; front it with nginx or isolate the port. See the addon page for flags, the data model, retention semantics and the test scripts.
Query patterns
First and last value per series:
Latest row per series:
Daily buckets in a specific timezone:
Fill gaps in a bucket sequence:
time_bucket_gapfill needs an explicit start and finish, either as the second
and third arguments or in the WHERE clause.
Compression and columnstore
Older chunks can be moved to the columnstore, which compresses them and speeds up analytical scans:
Check the compression status and the space used:
Retention policies
Expire chunks automatically once they fall outside the retention window:
These jobs run in TimescaleDB’s own background scheduler; pg_cron is not required.
The influx2tsdb addon uses the same mechanism: its
CREATE / ALTER / DROP RETENTION POLICY statements are translated into these jobs
automatically, and cleanup continues while the proxy is stopped.
Continuous aggregates
Pre-aggregate recent data and keep the rollup refreshed in the background:
Query the rollup like a normal table:
The policy refreshes only the recent window between start_offset and
end_offset; refresh older ranges manually after a backfill.
Managing chunks
Chunk intervals
Set the interval at creation, or change it later for new chunks:
For timestamptz columns, chunk boundaries align to UTC. Choose an interval
large enough that a chunk fits comfortably in memory; a very small interval
creates many chunks and adds planning overhead.
List chunks
older_than and newer_than filter by the chunk’s time range, not by its
creation time.
Chunk sizes
Split a chunk
Split one chunk at a timestamp inside its range; take the chunk name from
show_chunks('sensor'):
split_chunk is a procedure; use CALL, not SELECT.
Remove chunks
drop_chunks removes files instead of producing dead tuples. Avoid deleting
large time ranges with DELETE. For automatic expiration prefer a retention
policy; pg_cron is only needed for a custom schedule:
Manual columnstore conversion
With compression settings configured (see Compression and columnstore), convert a single chunk without waiting for the policy:
Both procedures take a chunk name, not the hypertable name.
Metadata views
jobs also lists the columnstore, retention and continuous-aggregate policies
created above, alongside the internal job-history task.
Feature availability
| Feature | Status |
|---|---|
Hypertables, time_bucket, first/last, show_chunks, drop_chunks, hypertable_size, metadata views |
✅ Apache |
Space partitioning with add_dimension / by_hash |
✅ Apache |
Columnstore and compression (ALTER TABLE ... SET (timescaledb.compress), CALL add_columnstore_policy) |
✅ TSL |
Continuous aggregates (CREATE MATERIALIZED VIEW ... WITH (timescaledb.continuous)) |
✅ TSL |
Retention policies (add_retention_policy) |
✅ TSL |
time_bucket_gapfill |
✅ TSL |
Rows marked ✅ Apache follow Apache-2.0. Rows marked ✅ TSL come from the Timescale License: free for internal use, but offering a time-series database as a service is not permitted.
Backup and restore
Logical backups and pg export/pg import work as usual. A physical restore
(pgBackRest, used by pg backup/pg restore) also needs TimescaleDB’s restore
hooks:
pg restore does not run these automatically, so run timescaledb_post_restore()
after the restore completes. Test the procedure before relying on it.
Permissions
CREATE EXTENSION requires a superuser; pg exec connects as the instance
admin. The owner of a table can convert it with create_hypertable and manage
its chunks. Application roles need only ordinary table privileges.
Troubleshooting
extension "timescaledb" is not available. The package is missing from the
instance image, or the extension was not created in the target database. Run
pg extension install timescaledb -i <instance> and create it in that database.
timescaledb is not preloaded. The extension is in
shared_preload_libraries but PostgreSQL was not restarted. pg extension install handles this; verify with SHOW shared_preload_libraries; and restart
the instance.
License errors for a function. The instance image still contains the
Apache build (an older pgcli release or a manually installed PGDG package).
Reinstall with pg extension install timescaledb -i <instance> --auto-restart
to switch to the Pigsty TSL package.
cannot create a unique index without the column "time". Every unique
index and primary key on a hypertable must include the partitioning column:
Too many chunks. Lower the chunk interval with
set_chunk_time_interval; existing chunks are not rewritten.
Old data does not disappear. Add a retention policy with
add_retention_policy, or run drop_chunks manually. Check
timescaledb_information.jobs for the policy job.
Function reference
| Function | Purpose | Status |
|---|---|---|
create_hypertable('t', by_range('time')) |
Convert a table to a hypertable | ✅ |
by_range('time', INTERVAL '1 day') |
Time partitioning with a chunk interval | ✅ |
set_chunk_time_interval('t', INTERVAL ...) |
Change the interval for new chunks | ✅ |
time_bucket('1 hour', time [, timezone]) |
Time-based aggregation buckets | ✅ |
first(value, time) / last(value, time) |
First/last value in a group | ✅ |
show_chunks('t' [, older_than/newer_than => ...]) / drop_chunks('t', ...) |
Inspect, filter and drop chunks | ✅ |
hypertable_size('t') / hypertable_detailed_size('t') |
Hypertable size and breakdown | ✅ |
add_dimension('t', by_hash('col', N)) |
Add space partitioning | ✅ |
add_retention_policy(...) / remove_retention_policy(...) |
Automatic chunk expiration | ✅ TSL |
CALL add_columnstore_policy(...) |
Columnstore/compression policy | ✅ TSL |
CALL convert_to_columnstore(chunk) / CALL convert_to_rowstore(chunk) |
Manual chunk storage conversion | ✅ TSL |
hypertable_columnstore_stats('t') / chunk_columnstore_stats('t') |
Columnstore statistics | ✅ TSL |
CREATE MATERIALIZED VIEW ... WITH (timescaledb.continuous) |
Continuous aggregates | ✅ TSL |
add_continuous_aggregate_policy(...) |
Continuous aggregate refresh policy | ✅ TSL |
time_bucket_gapfill(...) |
Fill missing buckets | ✅ TSL |
19 - SQL Commands
Two ways to run SQL against an instance: pg exec for one-shot SQL or container commands, pg psql for interactive sessions, pg shell for an interactive bash shell inside the container.
pg exec
SQL mode (default)
Arguments without -- are executed as SQL via psql, using the instance’s configured user and database.
Container command mode (after –)
Arguments after -- are run directly inside the container (as root).
Remote database (–dsn)
Execute SQL against any database reachable via a connection string, using a temporary container. --dsn only supports SQL mode; container commands require a local instance.
pg psql
Interactive session
Inside the shell you get full psql features: SQL with history and tab completion, meta-commands (\dt, \du, \l), and \q to quit.
Non-interactive (scripts)
Switch to postgres superuser
Some administrative tasks (e.g., creating certain extensions, modifying system-level settings) require postgres superuser privileges. Use -- to pass psql arguments and switch user:
Recommended approach: Use the instance default user (admin) for daily operations, and switch to postgres only when superuser privileges are needed. This is safer and more convenient than modifying config files or restarting containers.
Example scenarios:
Remote database (–dsn)
pg shell
Open an interactive bash shell inside the PostgreSQL container.
Use cases:
- Inspect PostgreSQL data directory and log files
- Run container-internal commands (e.g.,
psql,pg_isready,pg_repack) - Debug extension installations and configuration files
Rules
--dsnand--instanceare mutually exclusive: the connection string determines host, port and database, so-iis rejected to avoid silent misuse.- With
--dsn, the database is the path part of the URL:postgres://user:pass@host:5432/mydbconnects tomydb. To use another database, change the path. - With a local instance,
--passes raw psql arguments (including-d/-U), overriding the instance defaults.
20 - Destroy
Destroy stops and removes the container, then removes the instance from the configuration file. By default, host data directories are preserved. Use --clean-data to also remove data, WAL archives, and the pgBackRest repository stanza.
Basic Usage
What Happens
When you run pg destroy:
- Stop container — PostgreSQL is gracefully shut down
- Remove container — The Podman container is deleted
- Remove configuration — The instance entry is removed from
~/.pgcli/pg.yaml - Preserve data (by default) — Host data directory at
--base-dir/<instance>is kept
With --clean-data:
- All of the above, plus:
- Remove host data — The data directory is deleted
- Remove WAL archives — Any WAL files on the host are removed
- Remove backup stanza — The pgBackRest repository stanza for this instance is removed
Recreating an Instance
After destroying, you can recreate the instance with a fresh start:
Important: Without --clean-data, the old data directory is preserved. When you recreate the instance, PostgreSQL will use the existing data, and init.sh (which creates users and sets the admin password) does not run again. This can cause issues if:
- The data was created with a different user or password
- You changed the default user in configuration
- You want a completely fresh start
Use --clean-data when you need a clean slate.
Confirmation Prompt
By default, pg destroy asks for confirmation before proceeding:
Use --force to skip the prompt:
Use Cases
1. Clean Restart After Configuration Change
If you changed the default user, password, or PostgreSQL version in the config, destroy and recreate:
2. Remove Test Instance
After testing, remove an instance you no longer need:
3. Troubleshooting Corrupted State
If an instance is in a bad state (e.g., failed to start, corrupted data), destroy and recreate:
4. Free Up Resources
Destroy instances you’re not actively using to free up:
- Podman containers (CPU and memory)
- Host disk space (with
--clean-data) - Configuration file entries
Relationship with Replicas
When destroying a replica, the replication slot on the primary is not automatically removed. You need to clean it up separately:
This two-step process ensures you don’t accidentally lose the slot if you plan to recreate the replica later.
Safety Considerations
- Data Loss:
--clean-datapermanently deletes all data, WAL, and backups for the instance. Use with caution. - No Undo: Once destroyed, the instance cannot be recovered unless you have external backups.
- Configuration Loss: The instance entry is removed from the config file. If you need the configuration later, back it up first.
Flags
| Flag | Default | Description |
|---|---|---|
--force |
false |
Skip confirmation prompt |
--clean-data |
false |
Also remove host data, WAL archives, and backup stanza |
-i, --instance |
default |
Instance name to destroy |
Related Commands
pg create— Create a new instancepg start— Start an instancepg stop— Stop an instance (container remains)pg replica drop— Remove replication slot from primary
21 - Logs
The pg logs command displays console output logs from PostgreSQL instances and addon components (such as PgBouncer connection poolers and etcd members).
PostgreSQL Instance Logs
View recent logs
Follow logs in real-time
Show all available logs
Default behavior: Without -i, logs show the default instance.
Addon Logs
Addon logs (e.g., PgBouncer connection poolers) use the addon subcommand.
Local addons
View logs for addons attached to a local PostgreSQL instance:
Remote addons
View logs for standalone PgBouncer instances targeting remote databases:
etcd members
etcd is a top-level infra addon — select the member with --name (defaults
to etcd):
PgDog proxies
PgDog is also a top-level infra addon — select the proxy with --name
(defaults to pgdog):
PgDog logs connection-pool events (new server connections, auth, client connect/disconnect) as structured INFO lines.
HAProxy instances
HAProxy is also a top-level infra addon — select the instance with --name
(defaults to haproxy):
HAProxy logs health-check transitions (Server lb_rw/node2 is UP/DOWN, reason: ...) and connection events — useful for watching failovers live.
MinIO instances
MinIO is also a top-level infra addon — select the instance with --name
(defaults to minio):
MinIO logs to stdout: startup lines (the API: / Console: addresses it is
serving) and request errors.
Patroni members
Patroni is not an pg addon-managed component — it is a top-level pg ha
cluster — so it has its own shorthand, pg logs ha <scope> --member <name>:
pg logs ha is equivalent to pg logs addon patroni --scope <scope> --name <member>; the scope is required and the member name selects one node within
that cluster.
Patroni members log the HA loop each cycle (no action. I am (node1), a secondary, and following a leader (node3)) plus PostgreSQL startup/recovery
output — useful for spotting a stuck replica (repeating record with incorrect prev-link / waiting for WAL to become available).
Options
| Option | Short | Description |
|---|---|---|
--follow |
-f |
Follow log output (like tail -f) |
--tail N |
-n N |
Show last N lines (default: 50, use 0 for all) |
--instance NAME |
-i NAME |
Instance name (default: default) |
--pg-name NAME |
Remote addon name (for remote PgBouncer) | |
--name NAME |
etcd member / PgDog proxy / HAProxy / MinIO instance name (default: etcd / pgdog / haproxy / minio); Patroni member name (required with --scope) |
|
--scope NAME |
Patroni cluster scope (only for addon type patroni) |
|
--member NAME |
-m NAME |
Patroni member name (only for pg logs ha <scope>) |
Examples
Notes
- PostgreSQL logs include query execution, connection events, and system messages
- Addon logs show connection pooler activity (connections, disconnections, pool stats)
- etcd member logs are JSON-formatted raft/leader/peer events — useful for debugging quorum issues
- PgDog proxy logs are structured INFO lines for connection-pool events (server connections, auth, client connect/disconnect)
- HAProxy logs health-check transitions and connection events — watch a failover happen in real time
- MinIO logs startup lines and request errors to stdout
- Follow mode (
-f) keeps the connection open until interrupted with Ctrl+C - Remote addons use
--pg-nameinstead of-ito identify the target pooler; etcd members, PgDog proxies, HAProxy instances, and MinIO instances use--name
22 - Administration
Shell Completion
Enable tab completion for commands, flags, and instance names.
Bash
Zsh
Fish
PowerShell
PostgreSQL Configuration
Modify PostgreSQL runtime parameters via pg exec with ALTER SYSTEM, then reload:
Note: Some parameters (e.g. shared_buffers, max_connections) require a restart rather than reload. Use pg stop && pg start to apply those changes.
Configuration File Management
Inspect or validate the config file (~/.pgcli/pg.yaml by default, override with -c).
Init generates a default config; --add creates a named instance in the same file, -o writes to a custom path:
--backup-dir puts backup data and logs on a different path from instance data.
This is the typical production setup: PG data on one disk, backups on another,
so a data-disk failure does not cascade into backup loss.
Isolation parameters for running multiple configs on one host (see Quick Start for planning):
| Parameter | Default | Meaning |
|---|---|---|
--namespace |
default |
Prefix for container names: pgcli-pg-<namespace>-<instance>, backup container pgcli-backup-<namespace>. Pass --namespace "" to keep legacy names without a prefix |
--base-dir |
~/.pgcli |
Base directory for all data paths (instance data, addon config, backup) |
--backup-dir |
(under --base-dir) |
Separate base directory for backup data/log. Overrides the backup path set by --base-dir |
--pg-start-port |
35432 |
First PG host port in the allocation range |
--pg-ssh-port |
42201 |
First SSH host port in the allocation range |
All saved into the config file (namespace, pg_start_port, pg_ssh_port); use disjoint port ranges across configs so allocations never collide.