Skip to content

This is the multi-page printable view of this section. .

Return to the regular view of this page.

Documentation

Learn how to use pgcli to manage PostgreSQL databases.

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

Which pgcli components and features work on Linux vs macOS

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 over 127.0.0.1. This is the zero-overhead path and the only one that supports cross-host topologies.
  • macOS runs Podman inside a podman machine VM. 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 on 127.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-net and publish their ports. Client connections are still 127.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, not 127.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. The podman machine VM’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-net with its API and console ports published, so the Mac reaches both on 127.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-net bridge 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/redis image 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, per listen); on macOS it joins pgcli-net with the port published and pg redis-cli dials host.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/amd64 emulation, and the macOS podman machine path does not exist.
  • pgAdmin 4 — Linux, tested; macOS expected. A plain upstream docker.io/dpage/pgadmin4 image with no wrapper — the image’s own entrypoint chowns /var/lib/pgadmin to uid 5050 and drops privileges, so pgcli passes --user 0 and never chowns on the host (unlike rustfs). On Linux it serves over host networking on pgadmin_start_port (loopback by default); on macOS it joins pgcli-net with the port published and its listen widened to 0.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/grafana image with no wrapper. The image bakes USER 472 and its entrypoint does no ownership work at all, so pgcli chowns the host data dir to 472:0 itself before creating the container — via podman unshare chown under rootless podman, or a direct recursive chown when pgcli runs as root on a rootful host. On Linux it serves over host networking on grafana_start_port (on every interface by default); on macOS it joins pgcli-net with the port published and its listen widened to 0.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/prometheus image with no wrapper, the same ownership shape as Grafana (baked USER nobody, uid/gid 65534, no entrypoint fixup) — pgcli pre-chowns the host TSDB dir via podman unshare chown 65534:65534 under rootless podman, or a direct recursive chown when pgcli runs as root on a rootful host. Unlike Grafana it is configured by a bind-mounted prometheus.yml (rendered, or copied from --conf-file) plus CLI flags after the image name, not env vars. On Linux it serves over host networking on prometheus_start_port (on every interface by default) — with no authentication behind that default, so unlike Grafana’s password-guarded 0.0.0.0, this exposure is guarded only by the network boundary. On macOS it joins pgcli-net with 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/alertmanager image, the same ownership shape (baked USER nobody, uid/gid 65534, no entrypoint fixup — pgcli pre-chowns the host state dir), configured by a bind-mounted alertmanager.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+UDP 9094), and unlike Prometheus the unauthenticated POST /-/reload route 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: Listen defaults to 0.0.0.0 with no authentication, and reaching the port lets anyone create silences — silently muting every notification the on-call expects. On macOS it joins pgcli-net with 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/gitea image (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 baked git user, uid/gid 1000 (pinned via USER_UID/USER_GID so a host pre-chown cannot drift), no --user flag; pgcli pre-chowns the host /data dir the same way. Config is env-only (GITEA__* folded into the persisted app.ini on every boot) — which is exactly why SECRET_KEY/INTERNAL_TOKEN are 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: --dsn moves the metadata to an external PostgreSQL (never created or dropped by pgcli), and git-over-ssh rides the image’s OpenSSH sshd, which runs on every instance (the s6 service is unconditional). --ssh-port N makes 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. Listen defaults to 0.0.0.0 (a Git host is cloned from other machines), guarded by the generated admin password and closed self-registration. On macOS it joins pgcli-net with 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 host and 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, so pg addon install beegfs fails 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 via pg addon beegfs mount-hint.
  • nginx — both platforms (macOS lightly tested). The HTTP reverse proxy joins pgcli-net on 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 the mc binary from a throwaway container, with your ~/.mc/config.json mounted as its own default config path and local file operands (for cp/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 a 127.0.0.1 alias reaches a local addon instance; on macOS the container sits on the bridge, where a 127.0.0.1 alias is the container’s own loopback — point aliases at host.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:

=== pg start ===
Platform: linux        # or: macOS

2 - Quick Start

Install pgcli and create your first PostgreSQL instance.

Get pgcli up and running in minutes.

Installation

Install pgcli with a single command:

curl -fsSL https://raw.githubusercontent.com/mars-base/pgcli/main/scripts/install.sh | bash

This script will:

  • Download the latest pgcli binary for your platform
  • Install it to /usr/local/bin (or ~/.local/bin if no sudo)
  • Add pgcli to your PATH

Initialize Configuration

# Create the base directory
mkdir -p ~/pg

# Initialize config with a default instance
pg config init --add default --base-dir ~/pg

# Separate data and backup onto different disks (production recommended)
pg config init --base-dir /data/pg --backup-dir /mnt/backup/pg --add default

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

# Start the default instance
pg start

# Check status and connection info
pg status

The output shows the connection URL, admin password, and backup status.

Connect to Your Database

# Using pgcli's built-in psql wrapper
pg psql

# Or connect directly with the connection string shown in pg status
psql postgres://admin:<password>@localhost:35432/admin_db

# Execute SQL directly
pg exec "SELECT version()"

Basic Operations

# List all instances
pg list

# Stop an instance
pg stop

# Stop all instances, backup container, and PgBouncer
pg stop --all

# Start an instance
pg start

# View instance status
pg status

# Execute SQL
pg exec "SELECT version();"

Multi-Instance

# Create additional instances
pg create -i proj01 --base-dir /data/pg
pg create -i proj02 --base-dir /data/pg

# List all instances
pg list

# Start all instances
pg start --all

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.

# Environment "t1": containers prefixed pgcli-pg-t1-*, PG ports from 38000
pg config init -o ~/.pgcli-t1/pg.yaml --namespace t1 --pg-start-port 38000 --pg-ssh-port 43000 --add proj1

# Environment "t2": different namespace and disjoint ports, with separate backup disk
pg config init -o ~/.pgcli-t2/pg.yaml --namespace t2 --base-dir /data/t2 --backup-dir /mnt/backup/t2 --pg-start-port 38100 --pg-ssh-port 43100 --add proj2

# Manage each environment with -c
pg -c ~/.pgcli-t1/pg.yaml start -i proj1
pg -c ~/.pgcli-t2/pg.yaml list
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 --namespace to 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 destroy and re-init.

Interactive psql Session

# Open interactive psql shell (default instance)
pg psql

# Open psql for specific instance
pg psql -i proj01

# Run SQL from stdin (non-interactive, for scripts)
echo "SELECT version();" | pg psql

# Execute a single SQL command
pg psql -- -c "SHOW work_mem"

# Connect to a different database
pg psql -- -d postgres

# Use psql meta-commands
pg psql -- -c "\dt"     # list tables
pg psql -- -c "\du"     # list users
pg psql -- -c "\l"      # list databases

# Connect to a remote database via connection string
pg psql --dsn postgres://user:pass@host:5432/db

Inside the interactive psql shell you get:

  • SQL queries with history and tab completion
  • psql meta-commands (\dt, \du, \l, etc.)
  • \q to quit

Container Shell

# Open bash shell in container (default instance)
pg shell

# Open shell for specific instance
pg shell -i proj01

# Run a command directly
pg shell -- -c "ls -la /var/lib/postgresql/data"

# View logs
pg shell -- -c "tail -f /var/log/postgresql/postgresql-*.log"

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

3 - Namespace Isolation

Create isolated environments on a single host using namespaces

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:

# Create production environment
pg config init \
  --namespace prod \
  --pg-start-port 35432 \
  --pg-ssh-port 42201 \
  --add app-db \
  -o ~/.pgcli-prod/pg.yaml

# Create development environment
pg config init \
  --namespace dev \
  --pg-start-port 38000 \
  --pg-ssh-port 43000 \
  --add app-db \
  -o ~/.pgcli-dev/pg.yaml

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:

# Start production database
pg -c ~/.pgcli-prod/pg.yaml start -i app-db

# Start development database
pg -c ~/.pgcli-dev/pg.yaml start -i app-db

# List instances in production
pg -c ~/.pgcli-prod/pg.yaml list

# List instances in development
pg -c ~/.pgcli-dev/pg.yaml list

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:

namespace: prod
pg_start_port: 35432
pg_ssh_port: 42201

Best Practices

1. Always Use Explicit Namespaces

Never rely on the default namespace when running multiple configs on one host:

# Bad: both configs would use "default" namespace and clash
pg config init --add app -o ~/.pgcli-prod/pg.yaml
pg config init --add app -o ~/.pgcli-dev/pg.yaml  # Conflict!

# Good: explicit namespaces
pg config init --namespace prod --add app -o ~/.pgcli-prod/pg.yaml
pg config init --namespace dev --add app -o ~/.pgcli-dev/pg.yaml

2. Use Disjoint Port Ranges

Ensure port ranges don’t overlap between configs:

# Production: 35432-35999, 42201-42999
pg config init --namespace prod \
  --pg-start-port 35432 \
  --pg-ssh-port 42201 \
  --add app -o ~/.pgcli-prod/pg.yaml

# Development: 38000-38999, 43000-43999
pg config init --namespace dev \
  --pg-start-port 38000 \
  --pg-ssh-port 43000 \
  --add app -o ~/.pgcli-dev/pg.yaml

# Testing: 40000-40999, 44000-44999
pg config init --namespace test \
  --pg-start-port 40000 \
  --pg-ssh-port 44000 \
  --add app -o ~/.pgcli-test/pg.yaml

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:

# Create with namespace "prod"
pg -c ~/.pgcli-prod/pg.yaml start -i app-db
# Container: pgcli-pg-prod-app-db

# Edit config to change namespace to "production"
# This WON'T work - container name mismatch!

# Instead: destroy and recreate
pg -c ~/.pgcli-prod/pg.yaml destroy -i app-db --clean-data
pg -c ~/.pgcli-prod/pg.yaml create -i app-db
pg -c ~/.pgcli-prod/pg.yaml start -i app-db

4. Use Shell Aliases for Convenience

Create aliases to avoid typing -c repeatedly:

# Add to ~/.bashrc or ~/.zshrc
alias pg-prod='pg -c ~/.pgcli-prod/pg.yaml'
alias pg-dev='pg -c ~/.pgcli-dev/pg.yaml'
alias pg-test='pg -c ~/.pgcli-test/pg.yaml'

# Now use:
pg-prod start -i app-db
pg-dev list
pg-test destroy -i test-db --force

Advanced: Multiple Environments with Replicas

You can even set up isolated replication environments:

# Production: primary + replica
pg -c ~/.pgcli-prod/pg.yaml create -i primary --base-dir /data/prod
pg -c ~/.pgcli-prod/pg.yaml replica create replica -i primary

# Development: separate primary + replica
pg -c ~/.pgcli-dev/pg.yaml create -i primary --base-dir /data/dev
pg -c ~/.pgcli-dev/pg.yaml replica create replica -i primary

Each environment maintains its own replication slots, backup stanzas, and data directories.

Troubleshooting

Container Name Conflicts

Error: container "pgcli-pg-default-app-db" already exists

Cause: Two configs using the same namespace.

Solution: Use different namespaces or destroy the conflicting instance first.

Port Already in Use

Error: listen tcp 0.0.0.0:35432: bind: address already in use

Cause: Port ranges overlap between configs.

Solution: Use disjoint port ranges with sufficient spacing.

Instance Not Found After Namespace Change

Error: instance "app-db" exists in config but container not found

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

Backup guide for pgcli

Snapshots

# Create snapshot (full backup)
pg snapshot create -i proj01

# Create differential backup (recommended)
pg snapshot create --type diff -i proj01

# Stream backup container logs during snapshot
pg snapshot create --tail-logs -i proj01

# List snapshots
pg snapshot list -i proj01

# Limit the number of snapshots displayed
pg snapshot list --limit 5 -i proj01

# Delete snapshot
pg snapshot delete 20260826-073712F -i proj01

Snapshot types:

  • full — Complete backup (default, self-contained)
  • diff — Changes since last full backup
  • incr — 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 single backup.repo.s3 endpoint 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 second pg 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.

# Initialize the shared pgbackrest container (build image, create dirs, generate config)
pg backup setup

# Use a custom base directory for backup data and logs
pg backup setup --base-dir /mnt/backup

# Start / stop the backup container
pg backup start
pg backup stop

# Remove the backup container and its generated configs (pgbackrest.conf,
# ssh_config). Backup data and the SSH key pair are kept, so a later
# `pg backup setup` re-attaches to the same repository; --clean-data also
# deletes the local repository, logs and credentials.
pg backup remove
pg backup remove --clean-data

# Show backup container status
pg backup status

# List every stanza the backup container manages (one per PITR instance,
# one per Patroni cluster). These names feed stanza-upgrade below — they are
# repository labels, not container names.
pg backup list-stanza

# Refresh stanza metadata after a data directory was rebuilt (restore,
# recreate, reinit). Backups then fail with "[051] system-id ... do not match
# stanza"; stanza-upgrade re-syncs it without deleting existing backups.
# Pass the stanza name(s) explicitly (upgrade-all is intentionally not offered).
pg backup stanza-upgrade pgcli_default
pg backup stanza-upgrade pgcli_default pgcli_app-default

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:

backup:
  repo:
    s3:
      endpoint: 10.0.0.9:9000     # host:port, no scheme
      bucket: pgbackrest
      access_key: admin
      secret_key: <password>       # stored in pg.yaml like every pgcli secret
      ca_file: /home/you/.pgcli/tls/minio/store/ca.crt   # required for a private CA

Then re-run setup — it performs the archiving orchestration:

pg backup setup
  • 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 whose ca.crt path you put in ca_file above. (silo and rustfs take --tls the same way; garage has no native TLS, so front it with the nginx addon — see Garage.) Publicly-caught endpoints (real AWS S3) just omit ca_file; if you truly cannot supply a CA, verify_tls: false is 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>:9002 pulls 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 to pg 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 a pg cert-minted BYO self-signed leaf (the leaf is the anchor, and is saved as-is). Publicly-trusted endpoints need no ca_file at 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_file and the backup SSH public key only have to be set up on one host: pg backup setup publishes the CA to the cluster’s etcd registry and a joiner with ca_file empty pulls it; each host’s backup public key is likewise published on pg ha create and merged into every member’s authorized_keys by setup. 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_mode needs), then resumes. Each member’s patroni.yml gets the same archive_command — one stanza per cluster, members listed as pg*-host so 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):

pg backup setup --s3-endpoint 10.0.0.9:9000 --s3-bucket pgbackrest \
    --s3-access-key admin --s3-ca-file ~/.pgcli/tls/minio/store/ca.crt

5 - Restore

Point-in-Time Recovery (PITR) guide for pgcli

Point-in-Time Recovery (PITR)

Restore to any point in time after the first backup.

# Restore (read-only, inspect before committing)
pg restore --time "2026-08-26 15:30:00+00"

# Preview what would be restored without executing (dry run)
pg restore --time "2026-08-26 15:30:00+00" --dry-run

# Stream restore container logs during recovery
pg restore --time "2026-08-26 15:30:00+00" --tail-logs

# Try different time if needed
pg restore --time "2026-08-26 15:25:00+00"

# Promote to read-write (switches timeline)
pg restore --time "2026-08-26 15:30:00+00" --promote

# Skip confirmation
pg restore --time "2026-08-26 15:30:00+00" --promote --force

Time formats:

  • 2026-08-26 15:30:00+08:00 — with timezone offset
  • 2026-08-26 15:30:00+08 — timezone hour only
  • 2026-08-26 15:30:00Z — UTC
  • 2026-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.

# Preview the recovery plan without touching the cluster
pg ha restore app --time "2026-08-26 15:30:00+00" --dry-run

# Restore, streaming the bootstrap member's recovery logs
pg ha restore app --time "2026-08-26 15:30:00+00" --tail-logs

# Choose which local member carries the bootstrap (default: first local member)
pg ha restore app --time "2026-08-26 15:30:00+00" --member node1

# Skip the confirmation prompt
pg ha restore app --time "2026-08-26 15:30:00+00" --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-run to confirm the target before executing.
  • Leader-locality pre-check. Before touching anything, pg ha restore reads 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-run only 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 --promote flag — 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)

Replica (read-only standby) guide for pgcli

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.

# Create a replica of the default instance
pg replica create ro1

# Create a replica of a specific instance
pg replica create ro1 -i proj01

# List replicas and replication lag
pg replica list

What happens

  1. Pre-flight check — the primary must be running (verified before any config is written; a stopped primary fails with no side effects)
  2. Register — a new instance entry is added with the primary’s database name and password (see Notes), PITR disabled, and replica_of set to the primary
  3. Replication setup — on the primary:
    • pg_hba.conf gains host replication entries 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
  4. Base backup — pg_basebackup -R copies the primary’s data directory into the replica’s data dir, writing primary_conninfo (with password) and standby.signal so the replica boots in standby mode
  5. Start — the replica container starts and streams WAL continuously

Verify

# Read-only standby?
pg exec -i ro1 "SELECT pg_is_in_recovery()"      # t

# Writes are rejected
pg exec -i ro1 "INSERT INTO t VALUES (1)"         # read-only transaction error

# Streaming is live
pg exec -i ro1 "SELECT pg_is_in_recovery(), now() - pg_last_xact_replay_timestamp()"

# Slot is active on the primary
pg exec -i primary "SELECT slot_name, active FROM pg_replication_slots"

# Overview
pg list                              # ROLE/PRIMARY columns
pg replica list                      # NAME/PRIMARY/STATUS/LAG
pg status -i ro1                     # Role: standby (replica of ...)

Destroy

Destroying a replica is a 2-step process:

# Step 1: Destroy the replica instance (removes container + data + config entry)
pg destroy -i ro1 --clean-data --force

# Step 2: Drop the replication slot on the primary (no-op if already gone)
pg replica drop ro1 -i <primary>

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 — destroy automatically 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:

# ---- on the PRIMARY host: prepare the primary (run first) ----
pg replica create ro1 -i pg01 --replica-host 10.0.0.21

# ---- on the REPLICA host: copy data and start the replica (run second) ----
pg replica create ro1 --primary-dsn "postgres://admin:<password>@10.0.0.20:35432/pg01_db" --primary-name pg01

Getting the primary DSN

If the primary is a pgcli-managed instance, get its connection info with pg status:

pg status -i pg01
# ...
#   Connection: postgres://admin:<password>@127.0.0.1:35432/pg01_db

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 a host replication all <addr> entry to pg_hba.conf and creates the physical slot pgcli_r_<name>, then prints the exact --primary-dsn command to run on the replica host. IPs get a /32 (/128 for 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, runs pg_basebackup from 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:

# 1. on the REPLICA host: removes container + config, also drops the slot
#    on the remote primary via DSN (automatic if primary is reachable)
pg destroy -i ro1

# 2. on the PRIMARY host (only if step 1's DSN connection failed):
#    drop the slot manually
pg replica drop ro1 -i pg01

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):

  1. Allow replication from the replica host in pg_hba.conf, then reload (SELECT pg_reload_conf()):
    host replication <replica user> <replica ip>/32 scram-sha-256
  2. Create the physical slot with the exact name pgcli_r_<replica-name> (the replica side checks this name):
    SELECT pg_create_physical_replication_slot('pgcli_r_ro1');
    Requires wal_level = replica (or logical) and a user with REPLICATION privilege — the DSN user.

Then the replica-side command is unchanged:

pg replica create ro1 --primary-dsn "postgres://<user>:<pass>@<primary ip>:5432/<db>" --primary-name pg01

On destroy, there is no pgcli on the primary side — after pg destroy -i ro1, drop the slot manually:

SELECT pg_drop_replication_slot('pgcli_r_ro1');

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 shows active)
  • Lag display — replica list lag (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 ro1 skips the base backup when the data directory is already initialized

7 - HA Cluster

Patroni high-availability clusters with pgcli — pg ha commands, dynamic configuration, REST API, extensions, cluster backup and restore, direct SQL

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 ha is 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

# one host: bootstrap a cluster (on an installed etcd addon member)
pg ha create app --member node1 --etcd m1
pg ha create app --member node2 --etcd m1

# other hosts register their members with the same password set
pg ha passwords app --file app-passwd.yml
ssh other-host
pg ha create app --member node3 --advertise-host 10.0.0.12 \
    --etcd-endpoints 10.0.0.9:2379 --passwords-file app-passwd.yml

# run SQL directly — the leader is resolved from the DCS
pg ha exec app "SELECT version()"
pg ha psql app

# back it up and be able to go back in time
pg backup setup --s3-endpoint ...
pg ha snapshot create app --type full
pg ha restore app --time "2026-08-26 15:30:00+00"

7.1 - Patroni HA

Run PostgreSQL high availability with Patroni as a pgcli HA mode — automatic failover, switchover, and a DCS-backed cluster

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 ha members do not appear in cfg.Instances, share no instance lifecycle code, and are not created by pg 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 create is 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-running create is fine; for dynamic settings prefer pg ha edit-config, which never touches the container.
  • pg ha start / pg ha stop are 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 pause first (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:

  1. The first pg ha create for a scope bootstraps: Patroni runs initdb, wins the leader race, and becomes the leader.
  2. Every later member automatically pg_basebackups from the current leader and begins streaming — no flag distinguishes “add” from “join”.
  3. pg ha control commands (switchover, pause, …) run patronictl in 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) into patroni.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:

/service/<scope>[-<namespace>]/          # e.g. /service/app/  (no namespace)
                                         #      /service/app-prod/  (namespace: prod)
├── initialize       # bootstrap marker (written once)
├── leader           # current leader; value = member name
├── members/<member> # per-member registration (conn_url, api_url, state)
├── status           # cluster LSN / state
├── config           # dynamic config (pause lives here too)
├── history          # config revision history
├── failover         # manual failover request
└── sync             # synchronous-replication state

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:

ETCDCTL_ENDPOINTS=http://127.0.0.1:2379 pg etcdctl get /service/ -- --prefix --keys-only

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.

# 1. a DCS — here, the etcd addon (see the etcd page for multi-node setups)
pg addon install etcd --name m1

# 2. the first member bootstraps the cluster (becomes leader)
pg ha create app --member node1 --etcd m1

# 3. further members join automatically as replicas
pg ha create app --member node2 --etcd m1
pg ha create app --member node3 --etcd m1

# 4. watch it
pg ha status app

pg ha status app renders patronictl list — exactly one Leader row and the rest Replica … streaming:

+ Cluster: app (7683433951661608987) +-----------+----+-------------+-----+------------+-----+
| Member | Host            | Role    | State     | TL | Receive LSN | Lag | Replay LSN | Lag |
+--------+-----------------+---------+-----------+----+-------------+-----+------------+-----+
| node1  | 127.0.0.1:5432  | Leader  | running   |  1 |             |     |            |     |
| node2  | 127.0.0.1:5433  | Replica | streaming |  1 |   0/3000060 |   0 |  0/3000060 |   0 |
| node3  | 127.0.0.1:5434  | Replica | streaming |  1 |   0/3000060 |   0 |  0/3000060 |   0 |
+--------+-----------------+---------+-----------+----+-------------+-----+------------+-----+

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):

    # host E1 (10.0.0.20) — bootstrap the etcd cluster
    pg addon install etcd --name e1 --cluster prod \
        --advertise-host 10.0.0.20 --client-port 2379 --peer-port 2380
    
    # host E2 (10.0.0.21) — join
    pg addon install etcd --name e2 --cluster prod \
        --advertise-host 10.0.0.21 --client-port 2379 --peer-port 2380 \
        --join http://10.0.0.20:2379
    
    # host E3 (10.0.0.22) — join
    pg addon install etcd --name e3 --cluster prod \
        --advertise-host 10.0.0.22 --client-port 2379 --peer-port 2380 \
        --join http://10.0.0.20:2379
    
    # hosts A / B / C — one Patroni member each; same endpoint list everywhere
    pg ha create app --member node1 --advertise-host 10.0.0.11 \
        --etcd-endpoints 10.0.0.20:2379,10.0.0.21:2379,10.0.0.22:2379

    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-endpoints lists 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.

# host A (10.0.0.11) — bootstrap (its own etcd, or an external DCS)
pg ha create app --member node1 --advertise-host 10.0.0.11 \
    --etcd-endpoints 10.0.0.9:2379,10.0.0.10:2379

# host A — export the generated password set for other hosts
pg ha passwords app --file app-passwd.yml

# host B (10.0.0.12) — join, sharing the SAME DCS and password set
pg ha create app --member node2 --advertise-host 10.0.0.12 \
    --etcd-endpoints 10.0.0.9:2379,10.0.0.10:2379 \
    --passwords-file app-passwd.yml

Cross-host checklist (all three must line up on every host):

  1. Ports — auto-assignment is per-host, but connect_address is stored in the DCS cluster-wide. Pass --host-port / --restapi-port with the same value on every host, or replicas can’t reach each other.
  2. 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.yml and pass --passwords-file app-passwd.yml to every other pg ha create.
  3. --advertise-host — required for cross-host members; it flips the listen address to 0.0.0.0 and puts a reachable IP in connect_address. Left empty, a member is loopback-only. This applies to the first (bootstrap) member too: it becomes the leader, and its loopback connect_address is what every later host would try to pg_basebackup from — 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 first create.
  4. 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:

pg backup setup

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):

pg ha remote app --member node3 --ssh-port 42301   # writes members.<m>.remote_host
pg ha remote remove app --member node3             # undo the registration

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:

pg ha passwords app                          # print the stored set (YAML) to stdout
pg ha passwords app --file app-passwd.yml    # write it to a file (mode 0600) instead

(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:

# app-passwd.yml
superuser: <postgres superuser password>
replication: <replicator password>
rewind: <rewind_user password>
restapi_user: postgres          # optional, defaults to postgres
restapi_password: <REST API basic-auth password>
pg ha create app --member node1 --etcd m1 --passwords-file app-passwd.yml

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.

pg autostart enable --ha --scope app --name node1

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

pg logs addon patroni --scope app --name node1          # last 50 lines
pg logs addon patroni --scope app --name node1 -f       # follow

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).

pg ha exec app "SELECT version()"
pg ha exec app --member node2 "SELECT pg_is_in_recovery()"   # read-only, on a replica
pg ha psql app                                               # interactive

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>.

pg psql --dsn postgres://postgres@127.0.0.1:<leader_port>/postgres

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:

pg ha switchover app                     # patronictl prompts for the candidate
pg ha switchover app --candidate node2   # name it up front
pg ha switchover app --candidate node2 --yes   # skip prompts (scripted)

pg ha failover   app --candidate node2 --yes   # promote now, no handoff
pg ha pause      app                          # stop auto-failover
pg ha resume     app                          # re-enable it
  • switchover is 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 a streaming replica a few seconds later (you may briefly see it as stopped while Patroni restarts its postmaster). A new timeline is opened — this is normal, not a split-brain.
  • failover force-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 for switchover for anything planned.
  • pause turns off automatic failover cluster-wide. Do this before any planned container surgery — pg ha stop --all, a pg ha create recreate, or pg ha extension — so Patroni doesn’t promote a replica out from under you. pg ha resume puts 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:

addons:
  patroni:
    app:
      name: app
      etcd_members: [m1]                # or etcd_endpoints for an external DCS
      passwords:
        superuser: <superuser-password>
        replication: <replication-password>
        rewind: <rewind-password>
        restapi_user: postgres
        restapi_password: <restapi-password>
      members:
        node1:
          container_name: pgcli-patroni-app-node1
          image_tag: ghcr.io/mars-base/pgcli/pgcli-patroni:18-4.1.5
          host_port: 35590
          restapi_port: 39090
          data_dir: /home/you/.pgcli/addon/patroni/app/node1
          autostart: false

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.

scope: app-default
namespace: /service/
name: node1

etcd3:
    hosts: 10.0.0.11:2379       # from --etcd-endpoints or local etcd member
    protocol: http

restapi:
    listen: 0.0.0.0:8009
    connect_address: 10.0.0.11:8009
    authentication:
        username: postgres
        password: <auto-generated>

bootstrap:
    dcs:
        ttl: 30
        loop_wait: 10
        retry_timeout: 10
        maximum_lag_on_failover: 1048576
        postgresql:
            use_pg_rewind: true
            use_slots: true
            parameters:
                wal_level: replica
                hot_standby: "on"
    initdb:
        - encoding: UTF8
        - data-checksums
    pg_hba:
        - local all all trust
        - local replication all trust
        - host all all all scram-sha-256
        - host replication all all scram-sha-256

postgresql:
    listen: 0.0.0.0:35532
    connect_address: 10.0.0.11:35532
    data_dir: /var/lib/postgresql/data
    bin_dir: /usr/lib/postgresql/18/bin
    pgpass: /patroni/.pgpass
    use_unix_socket: true
    use_unix_socket_repl: true
    authentication:
        superuser:
            username: postgres
            password: <auto-generated>
        replication:
            username: replicator
            password: <auto-generated>
        rewind:
            username: rewind_user
            password: <auto-generated>
    parameters:
        unix_socket_directories: /var/lib/postgresql

Key points:

  • scope = Patroni cluster name. Combined with namespace, it forms the etcd key prefix.
  • etcd3.hosts = host:port only (no http:// scheme). Multiple endpoints are comma-separated.
  • bootstrap.dcs values are defaults only — they take effect on first bootstrap; afterwards use pg ha edit-config.
  • postgresql.listen: 0.0.0.0 is set when --advertise-host is used (cross-host). Without it, listen is 127.0.0.1.
  • Passwords are generated once and stored in pg.yaml; exported via pg 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.dcs is one-time. The bootstrap.dcs block in patroni.yml only takes effect when the first member of a scope runs pg ha create (i.e. the cluster bootstrap). Once Patroni writes the config into the DCS, subsequent changes to bootstrap.dcs in the YAML file are completely ignored — even on re-install (pg ha create). To change dynamic configuration after bootstrap, use pg ha edit-config.

The common pitfall: re-running pg ha create does not re-read bootstrap.dcs from the YAML — Patroni sees the existing config key 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

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

pg ha edit-config app --show

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

# Set a single parameter
pg ha edit-config app -- -s loop_wait=5

# Set multiple parameters
pg ha edit-config app -- -s loop_wait=5 -s retry_timeout=3

# Apply without confirmation (useful in scripts)
pg ha edit-config app -- -s loop_wait=5 --force

# Interactive edit (opens $EDITOR with the current config)
pg ha edit-config app

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:

pg ha edit-config app -- -s 'postgresql.parameters.max_connections=200'
pg ha edit-config app -- -s 'postgresql.parameters.work_mem=64MB'

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.yml on disk — it is regenerated from pg.yaml on every pg ha create, and Patroni reads dynamic config from the DCS anyway.
  • Do not edit postgresql.conf directly — Patroni overwrites it each loop from the DCS config.
  • Do not change scope or namespace — 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

pg ha extension install app pg_stat_statements,pg_cron   # install
pg ha extension list app                                  # list
pg ha extension remove app pg_cron                        # remove

Full reference: Extensions in HA Clusters covers the orchestration order, cross-host workflow, shared_preload_libraries ordering, 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 read 0600 config and write its data dir.
  • pg_hba.conf is permissive by design (host all all all scram-sha-256
    • a replication line, plus local trust lines). Rootless podman’s pasta rewrites loopback sources, and Patroni’s own replace_pg_hba step only ever grants its resolved loopback TCP address during custom bootstrap — so postgresql.use_unix_socket/use_unix_socket_repl are 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.
  • 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 the admin/default-db convention of plain instances does not exist here; use postgres (superuser) to connect and create roles.
  • use_slots / use_pg_rewind are enabled: Patroni owns replication slots and can rejoin a crashed leader via pg_rewind instead of a full rebase.

7.2 - Patroni Dynamic Configuration

Reference for all dynamically configurable parameters stored in the DCS

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:

pg ha edit-config app -- -s loop_wait=5
pg ha edit-config app -- -s 'postgresql.parameters.max_connections=200'
pg ha edit-config app --show          # view current config

Reference: Patroni official docs, Pigsty dynamic config.

bootstrap.dcs is one-time. The bootstrap.dcs block in patroni.yml only takes effect when the first member of a scope bootstraps the cluster. Once Patroni writes the config into the DCS, subsequent changes to bootstrap.dcs in the YAML file are completely ignored — even on re-install (pg ha create). Re-running pg ha create does not re-read bootstrap.dcs — Patroni sees the existing config key 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, or ttl:

loop_wait + 2 * retry_timeout <= ttl

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:

pg ha edit-config app -- -s 'postgresql.parameters.max_connections=200'
pg ha edit-config app -- -s 'postgresql.parameters.work_mem=64MB'
pg ha edit-config app -- -s 'postgresql.parameters.wal_log_hints=on'

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

slots:
  permanent_physical_slot:
    type: physical
  permanent_logical_slot:
    type: logical
    database: my_db
    plugin: pgoutput

ignore_slots:
  - name: externally_managed_slot
    type: physical

Node-pinned physical slots

For a fixed cluster topology, define a permanent physical slot per node to prevent slot deletion during temporary outages:

slots:
  node1:
    type: physical
  node2:
    type: physical
  node3:
    type: physical

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_wal growth 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

pg ha edit-config app --show

Or read directly from etcd:

ETCDCTL_ENDPOINTS=http://10.0.0.1:2379 pg etcdctl get /service/app-default/config -- --prefix

7.3 - Patroni REST API

Patroni REST API endpoints for health checks, monitoring, and cluster management

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.

# Find REST API ports
pg ha status app          # shows pg= and rest= ports for each member

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:

# No -u needed — returns 200 on leader, 503 on replica
curl -s http://<host>:<port>/primary -w "%{http_code}"

Write operations do require auth. The password is the same restapi_password used in patroni.yml’s restapi.authentication section. Export it with:

# Export passwords, then extract the restapi_password
pg ha passwords app --file app-passwd.yml
grep restapi_password app-passwd.yml
# restapi_password: <password>

# Example: a write operation with auth
curl -u postgres:<restapi-password> -X PATCH http://<host>:<port>/config -d '{"ttl": 60}'

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
# Returns 200 on leader, 503 on replica
curl -s -u postgres:<pw> http://<leader-host>:<port>/primary -w "%{http_code}"

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)
# Streaming replica only
curl -s -u postgres:<pw> "http://<replica-host>:<port>/replica?replication_state=streaming"

# Replica with lag < 1 MB
curl -s -u postgres:<pw> "http://<replica-host>:<port>/replica?lag=1048576"

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.
# Example Kubernetes probes
livenessProbe:
  httpGet:
    scheme: HTTP
    path: /liveness
    port: 8008          # REST API port
  initialDelaySeconds: 3
  periodSeconds: 10
  timeoutSeconds: 5
  failureThreshold: 3

readinessProbe:
  httpGet:
    scheme: HTTP
    path: /readiness
    port: 8008
  initialDelaySeconds: 3
  periodSeconds: 10
  timeoutSeconds: 5
  failureThreshold: 3

Monitoring Endpoints

GET /patroni

Returns detailed node status as JSON. Used internally by Patroni during leader election and also useful for monitoring:

curl -s -u postgres:<pw> http://<host>:<port>/patroni | jq .

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:

curl -s -u postgres:<pw> http://<host>:<port>/cluster | jq .
{
  "members": [
    {
      "name": "node1",
      "role": "leader",
      "state": "running",
      "api_url": "http://10.0.0.11:8008/patroni",
      "host": "10.0.0.11",
      "port": 35532,
      "timeline": 1
    },
    {
      "name": "node2",
      "role": "replica",
      "state": "streaming",
      "host": "10.0.0.11",
      "port": 35533,
      "timeline": 1,
      "receive_lag": 0,
      "receive_lsn": "0/3000168",
      "replay_lag": 0,
      "replay_lsn": "0/3000168"
    }
  ],
  "scope": "app-default"
}

GET /config

Returns the current dynamic configuration stored in the DCS:

curl -s -u postgres:<pw> http://<host>:<port>/config | jq .

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:

curl -s -u postgres:<pw> http://<host>:<port>/metrics

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:

# Only replicas with tag dc=us-east
curl -u postgres:<pw> "http://<host>:<port>/replica?dc=us-east"

# Only leader with tag region=primary
curl -u postgres:<pw> "http://<host>:<port>/leader?region=primary"

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):

global
    maxconn 100

defaults
    log global
    mode tcp
    retries 2
    timeout client 30m
    timeout connect 4s
    timeout server 30m
    timeout check 5s

listen stats
    mode http
    bind *:7000
    stats enable
    stats uri /

listen app
    bind *:5000
    option httpchk
    http-check expect status 200
    default-server inter 3s fall 3 rise 2 on-marked-down shutdown-sessions
    server node1 <ip>:35532 maxconn 100 check port 8008
    server node2 <ip>:35533 maxconn 100 check port 8009
  • 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 as UP.
  • on-marked-down shutdown-sessions — on failover, sessions to the old leader are killed so clients reconnect to the new leader.
  • fall 3 / rise 2 with inter 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:

listen app_rw
    bind *:5000
    mode tcp
    option httpchk GET /
    http-check expect status 200
    default-server inter 3s fall 3 rise 2 on-marked-down shutdown-sessions
    server node1 <ip>:35532 maxconn 100 check port 8008
    server node2 <ip>:35533 maxconn 100 check port 8009

listen app_ro
    bind *:5001
    mode tcp
    option httpchk GET /replica
    http-check expect status 200
    default-server inter 3s fall 3 rise 2
    server node1 <ip>:35532 maxconn 100 check port 8008
    server node2 <ip>:35533 maxconn 100 check port 8009
  • app_rw (:5000) — GET / → only the leader is UP → writes land on the leader.
  • app_ro (:5001) — GET /replica → only replicas are UP → 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:

#!/bin/bash
# Check all members — no auth needed for GET
for port in 8008 8009; do
  code=$(curl -s http://10.0.0.11:$port/health -o /dev/null -w "%{http_code}")
  echo "Port $port: HTTP $code"
done

Prometheus scrape config

GET /metrics needs no auth, so the scrape config works without credentials:

scrape_configs:
  - job_name: patroni
    static_configs:
      - targets:
        - '10.0.0.11:8008'   # node1
        - '10.0.0.11:8009'   # node2
    metrics_path: /metrics

7.4 - Extensions in HA Clusters

Install and manage PostgreSQL extensions in Patroni-managed 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:

  1. Building a -ext derived image with Pigsty packages
  2. Stopping and recreating the container from the new image
  3. Editing postgresql.conf to set shared_preload_libraries
  4. Running CREATE EXTENSION inside 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.conf every loop from the DCS. Any direct edit to the file is overwritten within seconds. shared_preload_libraries must be set via patronictl edit-config (which writes to the DCS).
  • CREATE EXTENSION must 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:

1. Validate extension names (IsExtensionKnown)
2. Build -ext image with Pigsty packages
3. patronictl pause <scope> --wait            ← disable auto-failover
4. Recreate each member container (one at a time, wait for rejoin)
5. patronictl resume <scope> --wait           ← ⚠ must come BEFORE edit-config
6. patronictl edit-config (set shared_preload_libraries)
   → Patroni triggers a rolling restart (replicas first, leader last)
7. Wait for rolling restart to complete
8. CREATE EXTENSION on leader
9. Save extensions list to pg.yaml

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

pg ha extension install <scope> <extension>[,<extension>...] [flags]

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 apply on any host to complete the installation.

Extensions are passed as a comma-separated list:

# Install a single extension
pg ha extension install app pg_stat_statements

# Install multiple extensions to a specific database
pg ha extension install app pg_cron,pg_stat_statements --database mydb

# Skip the rolling restart confirmation prompt
pg ha extension install app pgvector --auto-restart

Flags:

Flag Default Description
--database postgres Target database for CREATE EXTENSION
--auto-restart false Skip confirmation for the rolling restart triggered by edit-config

⚠ --database affects all extensions. When you specify --database, the apply phase runs CREATE EXTENSION IF NOT EXISTS for 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, running pg ha extension install app hstore --database mydb will create all four extensions in mydb. The extension .so files 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 EXISTS directly.

Remove

pg ha extension remove <scope> <extension>[,<extension>...] [flags]

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:

pg ha extension remove app pg_cron
pg ha extension remove app pg_stat_statements,pg_cron --auto-restart

List

pg ha extension list <scope>

Shows three views of the cluster’s extensions:

  • Config (pg.yaml): the extensions list stored in pg.yaml
  • DCS (preload): the shared_preload_libraries value from patronictl show-config
  • Leader (installed): actual extensions from pg_extension on the leader
$ pg ha extension list app
Cluster "app" extensions:

  Config (pg.yaml):  [pg_stat_statements pg_cron]
  DCS (preload):     shared_preload_libraries: pg_stat_statements,pg_cron
  Leader (installed): [pg_cron pg_stat_statements]

Apply

pg ha extension apply <scope> [flags]

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.

pg ha extension apply app
pg ha extension apply app --database mydb --auto-restart

⚠ --database affects all installed extensions. apply runs CREATE EXTENSION IF NOT EXISTS for 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 -ext image 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 install first, 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.

# host A (replicas only — run first) — cluster enters paused state
pg ha extension install app pg_stat_statements,pg_cron

# host B (has the leader — run last) — cluster stays paused
pg ha extension install app pg_stat_statements,pg_cron

Phase 2 — once, on any host: Run pg ha extension apply to:

  • Resume the cluster (idempotent — tolerates “not paused”)
  • Update shared_preload_libraries via patronictl edit-config
  • Wait for the rolling restart
  • Run CREATE EXTENSION on the leader
pg ha extension apply app --auto-restart

Note: After install, the cluster is in paused state (auto-failover disabled). If you forget to run apply, run pg ha extension apply or manually patronictl 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-first recreation order ensures that the host with the leader is the last one to run install, 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:

addons:
  patroni:
    app:
      name: app
      extensions:
        - pg_stat_statements
        - pg_cron
      passwords:
        superuser: ...
      members:
        node1: { ... }
        node2: { ... }

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 else
  • timescaledb — time-series engine, same constraint

Additional extensions may require companion DCS parameters:

  • pg_cron needs cron.database_name

pg ha extension handles all of this automatically:

  • Extensions with PreloadFirst are always placed at the start of the CSV, regardless of input order
  • When pg_cron is present, cron.database_name is set to the --database value (default postgres)
  • When pg_cron is removed, cron.database_name is cleared

Examples

Install pg_stat_statements and pg_cron

pg ha extension install app pg_stat_statements,pg_cron --database mydb --auto-restart

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)

pg ha extension install app pg_stat_statements,citus

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

pg ha extension remove app pg_cron

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

pg ha extension list app

Limitations

  • Image rebuild is additive. Removing an extension does not rebuild the -ext image or shrink it. To reclaim disk, manually prune old images with podman image prune.
  • No per-member extension list. Extensions are cluster-wide — all members share the same -ext image and the same shared_preload_libraries.
  • CREATE EXTENSION targets one database. PostgreSQL extensions are per-database. To install in multiple databases, re-run with --database pointing at each one.
  • Cross-host requires manual coordination. Each host must run install before apply is run once. pgcli does not SSH into remote hosts.

See Also

7.5 - Patroni Cluster Backup

pgBackRest backups for a Patroni HA cluster: backup setup, S3 repository and WAL archiving, pg ha snapshot operations, and reinit when a replica’s LSN is stuck

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 / diff all 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 snapshot commands 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:

# simplest (backup data lands under the default base-dir)
pg backup setup

# S3 object repository (the prerequisite for archiving to MinIO / AWS S3).
# When the store is on another host and you have no ca.crt yet, pull it with
# one TLS handshake first — no scp:
pg backup fetch-ca 10.0.0.9:9000          # prints a SHA-256 fingerprint to cross-check
pg backup setup --s3-endpoint 10.0.0.9:9000 --s3-bucket pgbackrest \
    --s3-access-key admin --s3-ca-file ~/.pgcli/backup/repo-ca/ca-10.0.0.9-9000.crt

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):

  1. Build/pull the pgbackrest image, create the network and dirs, generate the backup-container pgbackrest.conf and the member-local archive view pgbackrest-archive.conf.
  2. Start the shared backup container, then verify repository connectivity: a pgbackrest repo-ls proves the whole stack (TLS, credentials, bucket) and maps a failure to an actionable hint — a cert error points at pg backup fetch-ca, access-denied at the credentials, a refused/timeout connection at the endpoint.
  3. Wire up cross-host backup trust: each host publishes its backup public key into the cluster’s etcd registry at pg ha create; setup merges every member’s key into one per-cluster authorized_keys bind-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 with ca_file publishes it into the registry, joiners with it empty pull it. (Details: HA → Backing up a cross-host leader.)
  4. 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_mode needs), resume, and render the same archive_command into every member’s patroni.yml.
  5. Run stanza-create + check for 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-file at its ca.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:

pg backup status

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.conf if that file already exists, and the rendered patroni.yml only carries the archive GUCs if an S3 repo is configured. A member pg ha created before pg backup setup on its host therefore starts without WAL archiving — pg ha snapshot/pg ha restore cannot cover it until a later setup flags it stale and recreates it inside a pause window. pg ha create warns about this up front; run pg backup setup first 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).

# full backup (default --type full); --tail-logs streams pgBackRest output
pg ha snapshot create app --tail-logs

# incremental (changes since the last backup)
pg ha snapshot create app --type incr

# differential (changes since the last full)
pg ha snapshot create app --type diff

# list every snapshot for the cluster (Start / Stop / Name / Type)
pg ha snapshot list app
pg ha snapshot list app --limit 5

# delete a specific snapshot (label from list) — prompts unless --force
pg ha snapshot delete app 20260916-140131F_20260917-015154I
pg ha snapshot delete app <label> --force

Notes:

  • Before each command, pg ha snapshot re-renders the backup-container pgbackrest.conf from 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:

# 1) leader has no replication clients, slots inactive
pg exec --dsn "postgres://<user>@<leader_host>:<port>/postgres" \
  "SELECT client_addr, state FROM pg_stat_replication"
pg exec --dsn "postgres://<user>@<leader_host>:<port>/postgres" \
  "SELECT slot_name, active, confirmed_flush_lsn FROM pg_replication_slots"

# 2) the stuck replica has no walreceiver
pg exec --dsn "postgres://<user>@<replica_host>:<port>/postgres" \
  "SELECT status FROM pg_stat_wal_receiver"

# 3) the replica container log loops the same two lines (pg logs ha fetches by
#    scope + member name; -f follows, -n takes more lines):
pg logs ha <scope> -m <member> -n 50 | grep -iE "prev-link|waiting for WAL"
#   LOG: record with incorrect prev-link ... at 0/20000060
#   LOG: waiting for WAL to become available at 0/20000078

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:

# CLUSTER_NAME is the namespace-qualified scope (the name after "Cluster:" in pg ha status)
pg ha ctl <scope> -- reinit <scope>-<ns-suffix> <member> --force
# example (namespace default -> -default suffix):
pg ha ctl app -- reinit app-default node1 --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:

pg exec --dsn "postgres://<user>@<replica_host>:<port>/postgres" \
  "SELECT pg_last_wal_receive_lsn() recv, pg_last_wal_replay_lsn() replay,
          (SELECT status FROM pg_stat_wal_receiver) wr"
#   expected: recv == replay, wr = streaming

pg ha status app    # the replica's Replay LSN catches up, Lag converges

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

Point-in-time recovery (PITR) for a Patroni HA cluster with pg ha restore: the custom-bootstrap mechanism, the leader-locality pre-check, the end-to-end runbook, and the post-restore re-baseline steps

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.conf at /etc/pgbackrest.conf plus the S3 CA at /etc/pgbackrest/ca.crt. Both are generated by pg 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 the pgbackrest restore bootstrap cannot reach S3 and fails to start.
  • The leader-locality and stop-time pre-checks run pgbackrest ... info inside the backup container. pg ha restore refreshes the shared pgbackrest.conf best-effort but does not create the member archive config and does not start the backup container — so bring it up first (pg backup setup, or pg backup status to confirm it is Up). 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:

  1. 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.

  2. Clear the cluster’s DCS identity (patronictl remove). Patroni’s bootstrap.dcs block only runs when the DCS has no config key, so removing it is what re-arms the bootstrap path.

  3. Pick one LOCAL member and rewrite its patroni.yml so the default initdb bootstrap is replaced by a pgbackrest restore method targeting the point in time:

    bootstrap:
      method: pgbackrest
      pgbackrest:
        command: 'pgbackrest --stanza=pgcli_<scope> --type=time --target="<time>" --target-action=promote --delta restore'
        no_params: true
        keep_existing_recovery_conf: true

    no_params: true stops Patroni appending --scope/--datadir (which pgbackrest rejects with [031] invalid option); keep_existing_recovery_conf: true preserves the recovery.signal + restore_command + recovery_target_* that pgbackrest restore writes itself. The method/pgbackrest keys are siblings of bootstrap.dcs, never nested inside it — recovery GUCs leaked into the DCS config would apply as live settings on the promoted leader.

  4. 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.

  5. The remaining members rejoin the new leader: this host’s other local members are wiped and restarted as standard replicas (they pg_basebackup the 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.

# dry-run: a loud warning, no refusal — you can still inspect the plan
pg ha restore app --time "2026-08-26 15:30:00+00" --dry-run
#   [!!] current leader "node3" is NOT a local member — the real run REFUSES until
#        leadership is on this host...

# real run on a host whose leader is remote: hard error, nothing is touched
pg ha restore app --time "2026-08-26 15:30:00+00"
#   current leader "node3" is not a local member of scope "app" ...
#   Move leadership here first (pg ha switchover app ...) or run on the leader's host

Fix. Move leadership onto a member of the host you are standing on, then retry:

pg ha switchover app    # or: pg ha failover app ...
pg ha status app        # confirm the leader is now a local member

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-run first. (The patronictl pause in 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 --promote flag — 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.

pg ha restore app --time "2026-08-26 15:30:00+00" --dry-run

2. Make sure the leader is local (only if the dry-run warned). Switchover, then re-check status.

pg ha switchover app
pg ha status app

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.

pg ha restore app --time "2026-08-26 15:30:00+00" --tail-logs
# or, picking the bootstrap member explicitly:
pg ha restore app --time "2026-08-26 15:30:00+00" --member node1 --force

4. Verify the new cluster comes up. waitForLeader polls the DCS until a leader appears (15-minute ceiling), then rejoin members start as replicas.

pg ha status app                     # new leader on a switched timeline, replicas streaming
pg exec --dsn "postgres://<user>@<leader_host>:<port>/postgres" \
  "SELECT ... FROM your_table"       # data stops at the target time; later commits gone

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:

  1. Take a fresh full snapshot so future PITR has a base on the new timeline:

    pg ha snapshot create app --type full

    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 need pg backup stanza-upgrade after 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.)

  2. 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):

    pg ha ctl app -- reinit app-<ns> <member> --force

    (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 restore can 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_command is 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, a pg backup stanza-upgrade fixes it non-destructively.
  • FATAL: recovery ended before configured recovery target was reached on a repeat PITR into the same repo — the stanza already hosts a previously promoted timeline, and the default recovery_target_timeline=latest jumps 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 bootstrap pgbackrest ... restore command in the member’s patroni.yml). A first PITR after the target — no branch crossing — never hits this.

7.7 - HA Exec / psql

Run SQL against a Patroni HA cluster with pg ha exec and pg ha psql: zero-config leader resolution from the DCS, no hand-assembled dsn, no entering a member container, and read-only or cross-host targets via –member

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’s connect_address out of pg 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 (SELECT off a standby, pg_is_in_recovery() returns t), 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

# One-shot SQL against the leader
pg ha exec app "SELECT version()"
pg ha exec app "SELECT count(*) FROM pg_stat_activity"

# A different database on the leader
pg ha exec app --database mydb "SELECT * FROM t LIMIT 5"

# Read-only: target a replica instead of the leader
pg ha exec app --member node2 "SELECT pg_is_in_recovery()"

# Interactive psql against the leader
pg ha psql app

# Interactive against a specific (even cross-host) member
pg ha psql app --member node3

# Pass extra psql arguments after --
pg ha psql app -- -c "SELECT 1"
pg ha psql app --database mydb -- -x

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:

pg ha psql app < migrations.sql      # pipe a file, no pager, exits when done
printf '\conninfo\n' | pg ha psql app   # scripted one-liner, output streams back

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

A complete, verified end-to-end walkthrough: a single-member Patroni cluster backed up to a MinIO addon serving a bring-your-own (self-signed) domain certificate, run in a fully isolated pgcli environment

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-key let 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.s3 is 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 own base_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:

pg addon install etcd --name m1
# pgcli-etcd-default-m1, client 127.0.0.1:2379, peer 2380, cluster "pgcli-etcd"

(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):

mkdir -p ~/.pgcli-app1/certs
pg cert --host "minio1.test,127.0.0.1,<host-ip>" --valid-duration 8760h \
    --cert-file ~/.pgcli-app1/certs/store1.crt --key-file ~/.pgcli-app1/certs/store1.key
chmod 600 ~/.pgcli-app1/certs/store1.key

openssl x509 -in ~/.pgcli-app1/certs/store1.crt -noout -subject -issuer -ext subjectAltName -dates
# subject=...
# issuer=...                         <- self-signed: leaf and root are one and the same
# DNS:minio1.test, IP Address:127.0.0.1, IP Address:<host-ip>

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

mkdir -p ~/.pgcli-app1
pg config init -o ~/.pgcli-app1/pg.yaml \
    --base-dir /home/fish/bucket/pgcli-data-app1 \
    --namespace app1 \
    --pg-start-port 36500 --pg-ssh-port 43500

--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

pg addon install minio --name store1 \
    --api-port 9010 --console-port 9011 --listen 0.0.0.0 \
    --tls-cert ~/.pgcli-app1/certs/store1.crt \
    --tls-key  ~/.pgcli-app1/certs/store1.key
-> MinIO BYO cert: CN="" issuer="" valid 2026-09-18 → 2027-09-18
  [OK] TLS certs (BYO: /home/…/.pgcli-app1/certs/store1.crt, valid 2026-09-18 → 2027-09-18)
         self-signed: clients pin the issuing CA (pg backup setup --s3-ca-file <ca.pem>)
  [OK] MinIO container started

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:

curl -s --cacert ~/.pgcli-app1/certs/store1.crt \
     https://127.0.0.1:9010/minio/health/live -o /dev/null -w "%{http_code}\n"
# 200  — and curl verified the chain against exactly the cert we supplied

Create the repository bucket (pgBackRest does not create it):

pg mc alias set store1 https://127.0.0.1:9010 admin '<root-password>' -- --insecure
pg mc mb store1/pgbackrest

Step 3 — the Patroni cluster

pg ha create app1 --member nodea \
    --etcd-endpoints 127.0.0.1:2379 \
    --host-port 35632 --restapi-port 8028 \
    --advertise-host <host-ip>
!  No S3 backup repo configured on this host (backup.repo.s3): the member will
   be created WITHOUT WAL archiving ... Configure a repo and run `pg backup
   setup` to wire archiving in (it recreates members as needed).
  [OK] Patroni member nodea started
✓ Patroni member "nodea" installed in scope "app1"
  scope (DCS):  app1-app1        # namespace-qualified: "app1-" + scope "app1"
  pg:           <host-ip>:35632

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):

pg ha exec app1 "CREATE TABLE IF NOT EXISTS byo_probe(id serial primary key,
    note text, ts timestamptz default now());
    INSERT INTO byo_probe(note) SELECT 'row-'||g FROM generate_series(1,500) g;"
pg ha exec app1 "SELECT count(*) FROM byo_probe"   # 500

Step 4 — point the backup stack at the BYO store

pg backup setup \
    --s3-endpoint <host-ip>:9010 --s3-bucket pgbackrest \
    --s3-access-key admin --s3-secret-key '<root-password>' \
    --s3-ca-file ~/.pgcli-app1/certs/store1.crt

--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:

  [OK] <host-ip>:9010 reachable
  [OK] repo CA published to 1 cluster registry/registries (from …/store1.crt)
  [OK] repository reachable                      # a repo-ls over TLS: creds + CA + bucket all good
  [stale] app1/nodea: mounts the backup-side pgbackrest.conf (pg1-host breaks archive-push)
-> Pausing cluster app1 (no auto-failover during recreate)
  -> Recreating member nodea...                  # now with archive-push enabled
  [OK] app1: archive-ready after recreating 1 member(s)
  [OK] stanza pgcli_app1-app1 created
  [OK] check pgcli_app1-app1

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

pg ha snapshot create app1 --type full
#   Name:    20260918-091519F
#   Type:    full
pg ha snapshot list app1                          # pgBackRest sees the backup in the repo

The independent proof is that the objects are physically in the bucket, written over a TLS session terminated by our certificate:

pg mc ls store1/pgbackrest -- --recursive
# …/backup/pgcli_app1-app1/20260918-091519F/backup.manifest
# …/backup/pgcli_app1-app1/20260918-091519F/pg_data/base/…  (.zst files)
# …/archive/pgcli_app1-app1/18-1/0000000200000000/000000020000000000000005-….zst
# …/archive/pgcli_app1-app1/18-1/0000000200000000/000000020000000000000005.00000028.backup
# …/archive/pgcli_app1-app1/archive.info

archive/ is continuous WAL archiving (the archive-push the recreated member runs), backup/ is the full snapshot. Confirm the archiver is healthy:

pg ha exec app1 "SELECT archived_count, failed_count, last_archived_wal
                 FROM pg_stat_archiver"

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-file slot carries it (leaf==root), a public CA’s chain, or an internal CA bundle — the transport and the trust plumbing are identical.
  • backup.repo.s3 is 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):

pg -c ~/.pgcli-app1/pg.yaml ha remove app1 --scope-all --clean-data
pg -c ~/.pgcli-app1/pg.yaml backup remove --clean-data
pg -c ~/.pgcli-app1/pg.yaml addon remove minio --name store1 --clean-data
rm -rf ~/.pgcli-app1 /home/fish/bucket/pgcli-data-app1

--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.

7.9 - Generating a Certificate — pg cert

pg cert mints a self-signed certificate with DNS and IP SubjectAltNames for any TLS you need without a CA — dev/test servers, internal endpoints, and a MinIO serving a bring-your-own TLS certificate. Its flags, what it writes (a single self-signed leaf, not a chain), and how it acts as its own trust anchor

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:

pg cert --host "minio.test,127.0.0.1,10.0.0.9" \
  --cert-file minio.crt --key-file minio.key

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 CERTIFICATE block in --cert-file — the cert signs itself;
  • CA:FALSE, extended key usage serverAuth — a proper TLS server leaf;
  • SANs split automatically: any --host entry 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.test works 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:

make gencert          # -> bin/gencert
bin/gencert -host "minio.test,10.0.0.9" -cert-file minio.crt -key-file minio.key

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

# 1. mint a cert the MinIO will serve (SANs cover how clients dial it)
mkdir -p ~/.pgcli/certs
pg cert --host "minio.test,127.0.0.1,<host-ip>" --valid-duration 8760h \
  --cert-file ~/.pgcli/certs/store.crt --key-file ~/.pgcli/certs/store.key
chmod 600 ~/.pgcli/certs/store.key

# 2. MinIO serves it over HTTPS
pg addon install minio --name store --listen 0.0.0.0 \
  --tls-cert ~/.pgcli/certs/store.crt --tls-key ~/.pgcli/certs/store.key

# 3. the cluster's backups point at it; the served leaf is its own CA file
pg backup setup --s3-endpoint <host-ip>:9000 --s3-bucket pgbackrest \
  --s3-access-key admin --s3-ca-file ~/.pgcli/certs/store.crt

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:

# on another host, instead of copying ~/.pgcli/certs/store.crt over:
pg backup fetch-ca <host-ip>:9000
#   [OK] CA fetched from <host-ip>:9000
#        saved:    <base-dir>/backup/repo-ca/ca-<host-ip>-9000.crt
#        SHA-256:  d4df…81e2   (cross-check against the store host's sha256sum)
pg backup setup --s3-ca-file <base-dir>/backup/repo-ca/ca-<host-ip>-9000.crt

7.10 - S3 Storage High Availability

Making the MinIO/silo object store behind pgBackRest highly available: the four deployment modes pgcli exposes (SNSD/SNMD/MNSD/MNMD), how native multi-drive compares to a ZFS layer for disk redundancy, and the two paths for a distributed cluster whose nodes each hold several disks

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.

# one pool from the host's spare disks (mount point defaults to /minio-pool)
sudo zpool create minio-pool raidz1 /dev/sdb /dev/sdc /dev/sdd

# one dataset for the store: 1M recordsize suits object blobs, no atime churn
sudo zfs create -o recordsize=1M -o atime=off minio-pool/store

pg addon install minio --name store --data-dir /minio-pool/store   # or: silo

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=8589934592 in /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).

# once, anywhere — one self-signed leaf whose SANs cover every node address:
pg cert --host 10.0.0.11,10.0.0.12,10.0.0.20,10.0.0.21 \
        --cert-file grid.crt --key-file grid.key
# copy grid.crt + grid.key to every node — byte-identical files everywhere

# node 1 (10.0.0.11) — four data disks mounted; nodes 2–4: same command, the
# SAME grid.crt/grid.key, own --drive paths, SAME 16-endpoint matrix, SAME
# --root-password:
pg addon install minio --name store \
  --tls-cert grid.crt --tls-key grid.key \
  --listen 0.0.0.0 \
  --drive /mnt/minio/disk1 --drive /mnt/minio/disk2 \
  --drive /mnt/minio/disk3 --drive /mnt/minio/disk4 \
  --root-password '<shared-secret>' \
  --endpoint https://10.0.0.11:9000/data1 --endpoint https://10.0.0.11:9000/data2 \
  --endpoint https://10.0.0.11:9000/data3 --endpoint https://10.0.0.11:9000/data4 \
  --endpoint https://10.0.0.12:9000/data1 --endpoint https://10.0.0.12:9000/data2 \
  --endpoint https://10.0.0.12:9000/data3 --endpoint https://10.0.0.12:9000/data4 \
  --endpoint https://10.0.0.20:9000/data1 --endpoint https://10.0.0.20:9000/data2 \
  --endpoint https://10.0.0.20:9000/data3 --endpoint https://10.0.0.20:9000/data4 \
  --endpoint https://10.0.0.21:9000/data1 --endpoint https://10.0.0.21:9000/data2 \
  --endpoint https://10.0.0.21:9000/data3 --endpoint https://10.0.0.21:9000/data4

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.

# on EACH of the 4 nodes: build the local pool (layout per node — see below)
sudo zpool create minio-pool raidz1 /dev/sdb /dev/sdc /dev/sdd
sudo zfs create -o recordsize=1M -o atime=off minio-pool/store

# node 1 (10.0.0.11):
pg addon install minio --name store \
  --listen 10.0.0.11 \
  --data-dir /minio-pool/store \
  --root-password '<shared-secret>' --tls \
  --endpoint http://10.0.0.11:9000/data \
  --endpoint http://10.0.0.12:9000/data \
  --endpoint http://10.0.0.20:9000/data \
  --endpoint http://10.0.0.21:9000/data

# nodes 2–4: same command, own --listen, identical endpoint list and password

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-way mirror there, 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 simply zfs 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.

8 - Failover: Replica Promotion

Failover: Replica Promotion guide for pgcli

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

                    ┌─────────────┐
                    │   primary   │  ← crashes / becomes unavailable
                    │  (pg01)     │
                    └──────┬──────┘
                           │ WAL streaming
               ┌───────────┼───────────┐
               ▼           ▼           ▼
          ┌─────────┐ ┌─────────┐ ┌─────────┐
          │  ro1    │ │  ro2    │ │  ro3    │
          │ replica │ │ replica │ │ replica │
          └─────────┘ └─────────┘ └─────────┘

After failover (promote ro1 → new primary):

                    ┌─────────────┐
                    │   ro1       │  ← new primary (promoted)
                    │  (primary)  │
                    └──────┬──────┘
                           │ WAL streaming
               ┌───────────┼───────────┐
               ▼           ▼           ▼
          ┌─────────┐ ┌─────────┐ ┌─────────┐
          │  ro2    │ │  ro3    │ │  pg01   │
          │ replica │ │ replica │ │ replica │  ← demoted old primary
          └─────────┘ └─────────┘ └─────────┘

3-Step Failover

Each step is an independent command. Run them in order, on their respective hosts.

# Step 1: On the replica being promoted
pg replica promote ro1

# Step 2: On the old primary host (when it recovers)
pg replica drop ro1 -i pg01

# Step 3: On each remaining replica host
pg replica repoint ro2 --primary-dsn "postgres://admin:<pw>@<new-primary-ip>:<port>/<db>" --primary-name ro1
pg replica repoint ro3 --primary-dsn "postgres://admin:<pw>@<new-primary-ip>:<port>/<db>" --primary-name ro1

Step 1: pg replica promote <name>

Run on the host of the replica being promoted to primary.

pg replica promote ro1

What happens:

  1. Validates the instance is a replica (ReplicaOf is set) and the container is running
  2. Calls pg_promote() (PostgreSQL 12+ native promotion — no container restart)
  3. Waits for recovery to end (usually sub-second)
  4. Cleans up primary_conninfo from postgresql.auto.conf via ALTER SYSTEM RESET
  5. Updates config: clears ReplicaOf and PrimaryDSN, enables PITR
  6. Automatically initializes PITR:
    • pgBackRest stanza creation
    • archive_mode / archive_command configuration
    • PostgreSQL restart to apply postmaster-level parameters
  7. 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.

pg replica drop ro1 -i pg01

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 drop or repoint on 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.

pg replica repoint ro2 \
  --primary-dsn "postgres://admin:<password>@10.0.0.11:35439/pg01_db" \
  --primary-name ro1

What happens:

  1. Queries the new primary’s extensions via DSN (pg_extension catalog)
  2. If non-builtin extensions exist (e.g. pg_cron, timescaledb), builds a local -ext image with matching packages
  3. Stops the old replica container and destroys its data directory
  4. Creates a replication slot on the new primary via DSN
  5. Updates config: ReplicaOf, PrimaryDSN, ImageTag, Extensions, disables PITR
  6. Re-initializes via pg_basebackup -R from the new primary
  7. 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:

FATAL: requested starting point on timeline 1 is not in this server's history

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:

pg status -i ro1
# Connection: postgres://admin:<password>@127.0.0.1:35439/pg01_db

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:

# On the old primary host
pg replica repoint pg01 \
  --primary-dsn "postgres://admin:<password>@10.0.0.11:35439/pg01_db" \
  --primary-name ro1

This works even though pg01 was a primary (no ReplicaOf set). The command:

  1. Stops pg01 and destroys its data (including old PITR stanza)
  2. Creates a replication slot for pg01 on the new primary
  3. Sets ReplicaOf = "ro1", PITR.Enabled = false
  4. 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:

  1. Query — Connects to the new primary via DSN and queries pg_extension for installed extensions
  2. Filter — Identifies non-builtin extensions (those requiring external packages, e.g. pg_cron, pgmq, timescaledb)
  3. Build — If non-builtin extensions exist, builds a local -ext image:
    • If a local -ext image already exists, installs missing packages on top (reuses Pigsty repo — fast)
    • If no -ext image exists, builds from the base image with Pigsty repo setup
    • apt-get install is idempotent — installing an already-present package is a no-op
  4. Apply — On replica start, ApplyExtensions writes shared_preload_libraries to postgresql.conf
  5. 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

# ── Initial setup: pg01 (primary) + ro1, ro2 (replicas) on same host ──

$ pg list
NAME    ROLE      PRIMARY   STATUS
pg01    primary   -         Up 2 hours
ro1     replica   pg01      Up 1 hour
ro2     replica   pg01      Up 1 hour

# ── pg01 crashes ──

# Step 1: Promote ro1
$ pg replica promote ro1
  [OK] pg_promote() signaled
  [OK] recovery ended, instance is now read-write
  [OK] primary_conninfo removed from postgresql.auto.conf
  [OK] config updated (ReplicaOf/PrimaryDSN cleared, PITR re-enabled)
-> Initializing PITR (backup stanza + archive_mode)...
  [OK] pgBackRest stanza created
-> archive_command configured
  [OK] WAL archiver caught up
✓ Replica "ro1" promoted to primary

# Step 2: Clean up on old primary (skip if pg01 is permanently lost)
$ pg replica drop ro1 -i pg01
  [OK] replication slot "pgcli_r_ro1" removed from primary "pg01"

# Step 3: Re-point ro2 to new primary
$ pg replica repoint ro2 \
    --primary-dsn "postgres://admin:<password>@127.0.0.1:35437/pg01_db" \
    --primary-name ro1
  [OK] extension image built with pg_cron, pgmq, timescaledb
  [OK] replication slot "pgcli_r_ro2" created on new primary
  [OK] config updated (ReplicaOf = "ro1")
✓ Replica "ro2" re-pointed to "ro1"

# ── pg01 recovers, demote to replica ──
$ pg replica repoint pg01 \
    --primary-dsn "postgres://admin:<password>@127.0.0.1:35437/pg01_db" \
    --primary-name ro1
  [OK] backup stanza removed: pgcli_pg01
  [OK] replication slot "pgcli_r_pg01" created on new primary
  [OK] config updated (ReplicaOf = "ro1", PITR disabled)
✓ Replica "pg01" re-pointed to "ro1"

# ── Final state ──
$ pg list
NAME    ROLE      PRIMARY   STATUS
pg01    replica   ro1       Up 30 seconds    # demoted
ro1     primary   -         Up 10 minutes    # new primary
ro2     replica   ro1       Up 5 minutes

Cross-Host Example

# ── Setup: ra3 (primary, host A) + ra2 (replica, host A) + ro2 (replica, host B) ──

# ra3 crashes. On host A, promote ra2:
$ pg replica promote ra2
  [OK] pg_promote() signaled
  [OK] recovery ended
  [OK] PITR initialized (stanza + archive_mode)
✓ Replica "ra2" promoted to primary

# On host B (10.0.0.12), re-point ro2 to new primary ra2 (host A = 10.0.0.11):
$ pg replica repoint ro2 \
    --primary-dsn "postgres://admin:<password>@10.0.0.11:35438/pg01_db" \
    --primary-name ra2
-> New primary has 3 non-builtin extension(s): pg_cron, pgmq, timescaledb
-> Extension image already has all required packages
  [OK] replication slot "pgcli_r_ro2" created on new primary
  [OK] config updated (ReplicaOf = "ra2", image = ...-ext)
✓ Replica "ro2" re-pointed to "ra2"

# Verify cross-host replication:
$ pg exec -i ra2 "INSERT INTO test(msg) VALUES ('after failover')"
$ pg exec -i ro2 "SELECT * FROM test ORDER BY id DESC LIMIT 1"
   msg: after failover

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.

primary (ra3)
  ├─→ replica (ra2) ← upstream for ra2_ro1
  │     └─→ replica (ra2_ro1)
  └─→ replica (pg01)

How It Works

  1. Create a replica of a replica: Use the replica as the -i target

    # ra2 is a replica of ra3, create ra2_ro1 as a replica of ra2
    pg replica create ra2_ro1 -i ra2
  2. WAL propagation:

    • ra2 streams WAL from ra3
    • ra2_ro1 streams WAL from ra2
    • Data flows: ra3 → ra2 → ra2_ro1
  3. Replication slots: Each link maintains its own slot

    • ra3 has slot pgcli_r_ra2
    • ra2 has slot pgcli_r_ra2_ro1

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

# Check ra2's downstream replicas
pg exec -i ra2 "SELECT client_addr, state FROM pg_stat_replication"

# Check ra2_ro1's upstream
pg exec -i ra2_ro1 "SELECT conninfo FROM pg_stat_wal_receiver"

Failover with Cascading

If ra2 (middle node) fails:

# Option 1: Re-point ra2_ro1 to ra3 directly
pg replica repoint ra2_ro1 \
  --primary-dsn "postgres://admin:password@ra3-host:5432/pg01_db" \
  --primary-name ra3

# Option 2: Wait for ra2 to recover (automatic once ra2 reconnects to ra3)

If ra3 (primary) fails and ra2 is promoted:

# Step 1: Promote ra2
pg replica promote ra2

# Step 2: ra2_ro1 automatically follows (it's already replicating from ra2)
# No action needed for ra2_ro1

# Step 3: Re-point other replicas to the new primary
pg replica repoint pg01 \
  --primary-dsn "postgres://admin:password@ra2-host:5432/pg01_db" \
  --primary-name ra2

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 via pg_basebackup
  • PITR on promoted replica — After promotion, the pgBackRest stanza and WAL archiving are initialized automatically (no manual pg start needed). 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 drop cleans it up. If the old primary is demoted to a replica, repoint destroys the old data and the stale slot is no longer referenced
  • Extensions — Replica containers inherit shared_preload_libraries from the primary via postgresql.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_basebackup copies the extension metadata from the primary, so CREATE EXTENSION is not needed (and would fail with “cannot execute CREATE EXTENSION in a read-only transaction”)

9 - Self-Cert

Mint your own self-signed TLS certificates with pg cert and serve them from the store addons

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

# A directory to hold the pair (any writable path works; keep it out of the
# data dir). pgcli's base dir already lives under ~/.pgcli:
mkdir -p ~/.pgcli/certs

# A leaf valid for one DNS name and two IPs, ECDSA P-256, 825 days (defaults):
pg cert --host "rustfs.test,127.0.0.1,10.0.0.9" \
  --cert-file ~/.pgcli/certs/rustfs.crt \
  --key-file  ~/.pgcli/certs/rustfs.key

# Serve it from a store (any of the three), giving the full paths:
pg addon install rustfs --name store \
  --tls-cert ~/.pgcli/certs/rustfs.crt \
  --tls-key  ~/.pgcli/certs/rustfs.key

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:

pg backup setup --s3-endpoint <store-host>:<api-port> \
  --s3-ca-file /path/to/rustfs.crt

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:

# MinIO / silo read public.crt + private.key:
pg addon install minio --name store --tls-cert anything.crt --tls-key anything.key
pg addon install silo  --name store --tls-cert anything.crt --tls-key anything.key

# rustfs reads rustfs_cert.pem + rustfs_key.pem:
pg addon install rustfs --name store --tls-cert anything.crt --tls-key anything.key

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:

pg cert --host "rustfs.test,127.0.0.1" --cert-file rustfs.crt --key-file rustfs.key
pg addon install rustfs --name store \
  --tls-cert rustfs.crt --tls-key rustfs.key --force

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

10 - Auto-start on Boot

Configure pgcli to automatically start PostgreSQL instances and services after a host reboot

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

pg autostart enable -i <instance-name>

Example:

pg autostart enable -i default

This adds autostart: true to the instance configuration and creates/updates the boot service.

For the Backup Container

pg autostart enable --backup

Enables auto-start for the shared pgBackRest backup container.

For PgBouncer

Enable auto-start for a PgBouncer associated with a specific instance:

pg autostart enable --pgbouncer -i <instance-name>

Or for a remote PgBouncer:

pg autostart enable --pgbouncer --pg-name <remote-name>

For an etcd Member

Enable auto-start for an etcd member installed via pg addon install etcd:

pg autostart enable --etcd --name <member-name>

The member name defaults to etcd when --name is omitted:

pg autostart enable --etcd

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:

pg autostart enable --kafka --name <instance>

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):

pg autostart enable --sqlite --name <database>

--name is required — SQLite has no default database name. Replication must already be enabled and its litestream.yml must exist:

pg sqlite replication enable appdb --s3-bucket sqlite-backups ...
pg autostart enable --sqlite --name appdb

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

pg autostart disable -i <instance-name>
pg autostart disable --backup
pg autostart disable --pgbouncer -i <instance-name>
pg autostart disable --pgbouncer --pg-name <remote-name>
pg autostart disable --etcd --name <member-name>
pg autostart disable --sqlite --name <database>

When all auto-start targets are disabled, the boot service is automatically removed.

Check Status

pg autostart 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:

[Service]
Type=oneshot
Delegate=yes
RemainAfterExit=yes
  • Delegate=yes - Delegates cgroup control to the service process, allowing rootless podman to properly manage container cgroup hierarchies
  • RemainAfterExit=yes - Keeps the service in active state after execution completes, rather than immediately transitioning to inactive
  • Type=oneshot - Executes pg start --autostart once 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:

Error: crun: writing file `/sys/fs/cgroup/.../cgroup.procs`: Permission denied: OCI permission denied

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 commands
  • pg psql - Interactive psql sessions
  • pg status - Check container status
  • pg backup - Backup operations
  • pg extension - Extension management
  • All other podman exec operations

Unaffected commands:

  • pg start - Start containers (uses systemd-run itself)
  • pg stop - Stop containers
  • pg restart - Restart containers

Viewing Service Status

pg autostart status now displays detailed systemctl information:

pg autostart status

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:

=== Auto-start targets ===
  instance default         enabled
  backup                 enabled
  etcd m1                  enabled

=== Boot service ===
  Unit:      pgcli-autostart-554d14ed
  Installed: yes
  Enabled:   enabled
  Running:   active
  Linger:    yes

=== Service status ===
     Loaded: loaded (/home/user/.config/systemd/user/pgcli-autostart-554d14ed.service; enabled)
     Active: active (exited) since Mon 2026-09-07 14:30:00 CST; 2h ago
    Process: 1234 pg -c /home/user/.config/pgcli/pg.yaml start --autostart (code=exited, status=0/SUCCESS)
   Main PID: 1234 (code=exited, status=0/SUCCESS)

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:

instances:
  default:
    # ... other settings ...
    autostart: true

backup:
  # ... other settings ...
  autostart: true

addons:
  # For PgBouncer
  pgbouncer:
    default:
      # ... other settings ...
      autostart: true
  # For etcd members
  etcd:
    m1:
      # ... other settings ...
      autostart: true
  # For SQLite databases (requires replication)
  sqlite:
    appdb:
      # ... other settings ...
      autostart: true

Boot Service Behavior

The boot service runs pg start --autostart, which:

  1. Starts all instances with autostart: true
  2. Starts the backup container if backup.autostart: true
  3. Starts PgBouncer services with autostart: true
  4. Starts etcd members with autostart: true (start-only; see above)
  5. 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):

journalctl --user -u pgcli-autostart-<hash>.service

macOS (launchd):

cat ~/Library/Logs/pgcli-autostart-<hash>.log

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:

# Enable the default instance
pg autostart enable -i default

# Enable the backup container
pg autostart enable --backup

# Enable PgBouncer for the default instance
pg autostart enable --pgbouncer -i default

# Enable etcd members m1 and m2 (names from pg addon list / pg.yaml)
pg autostart enable --etcd --name m1
pg autostart enable --etcd --name m2

# Restart Litestream for a SQLite database (replication must be enabled first)
pg autostart enable --sqlite --name appdb

# Check the status
pg autostart status

After the next reboot, all these services will start automatically.

11 - Extensions

PostgreSQL Extensions guide for pgcli

pgcli supports installing and managing PostgreSQL extensions from the Pigsty DEB repository.

How It Works

Extensions are baked into a derived container image:

  1. pg extension install builds a new image (based on the current image + Pigsty repo + extension packages)
  2. Stops and removes the old container
  3. Recreates the container from the new image (host data volumes are preserved)
  4. Updates image_tag in the config file

Benefits of this approach:

  • Extensions survive container rebuilds (baked into the image layer)
  • pg start does not need to run apt-get install on every boot
  • Extension files are persistent and decoupled from the container lifecycle

Commands

Install Extensions

# Install a single extension
pg extension install pg_stat_statements

# Install multiple extensions (single image build, comma-separated)
pg extension install pgmq,uuid-ossp,pg_stat_statements

# Target a specific instance
pg extension install pg_stat_statements -i pg01

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:

# Interactive confirmation (default)
pg extension install pg_stat_statements
# Output:
# Installing extensions that require shared_preload_libraries will cause a PostgreSQL restart.
# Extensions to be installed: [pg_stat_statements]
# This will cause a brief interruption to database connections.
# Restart PostgreSQL now? [y/N]:

# Skip confirmation and restart automatically
pg extension install pg_stat_statements --auto-restart

If you decline the restart, you can apply the changes later:

pg stop -i pg01
pg start -i pg01

List Installed Extensions

pg extension list -i pg01

Example output:

Installed extensions in "pg01":
  pg_stat_statements (managed)
  uuid-ossp (managed)
  plpgsql (unmanaged)
  • managed: tracked by pgcli (recorded in config, included in image)
  • unmanaged: manually installed extensions (not tracked in config)

Remove Extensions

pg extension remove pgmq -i pg01

Workflow:

  1. DROP EXTENSION IF EXISTS pgmq
  2. Update config and shared_preload_libraries
  3. No image rebuild — the -ext image 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:

# Interactive confirmation (default)
pg extension remove pg_stat_statements -i pg01
# Output:
# Removing extensions that require shared_preload_libraries will cause a PostgreSQL restart.
# Extensions to be removed: [pg_stat_statements]
# This will cause a brief interruption to database connections.
# Restart PostgreSQL now? [y/N]:

# Skip confirmation and restart automatically
pg extension remove pg_stat_statements -i pg01 --auto-restart

If you decline the restart, you can apply the changes later:

pg stop -i pg01
pg start -i pg01

View Available Extensions

pg extension available

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:

  [X] Unknown extension(s): [nonexistent_ext]

      These extensions are not in the Pigsty catalog or builtin contrib list.
      Check available extensions: pg extension available
      Full Pigsty catalog: https://pigsty.cc/ext/list/

Full catalog: https://pigsty.cc/ext/list/

Configuration

After installing extensions, the config is updated:

instances:
  pg01:
    extensions:
      - pg_stat_statements
      - uuid-ossp
      - pgmq
    podman:
      image_tag: ghcr.io/mars-base/pgcli/pgcli-pg:18-2.58.0-ext

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:

# === pgcli extensions (managed — do not edit) ===
shared_preload_libraries = 'pg_stat_statements,pg_cron'
# === end pgcli extensions ===

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

  [X] Unknown extension(s): [nonexistent_ext]

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., vector not pgvector)

CREATE EXTENSION Failure

ERROR: extension "pgmq" already exists

The extension is installed but not tracked in config. You can safely ignore this, or manually add it to the config:

extensions:
  - pgmq

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 EXTENSION will 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 via pg exec

11.1 - Extension Catalog

Built-in PostgreSQL extensions available in the base image

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

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.
# Install all at once
pg extension install pg_stat_statements,pg_repack,pg_prewarm --auto-restart

# Find top 5 slow queries
pg exec "SELECT query, calls, total_exec_time::numeric(10,2) AS ms
         FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 5;"

# Repack a bloated table
pg exec -- pg_repack -U admin -d default_db -t my_table --no-superuser-check

# Prewarm a table
pg exec "SELECT pg_prewarm('my_table');"

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.
# UUID generation (PG 18 built-in — no extension needed)
pg exec "SELECT gen_random_uuid();"  # v4 random UUID (built-in since PG 13)
pg exec "SELECT uuidv7();"           # v7 time-ordered UUID (new in PG 18)

# uuid-ossp extension — only needed for v1/v3/v5 UUIDs
pg exec "CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\";"
pg exec "SELECT uuid_generate_v1();"  # v1 timestamp + MAC address
pg exec "SELECT uuid_generate_v3(uuid_ns_url(), 'https://example.com');"  # v3 MD5-based
pg exec "SELECT uuid_generate_v5(uuid_ns_url(), 'https://example.com');"  # v5 SHA-1 based

# Key-value storage
pg exec "SELECT 'theme => dark, lang => en'::hstore -> 'theme';"
# Returns: dark

# Case-insensitive matching
pg exec "CREATE TABLE users (email citext);
         INSERT INTO users VALUES ('Alice@Example.COM');
         SELECT * FROM users WHERE email = 'alice@example.com';"

Search & Text

Extension Install Description
pg_trgm builtin Trigram similarity for fuzzy search and autocomplete.
unaccent builtin Strip accents for flexible international text search.
# Fuzzy search
pg exec "SELECT name, similarity(name, 'John') FROM users
         ORDER BY similarity(name, 'John') DESC LIMIT 5;"

# Create a trigram index for fast fuzzy search
pg exec "CREATE INDEX users_name_trgm_idx ON users USING gin (name gin_trgm_ops);"

# Remove accents
pg exec "SELECT unaccent('Crème Brûlée');"  -- returns 'Creme Brulee'

# Combine for accent-insensitive fuzzy search
pg exec "SELECT name FROM users WHERE name % unaccent('Creme Brulee');"

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).
# Hash a password with bcrypt
pg exec "SELECT crypt('my_password', gen_salt('bf'));"

# Verify a password
pg exec "SELECT (crypt('my_password', stored_hash) = stored_hash);"

# Symmetric encryption/decryption
pg exec "SELECT pgp_sym_decrypt(pgp_sym_encrypt('secret', 'key'), 'key');"

# Enable audit logging for specific operations
pg exec "SET pgaudit.log = 'read, ddl';"

Geospatial

Extension Install Description
postgis pg extension install postgis Full spatial database: geometry, spatial indexes, distance/area.
pg extension install postgis --auto-restart

# Create a table with spatial data
pg exec "CREATE TABLE places (id serial, name text, geom geometry(Point, 4326));"
pg exec "INSERT INTO places (name, geom) VALUES
         ('San Francisco', ST_MakePoint(-122.4, 37.8)),
         ('London', ST_MakePoint(-0.1276, 51.5074));"

# Find places within 5km of a point
pg exec "SELECT name FROM places
         WHERE ST_DWithin(geom, ST_MakePoint(-122.4, 37.8)::geography, 5000);"

# Calculate distance between two cities (in meters)
pg exec "SELECT ST_Distance(
           ST_MakePoint(-74.006, 40.7128)::geography,
           ST_MakePoint(-0.1276, 51.5074)::geography);"
Extension Install Description
pgvector pg extension install vector Store and search embedding vectors. Standard for AI apps.
pg extension install vector --auto-restart

# Create a table with vector column
pg exec "CREATE TABLE items (id serial PRIMARY KEY, embedding vector(3));"
pg exec "INSERT INTO items (embedding) VALUES ('[1,2,3]'), ('[4,5,6]'), ('[7,8,9]');"

# Find nearest neighbors (Euclidean distance)
pg exec "SELECT id FROM items ORDER BY embedding <-> '[3,1,2]' LIMIT 5;"

# Create an IVFFlat index for fast approximate search
pg exec "CREATE INDEX items_embedding_idx ON items USING ivfflat (embedding vector_l2_ops);"

Time Series

Extension Install Description
timescaledb pg extension install timescaledb Optimized time-series storage with hypertables.
pg extension install timescaledb --auto-restart

# Create a hypertable
pg exec "CREATE TABLE sensor (time timestamptz NOT NULL, value float);
         SELECT create_hypertable('sensor', 'time');"

# Time-bucket aggregation
pg exec "SELECT time_bucket('1 hour', time) AS bucket, avg(value)
         FROM sensor GROUP BY bucket ORDER BY bucket;"

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.
pg extension install pg_cron --auto-restart

# Schedule a daily cleanup job (runs at midnight)
pg exec "SELECT cron.schedule('cleanup', '0 0 * * *',
         'DELETE FROM logs WHERE created_at < now() - interval ''30 days''');"

# List scheduled jobs
pg exec "SELECT jobid, schedule, command FROM cron.job;"

# Unschedule a job
pg exec "SELECT cron.unschedule('cleanup');"

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.
# Create a foreign server
pg exec "CREATE SERVER remote FOREIGN DATA WRAPPER postgres_fdw
         OPTIONS (host '10.0.0.2', port '5432', dbname 'analytics');"

# Create user mapping
pg exec "CREATE USER MAPPING FOR current_user SERVER remote
         OPTIONS (user 'reader', password 'secret');"

# Import remote schema as foreign tables
pg exec "IMPORT FOREIGN SCHEMA public FROM SERVER remote INTO remote_schema;"

# Query remote data as if it were local
pg exec "SELECT * FROM remote_schema.events LIMIT 10;"
pg extension install pg_duckdb --auto-restart

# Run SQL in DuckDB — duckdb.query is a table function (FROM clause required)
pg exec "SELECT * FROM duckdb.query(\$\$ SELECT 42 AS answer, 'hello' AS msg \$\$);"

# Export a table to Parquet
pg exec "COPY my_table TO '/tmp/my_table.parquet' (FORMAT 'parquet');"

# Read Parquet/CSV files directly
pg exec "SELECT * FROM duckdb.query(\$\$
         SELECT * FROM read_parquet('/tmp/my_table.parquet') WHERE id > 2 \$\$);"

# Offload an entire query to DuckDB (OLAP acceleration)
pg exec "SET duckdb.force_execution = true;
         SELECT category, sum(amount) FROM sales GROUP BY category;"

# Pure DuckDB SQL (no PostgreSQL planner involved)
pg exec "SELECT * FROM duckdb.raw_query(\$\$ SELECT range AS n FROM range(1, 6) \$\$);"

# --- Read files from S3 / MinIO (no file copy needed) ---

# Create an S3 secret once (use_ssl is text: 'true'/'false')
pg exec "SELECT duckdb.create_simple_secret('S3', '<access_key>', '<secret_key>',
         '', 'us-east-1', 'path', '', 'minio.example.com:9000',
         's3://bucket', '', 'false');"

# Query a CSV in the bucket directly
pg exec "SELECT * FROM duckdb.query(\$\$
         SELECT category, count(*) AS cnt, sum(sales) AS total_sales
         FROM read_csv('s3://bucket/products.csv', auto_detect=true)
         GROUP BY category ORDER BY total_sales DESC \$\$);"

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 addon management guide

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 via pg addon but is its own top-level command, and its documentation now lives in a dedicated HA Cluster section. etcd and HAProxy here are addons a pg ha cluster can use.

How It Works

Addons run as standalone containers managed through pg.yaml:

  1. pg addon install generates configuration and starts the addon container
  2. Config and data live under <base-dir>/addon/<addon-name>/
  3. Addon containers communicate over the host network
  4. 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

pg addon install <addon> [flags]   # install / reconfigure (idempotent)
pg addon list                       # show all installed addons and status
pg addon remove <addon> [flags]     # remove the addon, container, and data

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

Connection pooling for PostgreSQL instances via the PgBouncer addon

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 under instances.<name>.addons
  • Remote: pg addon install pgbouncer --dsn <dsn> --pg-name <name> — stored in top-level addons.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 --dsn host must be reachable from the Mac — do not point it at 127.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

  1. pg addon install pgbouncer generates configuration files and starts the container
  2. Config files live in <base-dir>/addon/pgbouncer/<instance>/
  3. The container reaches PostgreSQL over the host network (Linux) or the pgcli-net bridge by container name (macOS)
  4. 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.

# Config with namespace "prod"
pg -c prod-pg.yaml addon install pgbouncer -i mypg
# → container: pgcli-pgbouncer-prod-mypg, auth user: pgb_prod_mypg

# Config with namespace "staging"
pg -c staging-pg.yaml addon install pgbouncer -i mypg
# → container: pgcli-pgbouncer-staging-mypg, auth user: pgb_staging_mypg

Commands

Install

# Local mode: pool a managed instance
pg addon install pgbouncer -i mypg

# Remote mode: pool a remote PG instance
pg addon install pgbouncer \
  --dsn "postgres://admin:pass@10.0.0.20:35432/mypg_db" \
  --pg-name my-remote-pool

# Tune pool parameters
pg addon install pgbouncer -i mypg \
  --max-client-conn 200 \
  --default-pool-size 30 \
  --min-pool-size 5 \
  --reserve-pool-size 10 \
  --max-db-connections 50 \
  --query-timeout 60 \
  --admin-users admin \
  --log-connections 1

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

pg addon list

PgBouncer appears under Local add-ons / Remote add-ons:

Local add-ons:
  pgbouncer (instance: mypg)
    Status:    running
    Host:      127.0.0.1:56432
    Port:      56432
    Pool mode: transaction
    Container: pgcli-pgbouncer-default-mypg

Remote add-ons:
  pgbouncer (pg-name: my-remote-pool)
    Status:    running
    Host:      10.0.0.20:56433
    Port:      56433
    Pool mode: transaction
    Container: pgcli-pgbouncer-default-my-remote-pool

Start and stop

After a reboot or manual stop, bring the pooler back up without re-running install (config and auth users are untouched):

# Local
pg addon start pgbouncer -i mypg
pg addon stop pgbouncer -i mypg

# Remote
pg addon start pgbouncer --pg-name my-remote-pool
pg addon stop pgbouncer --pg-name my-remote-pool

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

# Remove local PgBouncer
pg addon remove pgbouncer -i mypg

# Remove remote PgBouncer
pg addon remove pgbouncer --pg-name my-remote-pool

Workflow:

  1. Stop and remove the addon container
  2. Delete the <base-dir>/addon/pgbouncer/<instance>/ directory and files
  3. Remove the addon entry from pg.yaml

Configuration

Local mode (under instances.<name>.addons):

instances:
  mypg:
    addons:
      pgbouncer:
        enabled: true
        max_client_conn: 200
        default_pool_size: 30
        min_pool_size: 5
        reserve_pool_size: 10
        max_db_connections: 50
        query_timeout: 60
        admin_users: admin
        log_connections: 1

Remote mode (under top-level addons):

addons:
  pgbouncer:
    my-remote-pool:
      container_name: pgcli-pgbouncer-default-my-remote-pool
      host_port: 56433
      pool_mode: transaction
      dsn: "postgres://admin:pass@10.0.0.20:35432/mypg_db"
      max_client_conn: 200
      default_pool_size: 30

Generated files, per instance, under <base-dir>/addon/pgbouncer/:

<base-dir>/addon/pgbouncer/
├── mypg/
│   ├── pgbouncer.ini    # PgBouncer main configuration
│   └── userlist.txt     # auth user credentials (auto-regenerated)
└── my-remote-pool/
    ├── pgbouncer.ini
    └── userlist.txt

Authentication

PgBouncer uses the auth_query method for dynamic password lookup:

  1. 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)
  2. A shared SECURITY DEFINER function pgbouncer_lookup() is installed to query pg_authid
  3. When a client connects, PgBouncer uses its own auth user to run the auth_query and fetch the real user’s password hash
  4. 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:

# Direct connection to PostgreSQL
pg exec -i mypg "SELECT version()"

# Connection through PgBouncer
pg exec --dsn "postgres://user:pass@127.0.0.1:56432/mypg_db" "SELECT version()"

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

pg addon install pgbouncer -i mypg \
  --max-client-conn 1000 \
  --default-pool-size 50 \
  --reserve-pool-size 20 \
  --max-db-connections 100

Short-Lived Connections

pg addon install pgbouncer -i mypg \
  --pool-mode transaction \
  --server-idle-timeout 60 \
  --server-lifetime 600

Long-Lived Connections

pg addon install pgbouncer -i mypg \
  --pool-mode session \
  --server-lifetime 86400

Read Replicas

pg addon install pgbouncer -i mypg-replica \
  --pool-mode transaction \
  --max-db-connections 30 \
  --query-timeout 30

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:

pg exec --dsn "postgres://<admin-user>:<password>@127.0.0.1:<pgbouncer-port>/pgbouncer" "SHOW pools"

Example:

pg exec --dsn "postgres://admin:secret@127.0.0.1:56432/pgbouncer" "SHOW pools"

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

# Check pool status
pg exec --dsn "postgres://admin:secret@127.0.0.1:56432/pgbouncer" "SHOW pools"

# View active client connections
pg exec --dsn "postgres://admin:secret@127.0.0.1:56432/pgbouncer" "SHOW clients"

# View PostgreSQL backend connections
pg exec --dsn "postgres://admin:secret@127.0.0.1:56432/pgbouncer" "SHOW servers"

# Check current configuration
pg exec --dsn "postgres://admin:secret@127.0.0.1:56432/pgbouncer" "SHOW config"

# View traffic statistics
pg exec --dsn "postgres://admin:secret@127.0.0.1:56432/pgbouncer" "SHOW stats"

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

ERROR: no more connections allowed

Cause: Reached max_client_conn limit.

# Increase maximum connections
pg addon install pgbouncer -i mypg --max-client-conn 500

# Or reduce pool size
pg addon install pgbouncer -i mypg --default-pool-size 10

Authentication Failure

FATAL: password authentication failed

Cause: Auth cache contains a stale password hash.

# Re-run install to reset the auth cache
pg addon install pgbouncer -i mypg

Container Won’t Start

# View container logs
podman logs pgcli-pgbouncer-default-mypg

# Check the configuration file
cat <base-dir>/addon/pgbouncer/mypg/pgbouncer.ini

Common causes: config syntax error, malformed user list, or port already in use.

Query Timeout

ERROR: query timeout

Cause: Query exceeded query_timeout.

# Increase the timeout, or disable it
pg addon install pgbouncer -i mypg --query-timeout 300
# or
pg addon install pgbouncer -i mypg --query-timeout 0

Notes

  • Port: PgBouncer defaults to 56432; ensure firewall rules allow access
  • Passwords: After changing a PostgreSQL user password, re-run pg addon install to reset the auth cache
  • Config edits: After editing config files manually, restart to apply:
    podman restart pgcli-pgbouncer-default-mypg
  • Transaction mode: transaction mode does not support session-level features (e.g. temporary tables); use session mode instead

12.2 - Etcd

Run a standalone etcd cluster as a pgcli addon for HA / DCS use

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.etcd map in pg.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 to 127.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 local etcdctl and remote peers both work.
  • Dynamic membership: the first member starts with --initial-cluster-state new; each subsequent member is registered with etcdctl member add against 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 --cluster value join the same etcd cluster (it maps to etcd’s --initial-cluster-token). The default is pgcli-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.

pg addon install etcd --name m1              # 127.0.0.1:2379/2380
pg addon install etcd --name m2              # 127.0.0.1:2381/2382
pg addon install etcd --name m3              # 127.0.0.1:2383/2384

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.

# host A (10.0.0.1) — bootstrap
pg addon install etcd --name m1 --cluster prod \
  --advertise-host 10.0.0.1 --client-port 2379 --peer-port 2380

# host B (10.0.0.2) — join
pg addon install etcd --name m2 --cluster prod \
  --advertise-host 10.0.0.2 --client-port 2379 --peer-port 2380 \
  --join http://10.0.0.1:2379

# host C (10.0.0.3) — join
pg addon install etcd --name m3 --cluster prod \
  --advertise-host 10.0.0.3 --client-port 2379 --peer-port 2380 \
  --join http://10.0.0.1:2379

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

pg addon install etcd --name m1
-> Bootstrapping etcd cluster...
  [OK] etcd container started

✓ etcd installed: "m1"
  Container:    pgcli-etcd-m1
  Cluster:      pgcli-etcd
  Data dir:     ~/.pgcli/addon/etcd/m1/data
  Client port:  2379
  Peer port:    2380
  Advertise:    127.0.0.1

  Client URL: http://127.0.0.1:2379
  Connect (etcdctl): ETCDCTL_ENDPOINTS=http://127.0.0.1:2379

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:

pg addon install etcd --name m2
pg addon install etcd --name m3

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:

# host A (10.0.0.1)
pg addon install etcd --name m1 --cluster prod \
  --advertise-host 10.0.0.1 --client-port 2379 --peer-port 2380

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:

# host B (10.0.0.2)
pg addon install etcd --name m2 --cluster prod \
  --advertise-host 10.0.0.2 --client-port 2379 --peer-port 2380 \
  --join http://10.0.0.1:2379
-> Registering member with the cluster at http://10.0.0.1:2379...
-> Starting etcd container (joining cluster)...
  [OK] etcd container started

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 default 127.0.0.1 can never be joined from another host. If you plan a cross-host cluster, pass the LAN address at bootstrap time.
  • --cluster is required with --join and must match the remote cluster’s token — membership is checked by that cluster, not by the local config.
  • --advertise-host is required with --join; without it the other members could register a peer URL they can’t dial.
  • Ports may repeat across hosts (2379/2380 on 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 or pg addon list (or pin them with --client-port/--peer-port) and open that range, e.g. 2379-2386/tcp for a 4-member cluster on default ports. Skipping this is the usual cause of a member hanging with etcdserver: no leader or a --join that times out.
  • Re-running the same --join install for an already-registered name fails with a clear error; deregister first via pg 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:

export ETCDCTL_ENDPOINTS=http://10.0.0.2:2379
pg etcdctl member list
pg etcdctl endpoint health
pg etcdctl endpoint status -- -w table

etcdctl flags that pg’s own parser would reject (-w table, --hex, …) go after --.

List

pg addon list

etcd members appear under the Infra add-ons (etcd) section:

Infra add-ons (etcd):
  etcd (name: m1)
    Status:      running
    Cluster:     pgcli-etcd
    Client URL:  http://127.0.0.1:2379
    Client port: 2379
    Peer port:   2380
    Image:       quay.io/coreos/etcd:v3.5.30
    Container:   pgcli-etcd-m1

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:

pg addon start etcd --name m1
pg addon stop etcd --name m1

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

pg addon remove etcd --name m2

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:

# 1. on the host that runs the member — stop and clean up local state
pg addon remove etcd --name m4

# 2. from any surviving host's member, deregister it from the cluster
export ETCDCTL_ENDPOINTS=http://10.0.0.1:2379   # a surviving member's client URL
pg etcdctl member list                            # find m4's hex ID
pg etcdctl member remove <hex-id>                 # e.g. 5c7048c8f7521ec7

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:

<base_dir>/addon/etcd/
├── m1/data/     # member m1
├── m2/data/     # member m2
└── m3/data/     # member m3

--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:

# base_dir: /data/pgcli  →  root /data/pgcli/etcd, member d1 → /data/pgcli/etcd/d1/data
pg addon install etcd --name d1 --data-dir ./etcd

# absolute root
pg addon install etcd --name d2 --data-dir /mnt/ssd/etcd

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 member list
pg etcdctl endpoint status -- -w table
pg etcdctl endpoint health
pg etcdctl put foo bar
pg etcdctl get foo -- --hex

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:

addons:
  etcd:
    m1:
      container_name: pgcli-etcd-m1
      name: m1
      cluster_name: pgcli-etcd
      image_tag: quay.io/coreos/etcd:v3.5.30
      data_dir: /home/user/.pgcli/addon/etcd
      client_port: 2379
      peer_port: 2380
      autostart: true
    m2:
      container_name: pgcli-etcd-m2
      name: m2
      cluster_name: pgcli-etcd
      client_port: 2381
      peer_port: 2382

Port base is configurable via top-level etcd_start_port (default 2379). A cross-host member records the address it advertises:

addons:
  etcd:
    m1:
      name: m1
      cluster_name: prod
      advertise_host: 10.0.0.1
      client_port: 2379
      peer_port: 2380

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:

pg autostart enable --etcd --name m1
pg autostart enable --etcd --name m2
pg autostart enable --etcd --name m3

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 m1 alone gives a one-member cluster; install more members with the same --cluster to 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

Run PgDog — a Postgres proxy for connection pooling, load balancing and sharding — as a pgcli addon

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 mode 0600 and mounts it read-only into the container, but the passwords still sit in clear text on disk and in pg.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.0 inside the container so the published port is reachable (a loopback-only bind can’t be forwarded to); the client address you connect to is still 127.0.0.1:<port>.
  • Backends must be reachable from the bridge. PgDog has no automatic name-resolution for --backend, so a 127.0.0.1 backend would point at the Mac, not the podman machine VM. Give each --backend either the target instance’s container name (run pg status -i <instance> and read the Container: line, e.g. app=pgcli-pg-mypg:5432:...) or an address the Mac can route to — never 127.0.0.1 for a managed instance.

How It Works

  • Shared infrastructure: PgDog lives in the top-level addons.pgdog map in pg.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-style openmetrics port on the next free number. On Linux it runs with --network host; on macOS it joins pgcli-net and publishes both ports.
  • Generated config: pgdog.toml + users.toml are written under <base-dir>/addon/pgdog/<name>/ and bind-mounted into the container at /pgdog.
  • Image pinned: ghcr.io/pgdogdev/pgdog:v0.1.57 by default; override with --image.

Install

A minimal single-backend proxy (pooling only — see the note below):

pg addon install pgdog \
  --backend "app=127.0.0.1:35432:default_db" \
  --user "appuser:secret:app" \
  --sharded-table "app:users:id:bigint"

Declare a --sharded-table so the example is a complete, self-consistent config (it mirrors the sharding example below). Be aware that with a single --backend there 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. NAME is the logical database clients connect to; DBNAME is the real database on the backend. SHARD defaults to 0; ROLE to PgDog’s default (primary, or set replica for read routing). Repeatable.
  • --user NAME:PASSWORD[:DBNAME] — one [[users]] entry. The DBNAME is the logical database (a --backend name) the user may reach; it defaults to the first backend’s name. Repeatable.
  • At least one --backend and one --user are 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:

pg addon install pgdog \
  --backend "app=10.0.0.1:5432:shard0:0" \
  --backend "app=10.0.0.2:5432:shard1:1" \
  --backend "app=10.0.0.1:5433:shard0:0:replica" \
  --backend "app=10.0.0.2:5433:shard1:1:replica" \
  --user "appuser:secret:app" \
  --sharded-table "app:users:id:bigint"

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):

# 1. create the shard databases
pg exec -i default -- psql -U admin -d default_db -c "CREATE DATABASE shard0;"
pg exec -i default -- psql -U admin -d default_db -c "CREATE DATABASE shard1;"

# 2. create the sharded table (same schema) in each shard
for db in shard0 shard1; do
  pg exec -i default -- psql -U admin -d "$db" \
    -c "CREATE TABLE users (id bigint PRIMARY KEY, name text, email text);"
done

# 3. create the login role PgDog uses to reach the backends — its name must
#    match a --user, and its password must match that user's users.toml entry
pg exec -i default -- psql -U admin -d default_db \
  -c "CREATE ROLE appuser LOGIN PASSWORD 'secret';"

# 4. grant that role access to the sharded table in every shard
for db in shard0 shard1; do
  pg exec -i default -- psql -U admin -d "$db" \
    -c "GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE users TO appuser;"
done

# 5. install the proxy over both shards (see "Sharding and replicas" above)
pg addon install pgdog \
  --backend "app=127.0.0.1:35432:shard0:0" \
  --backend "app=127.0.0.1:35432:shard1:1" \
  --user "appuser:secret:app" \
  --sharded-table "app:users:id:bigint"

# 6. write rows through the proxy with pgcli (pg exec --dsn), ONE row per
#    statement so PgDog routes each by its id (a multi-row INSERT is broadcast
#    to every shard instead)
for id in 1 2 3 4 5 6; do
  pg exec --dsn "postgres://appuser:secret@127.0.0.1:7432/app" \
    "INSERT INTO users (id, name, email) VALUES ($id, 'n$id', 'e$id@x');"
done

# 7. read each shard directly to confirm the rows landed where PgDog routed
#    them — pg psql -- -d <db> targets one database
pg psql -i default -- -d shard0 -tAc "SELECT id, name FROM users ORDER BY id;"
pg psql -i default -- -d shard1 -tAc "SELECT id, name FROM users ORDER BY id;"

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 carry server_user / server_password, and a [[databases]] entry can carry user / password (which take priority). pgcli’s install flags do not currently emit these fields, so through pg addon install pgdog the backend role name is always the --user name. If you need a shared backend role (a common postgres superuser, or a name that differs from every client user), hand-edit the generated users.toml/pgdog.toml and 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:

# interactive psql through the proxy
pg psql --dsn "postgres://appuser:secret@127.0.0.1:7432/app"

# one-shot SQL
pg exec --dsn "postgres://appuser:secret@127.0.0.1:7432/app" "SELECT version();"

Prometheus metrics are served on the openmetrics port:

curl -s http://127.0.0.1:7433/metrics

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:

pg exec --dsn "postgres://appuser:secret@127.0.0.1:7432/app" \
  "INSERT INTO users (id, name, email) VALUES (4, 'n4', 'e4@x');"

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:

pg exec --dsn "postgres://appuser:secret@127.0.0.1:7432/app" \
  "INSERT INTO users (id, name, email) VALUES (1,'a','a@x'),(2,'b','b@x'),(3,'c','c@x'),(4,'d','d@x');"

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:

addons:
  pgdog:
    pgdog:
      container_name: pgcli-pgdog-pgdog
      name: pgdog
      image_tag: ghcr.io/pgdogdev/pgdog:v0.1.57
      host: 127.0.0.1
      host_port: 7432
      openmetrics_port: 7433
      pooler_mode: transaction
      workers: 2
      default_pool_size: 10
      backends:
        - name: app
          host: 127.0.0.1
          port: 35432
          database_name: default_db
          shard: 0
      users:
        - name: appuser
          password: secret
          database: app
      autostart: false

Port base is configurable via the top-level pgdog_start_port (default 7432); the openmetrics port is always allocated just above it.

List

pg addon list

Every PgDog proxy appears under the Infra add-ons (pgdog) section with its runtime status, ports, backend/user counts, image, and container name:

Infra add-ons (pgdog):
  pgdog (name: pgdog)
    Status:      running
    Listen:      127.0.0.1:7432
    Client port: 7432
    Metrics:     http://127.0.0.1:7433/metrics
    Pool mode:   transaction
    Backends:    2
    Users:       1
    Image:       ghcr.io/pgdogdev/pgdog:v0.1.57
    Container:   pgcli-pgdog-pgdog

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):

pg addon start pgdog            # default name "pgdog"
pg addon start pgdog --name proxy
pg addon stop pgdog --name proxy

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:

pg autostart enable --pgdog --name pgdog

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

pg logs addon pgdog --name pgdog       # last 50 lines
pg logs addon pgdog --name pgdog -f    # follow

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 --backend is removed, not kept.
  • Plaintext credentials: passwords live in users.toml and pg.yaml. Prefer a dedicated low-privilege role for proxy users and restrict network access to the client port.
  • Write forms differ: single-row INSERT is routed by the shard key; a multi-row INSERT VALUES is 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

Run Garage (the Rust S3-compatible object store from Deuxfleurs) as a pgcli addon — single-node or multi-node cross-host cluster, plaintext HTTP, TLS terminated by fronting it with the nginx addon

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:

  1. 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.
  2. Two kinds of directory — and multi-disk is native since Garage v0.9.0. metadata_dir (LMDB) and data_dir (blocks) are separate bind mounts, and metadata_dir is always exactly one. data_dir takes 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.
  3. Four ports, from their own pool. S3 API, node-to-node RPC, the s3_web static site, and the admin/metrics API — allocated together per instance from garage_start_port (default 3900), a separate cursor from the shared minio/silo/rustfs pool.
  4. No native TLS — plaintext HTTP only. pgBackRest’s S3 repository forces HTTPS, so a Garage store meant to receive archive-push must 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_user is the access key id, generated on first install with Garage’s own GK-prefixed shape (GK + 32 hex chars, what openssl rand -hex 16 yields in the quick-start), or supplied via --root-user.
  • root_password is 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

# default instance name "garage", four ports from the garage pool, loopback bind
pg addon install garage

# a named instance with an explicit data directory
pg addon install garage --name store --data-dir /srv/garage

# spread this node's blocks over several disks (see Multi-disk data dirs below)
pg addon install garage --name store --drive /mnt/d1/garage --drive /mnt/d2/garage

# a fixed S3 API port (RPC/web/admin take the next three free ports)
pg addon install garage --name store --api-port 3900

# expose the store on the network instead of loopback only
pg addon install garage --name store --listen 0.0.0.0

# supply your own access key / secret key instead of generating them
#   Garage's own limits apply: access key >= 8 chars, secret key >= 16 chars
pg addon install garage --name store --root-user GK<...> --root-password <secret>

# join a cross-host cluster (see Multi-node cluster below)
pg addon install garage --name store --cluster c1 --advertise-host 10.0.0.11 \
  --zone z1 --capacity 15 --replication-factor 3 \
  [--peer <id@addr> --rpc-secret <64hex>]   # a joiner carries both

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 create at 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, install itself reports success while the node crash-loops behind --restart unless-stopped. Check podman logs (or just let pg mc alias set fail) if a custom-key install comes up unable to serve. The generated shapes (GK + 32 hex / 64 hex) clear both bounds with room; mc additionally requires an access key of [A-Za-z0-9], so stay alphanumeric.

The output reports endpoints and the seeded credentials:

✓ garage installed: "store"
  Container:     pgcli-garage-default-store
  Image:         docker.io/dxflrs/garage:v2.4.1
  Data:          ~/pg/addon/garage/store/data
  Metadata:      ~/pg/addon/garage/store/meta
  Config:        ~/pg/addon/garage/store/garage.toml
  S3 API:        http://127.0.0.1:3900
  RPC/Web/Admin: 3901/3902/3903

  Access key:    GK<generated>
  Secret key:    <generated>
  Bucket:        store (created on first boot, owned by the access key)

  NOTE: Garage serves plaintext HTTP only. pgBackRest requires HTTPS — front it
        with the nginx addon via --conf-file (template --upstream mode strips
        the port from Host and breaks S3 signing; see docs/addon/garage):
          backend 127.0.0.1:3900, listen ssl, proxy_set_header Host $http_host
  Try it:        pg mc alias set store http://127.0.0.1:3900 GK<...> <secret> && pg mc ls store

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.1 keeps the store local. --listen 0.0.0.0 (or the listen key in pg.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.

--tls is rejected for garage. Garage has no TLS listener to enable; passing --tls/--tls-cert/--tls-key errors 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 --upstream template. S3 requests are signed with SigV4 over the Host header including the port. The template mode forwards Host $host, which nginx renders without the port — Garage then re-derives a different canonical request and every write fails with Invalid signature (verified: pg mc mb through a template front is refused; through the --conf-file front below it round-trips). The custom config also lets you set client_max_body_size 0; (the 1 MiB nginx default otherwise rejects any real object upload) and long proxy timeouts.

pg addon install garage --name store

# front.conf — path-style S3 over TLS, host+port preserved for signing:
cat > front.conf <<'EOF'
worker_processes auto;
pid /tmp/nginx.pid;
events { worker_connections 1024; }
http {
    access_log /var/log/nginx/access.log;
    error_log  /var/log/nginx/error.log;
    server {
        listen 0.0.0.0:8081 ssl;          # 0.0.0.0 so remote clients can reach it; the HTTPS port pgcli assigned
        ssl_certificate     /etc/nginx/certs/public.crt;
        ssl_certificate_key /etc/nginx/certs/private.key;
        client_max_body_size 0;          # unlimited PUT
        proxy_read_timeout   3600s;
        proxy_send_timeout   3600s;
        location / {
            proxy_pass http://127.0.0.1:3900;   # the garage S3 API port
            proxy_set_header Host $http_host;   # keep the port — SigV4
            proxy_request_buffering off;
            proxy_http_version 1.1;
        }
    }
}
EOF

pg addon install nginx --name gtls --tls --conf-file front.conf --https-port 8081

--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. The listen line 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. The proxy_pass still targets 127.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, bind listen 127.0.0.1:<port> ssl; instead.

Why the listen and proxy_pass lines are pinned by hand. In --conf-file mode pgcli mounts your config verbatim, so the ports in it must match the ones the two addons were assigned — read the garage S3 API: port from its install output and the nginx HTTPS: port from pg addon list. (The $http_host and client_max_body_size lines 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:

pg backup fetch-ca <front-host>:<https-port>
#   [OK] CA fetched from <front-host>:<https-port>
#        saved:    ~/.pgcli/backup/repo-ca/ca-<front-host>-<https-port>.crt
#        SHA-256:  c0f0…fe2e
pg backup setup --s3-ca-file ~/.pgcli/backup/repo-ca/ca-<front-host>-<https-port>.crt

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:

http {
    upstream garage_s3 {
        server 10.0.0.11:3904;   # member A, this site
        server 10.0.0.12:3900;   # member B, this site
        # no remote member: it stays a data replica, off the request path
    }
    server {
        listen 0.0.0.0:8081 ssl;
        ssl_certificate     /etc/nginx/certs/public.crt;
        ssl_certificate_key /etc/nginx/certs/private.key;
        client_max_body_size 0;
        proxy_read_timeout   3600s;
        proxy_send_timeout   3600s;
        location / {
            proxy_pass http://garage_s3;
            proxy_set_header Host $http_host;   # keep the port — SigV4
            proxy_request_buffering off;
            proxy_http_version 1.1;
        }
    }
}

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; --listen still defaults to 127.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):

pg backup setup \
  --s3-endpoint <front-host>:<https-port> \
  --s3-bucket store \
  --s3-access-key GK<...> \
  --s3-secret-key <secret> \
  --s3-ca-file <front>/addon/nginx/gtls/tls/ca.crt

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:

pg addon install garage --name store \
  --drive /mnt/d1/garage --drive /mnt/d2/garage --drive /mnt/d3/garage

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 --drive order — and the rendered garage.toml lists them as Garage’s array form:

    data_dir = [
      { path = "/var/lib/garage/data1", capacity = "4G" },
      { path = "/var/lib/garage/data2", capacity = "4G" },
    ]
  • The per-entry capacity is mandatory — v2.4 refuses to boot an entry that carries neither a capacity nor read_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. --capacity stays the node-level number, unchanged by this.

  • metadata_dir never joins the set — it stays the single meta dir beside the instance’s data path (LMDB is one directory by upstream design).

  • --drive and --data-dir are mutually exclusive: drives fully replace the resolved data dir. Re-running install without --drive on 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 list reports it as Drives: N (multi-disk data_dir) with one container-slot <- host-dir line each; the install summary prints the same mapping as Drive 1: … -> /var/lib/garage/data1 lines.

  • remove --clean-data goes 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 (sha256sum identical) through a 3-node rf = 3 cluster 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:

# on 10.0.0.11
pg addon install garage --name store --cluster c1 \
  --advertise-host 10.0.0.11 --zone z1 --capacity 15 --replication-factor 3

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:

  Cluster:       c1 (role: bootstrapping cluster, replication factor 3)
  Advertise:     10.0.0.11:3905
  Zone/Capacity: z1 / 15 GiB
  Node ID:       1c0597cc…200aeb@10.0.0.11:3905
  [i] layout staged but not applied yet — Garage needs replication-factor assigned nodes to commit (later joins will).

  To add ANOTHER member, run on the next host (carrying --rpc-secret yourself;
  pgcli never executes remotely). --advertise-host is that host's own peer-
  reachable IP; pick a --zone per failure domain and keep --replication-factor:
    pg addon install garage --name store --cluster c1 \
      --advertise-host <next-host-ip> --rpc-secret <64hex> \
      --peer 1c0597cc…200aeb@10.0.0.11:3905 --zone <zone> --capacity <GiB> --replication-factor 3

Host 2 — join. Copy the printed command, change --advertise-host to this host’s IP and --zone to a distinct zone:

# on 10.0.0.12
pg addon install garage --name store --cluster c1 \
  --advertise-host 10.0.0.12 --rpc-secret <64hex> \
  --peer 1c0597cc…200aeb@10.0.0.11:3905 --zone z2 --capacity 15 --replication-factor 3

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:

# on 10.0.0.14
pg addon install garage --name store --cluster c1 \
  --advertise-host 10.0.0.14 --rpc-secret <64hex> \
  --peer 5b370435…1c1c9187@10.0.0.12:3901 --zone z3 --capacity 15 --replication-factor 3
  Access key:    GKc57063…6147b0
  Secret key:    <printed once, here>
  Bucket:        store (cluster key + bucket seeded on this install)

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 create prints the secret exactly once and is not idempotent (a re-run mints a second key with the same name). pgcli checks key list first and only creates when absent. Whichever host performs the seeding writes the access/secret pair into its own pg.yaml; the other hosts hold only the public key id.
  • pg addon password garage on a non-seeding host has no secret to print and says so — the secret lives in the pg.yaml of the host whose install completed quorum. Reveal it there, or run garage key create yourself 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-factor must be ≤ the number of nodes you will join; a cluster cannot seed until rf nodes hold a positive-capacity role.
  • Give each host a distinct --zone to survive whole-failure-domain loss; Garage keeps replica copies in different zones when it can.
  • --capacity is GiB and is what Garage carves out for blocks; a node with capacity 0 is treated as not-yet-data-bearing.
  • --advertise-host is required for every member — it becomes rpc_public_addr, the address peers dial. Without it a cluster member is unreachable. (A cluster member whose --listen is loopback still widens its RPC bind to 0.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 status reports as DataAvail. Setting --capacity 100 on 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 install line 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):

# on host 3
pg addon stop garage --name store
# on host 1 — still reads and writes
pg mc cat c1/store/v.txt

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 --force or a pgBackRest expire propagates 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 layout operation 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:

# on the seeding host only:
pg addon password garage --name store              # print the secret key
pg addon password garage --name store --file ./ak  # mode 0600, for shipping
pg addon list --show-password                      # access key + secret together
  • Any host can show the public id (pg addon list → Access key:); on non-seeding hosts pg addon password errors 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:

    pg garage --name store key create app-key
    pg garage --name store bucket allow store \
      --key GK<new-id> --read --write --owner

    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 garage is the general passthrough for Garage’s own control CLI — see Administering via pg 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 via pg 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:

# pick the URL for your platform — see "Endpoints by platform" below:
pg mc alias set store http://127.0.0.1:3900 GK<...> <secret>       # Linux, local addon
pg mc alias set store http://host.containers.internal:3900 GK<...> <secret>  # macOS, local addon
pg mc alias set store http://10.0.5.7:3900 GK<...> <secret>        # either, remote store
pg mc mb store/backups
pg mc ls store                       # the seeded "store" bucket is already here
pg mc cp ./dump.pglz store/backups/

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:

pg mc cp ./dir store/backups/ -- --recursive
pg mc rb store/scratch -- --force

Endpoints by platform: on Linux pg mc runs on the host network, so a 127.0.0.1 alias reaches a local addon instance directly. On macOS the container sits on the bridge network, where 127.0.0.1 is the container’s own loopback — point an alias at a local Mac addon via http://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’s https:// endpoint instead (and -- --insecure for 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 garage command — 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, so bucket 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 in bucket list on 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 the pg.yaml of the host that minted it, so pg addon password works 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

pg garage --name store health            # exit code 0 iff the cluster is healthy
pg garage --name store status            # per node: zone, capacity, DataAvail, version
pg garage --name store layout show       # committed roles + staged changes + layout version
pg garage --name store stats             # per-partition replication detail
curl -s http://127.0.0.1:3903/health     # the admin-port liveness line

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

pg garage --name store key list                    # who can access the store
pg garage --name store key create app-key          # secret printed once
pg garage --name store bucket allow store --key GK... --read --write
pg garage --name store bucket deny  store --key GK... --write
pg garage --name store bucket list
pg garage --name store bucket info store           # size, object count, per-key grants
pg garage --name store key delete GK...            # revoke an app key

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:

pg garage --name store bucket create backups        # global alias = "backups"
pg garage --name store bucket allow backups --key GK... --read --write --owner
pg garage --name store bucket list                  # shows the new "backups" alias
pg garage --name store bucket delete backups --yes  # --yes required; empty bucket only

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:

pg garage --name store layout history              # which version when, by which node
pg garage --name store layout show                 # any STAGED ROLE CHANGES pending?
pg garage --name store node connect <id@addr>      # (re-)peer a node

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

pg garage --name store bucket cleanup-incomplete-uploads   # abort stale multipart uploads
pg garage --name store key delete-expired                  # sweep expired keys
pg garage --name store bucket inspect-object store/key    # which nodes hold this object's blocks

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:

pg addon install garage --name store    # 3900 / 3901 / 3902 / 3903
pg addon install garage --name lake     # 3904 / 3905 / 3906 / 3907
pg addon install minio  --name archive  # 9000 / 9001  (shared pool)

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 garage CLI, via pg garage — the practical way to inspect and change state. pg garage runs Garage’s own control CLI inside the instance’s running container (it talks to the local RPC port), so you never podman exec by hand:

    pg garage --name store status         # nodes, zones, capacity, DataAvail
    pg garage --name store layout show    # committed layout + version
    pg garage --name store bucket list
    pg garage --name store key list

    Everything after --name is forwarded to /garage verbatim, 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, --name can be dropped; with several, omitting it targets the first by name (announced on stderr). This needs a running member — unlike pg mc/pg etcdctl, which spin up a throwaway client container, pg garage execs the node’s own CLI. See Administering via pg garage for 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 the Authorization header; a bare curl gets 403 AccessDenied. Only a few endpoints are open without a token:

    curl -s http://127.0.0.1:3903/health          # "Garage is fully operational"
    curl -s http://127.0.0.1:3903/metrics         # Prometheus exposition
    curl -s http://127.0.0.1:3903/v2/GetClusterHealth \
      -H "Authorization: Bearer <admin-token>"   # needs a token
  • s3_web on 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 returns 404.

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:

namespace: default
garage_start_port: 3900    # garage's own pool (API/RPC/web/admin, four each)
addons:
  garage:
    store:
      container_name: pgcli-garage-default-store
      name: store
      image_tag: docker.io/dxflrs/garage:v2.4.1
      # data_dir: /srv/garage      # omit for <base-dir>/addon/garage/store/data
      #                             # (meta dir and garage.toml live beside it)
      # drives:                   # multi-disk: replaces data_dir, one dir per disk
      #   - /mnt/d1/garage        # (mutually exclusive with data_dir; each drive
      #   - /mnt/d2/garage        #  is mounted at /var/lib/garage/data<N>)
      # config_file: /etc/garage.toml   # BYO config: mounted verbatim, never rewritten
      listen: 127.0.0.1
      api_port: 3900
      rpc_port: 3901
      web_port: 3902
      admin_port: 3903
      root_user: GK<generated>        # access key id, written on first install
      root_password: <generated>      # secret key, written on first install
      rpc_secret: <generated>         # cluster RPC signing key, written on first install
      region: garage                  # s3_region
      replication_factor: 1           # single-node must be 1; a cluster sets it >1
      autostart: false                # pg autostart enable --garage --name store
      # --- multi-node cluster keys (omit for single-node) ---
      # cluster: c1                   # non-empty ⇒ cluster mode; groups members
      # advertise_host: 10.0.0.11 # rpc_public_addr host; required in cluster
      # zone: z1                      # layout assign -z (failure domain)
      # capacity: 15                  # layout assign -c, GiB; 0 ⇒ no auto-assign
      # peers:                        # known id@addr, each `garage node connect`ed
      #   - 1c0597cc…200aeb@10.0.0.11:3905
      # node_id: 1c0597cc…200aeb@10.0.0.11:3905  # read back after first boot

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

pg addon list
Infra add-ons (garage):
  garage (name: store)
    Status:      running
    Cluster:     c1 (rf 3)
    Advertise:   10.0.0.11:3905
    Zone/Cap:    z1 / 15 GiB
    Node ID:     1c0597cc…200aeb@10.0.0.11:3905
    Nodes:       3 assigned (healthy)
    Listen:      127.0.0.1
    API port:    3904
    RPC/Web/Admin: 3905/3906/3907
    S3 URL:      http://127.0.0.1:3904
    Data:        ~/pg/addon/garage/store/data
    Metadata:    ~/pg/addon/garage/store/meta
    Access key:  GK<generated>
    Bucket:      store
    Image:       docker.io/dxflrs/garage:v2.4.1
    Container:   pgcli-garage-default-store
    NOTE:        plaintext HTTP only — front with the nginx addon for TLS

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

pg addon start garage --name store
pg addon stop  garage --name store

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:

pg autostart enable --garage --name store

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

pg addon remove garage --name store            # container gone, data kept
pg addon remove garage --name store --clean-data   # also delete every data dir + meta dir + config

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

pg logs addon garage --name store      # last 50 lines
pg logs addon garage --name store -f   # follow

Troubleshooting

  • pulling garage image ... : ... on install. The tag docker.io/dxflrs/garage:v2.4.1 is not reachable — check network access to Docker Hub, or pre-pull it with podman pull.
  • pg mc alias set fails with a connection error. The default --listen 127.0.0.1 binds the store loopback-only; on macOS, use host.containers.internal, not 127.0.0.1. Confirm the live API port with pg addon list.
  • Writes through the nginx front fail with Invalid signature. The front rewrote Host without the port; use proxy_set_header Host $http_host; (the conf-file recipe does) — the --upstream template mode hard-codes $host and does not fit S3.
  • PUTs through the nginx front fail at ~1 MiB. nginx defaults client_max_body_size to 1m; the conf-file recipe sets it to 0 (unlimited).
  • pgBackRest refuses the endpoint (HTTP / cert). pgBackRest forces HTTPS for S3 repos. Front Garage with the nginx addon (--tls) and point backup.repo.s3.ca_file at the front’s ca.crt.
  • replication_factor must be 1 on a single-node instance. A hand-edited replication_factor: 2 without a cluster is rejected at install — a lone node cannot meet a multi-node quorum. For multi-node, set cluster (or install with --cluster); see Multi-node cluster.

12.5 - PostgREST

Expose a PostgreSQL schema as a REST API via the PostgREST addon

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 under instances.<name>.addons.postgrest, DSN built from the instance.
  • Remote: pg addon install postgrest --dsn <dsn> --pg-name <name> — stored in top-level addons.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: --dsn must 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, or host.containers.internal for 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

  1. pg addon install postgrest pulls the image (if missing) and starts a container wired entirely from PGRST_* env vars.
  2. The container reaches the backend PG over the host network (Linux) or the pgcli-net bridge (macOS).
  3. PostgREST introspects the exposed schema on startup, serves REST requests, and listens on the pgrst channel 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

# Local mode: expose a managed instance's schema
pg addon install postgrest -i mypg --schema api --anon-role web_anon

# Remote mode: any PG endpoint (direct instance, pgbouncer, or haproxy LB)
pg addon install postgrest \
  --dsn "postgres://api:pass@127.0.0.1:5000/appdb" \
  --pg-name app-api --schema api --anon-role web_anon

# Tune the connection pool PostgREST keeps toward its backend
pg addon install postgrest -i mypg --db-pool 20

# Enable JWT auth (unauthenticated requests still fall back to --anon-role)
pg addon install postgrest -i mypg --schema api --anon-role web_anon --jwt-secret "$(openssl rand -hex 32)"

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-pool and the backend’s max_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, budget max_connections accordingly.

List

pg addon 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

pg addon start postgrest -i mypg
pg addon stop postgrest --pg-name app-api
pg addon remove postgrest -i mypg          # stateless: only the container goes away
pg addon remove postgrest --pg-name app-api
pg logs addon postgrest -i mypg            # local container logs
pg logs addon postgrest --pg-name app-api  # remote container logs

Autostart

pg autostart enable --postgrest -i mypg
pg autostart enable --postgrest --pg-name app-api
pg autostart status

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):

postgrest_start_port: 3500      # base of the HTTP port pool

addons:
  postgrest:
    app-api:
      container_name: pgcli-postgrest-default-app-api
      name: app-api
      image_tag: docker.io/postgrest/postgrest:v16.3
      host_port: 3501
      listen: 127.0.0.1
      dsn: postgres://api:pass@10.0.0.20:35432/appdb
      backend_host: 10.0.0.20:35432
      db_pool: 4
      schemas: api
      anon_role: web_anon
      jwt_secret: <HS256-shared-secret>   # only present when --jwt-secret was given
      autostart: false

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.

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):

# Recommended: writes always follow the leader through the LB's rw listener
pg addon install postgrest --dsn "postgres://api:pass@<lb-host>:5000/appdb" \
  --pg-name app-api --schema api --anon-role web_anon

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 failed 503 (SQLSTATE 57P01, connection terminated), and the very next retried request succeeded 201 against the new leader. Treat that single transient 503/57P01 during 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 /primary check 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-pool worth 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 exposed schema (--schema value; skip for public, which already exists):
CREATE SCHEMA IF NOT EXISTS api;

-- The NOINHERIT role unauthenticated requests run as (the --anon-role value).
DO $$ BEGIN
  IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'web_anon') THEN
    CREATE ROLE web_anon NOINHERIT NOLOGIN;
  END IF;
END $$;
GRANT USAGE ON SCHEMA api TO web_anon;
GRANT SELECT ON ALL TABLES IN SCHEMA api TO web_anon;
ALTER DEFAULT PRIVILEGES IN SCHEMA api GRANT SELECT ON TABLES TO web_anon;

-- SET ROLE requires the login user to be a member of the target role
-- (skip if the DSN user is a superuser):
GRANT web_anon TO <dsn-user>;

The role view’s column is rolname, not rolename — a typo fails the whole batch. ALTER DEFAULT PRIVILEGES covers only tables created after it runs; existing ones are handled by the GRANT ... ON ALL TABLES line.

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:

NOTIFY pgrst, 'reload schema';

LISTEN and transaction pooling. PostgREST relies on a persistent LISTEN pgrst session 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 is NOTIFY pgrst reaching 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 a NOTIFY sent 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:

CREATE TABLE api.widgets (id integer PRIMARY KEY, name text);
INSERT INTO api.widgets VALUES (1, 'bolt'), (2, 'nut');
GRANT SELECT ON api.widgets TO web_anon;
NOTIFY pgrst, 'reload schema';   -- otherwise the new table stays 404
# The OpenAPI root — answers as soon as PostgREST has connected to the backend.
curl -s http://127.0.0.1:3500/ | head -c 120

# Which tables/relations are exposed (from the OpenAPI paths):
curl -s http://127.0.0.1:3500/ | grep -o '"/[a-z_]*"'

# Read rows from the table. The schema is implicit in the path — it is
# /widgets, NOT /api.widgets:
curl -s "http://127.0.0.1:3500/widgets"
curl -s "http://127.0.0.1:3500/widgets?id=eq.1"   # a filter

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 503 until PostgREST has connected.
  • A 404 on a just-created table or a 401 on unauthenticated requests means the schema cache is stale or --anon-role is 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.

pg addon install postgrest -i mypg --schema api --anon-role web_anon \
  --jwt-secret "$(openssl rand -hex 32)"

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 --force after 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 role claim must name a database role with grants on the exposed schema (create it like web_anon above, NOINHERIT).
  • The login role in the DSN must be a member of that role, because serving the request means SET ROLE to it. So for a JWT role like web_user you also need GRANT 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 with permission denied to set role.
  • Change the claim key from role via PGRST_JWT_ROLE_CLAIM_KEY if 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:

b64url() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }
SECRET='<the --jwt-secret value>'
HEADER=$(printf '{"alg":"HS256","typ":"JWT"}' | b64url)
PAYLOAD=$(printf '{"role":"web_anon","exp":%d}' $(( $(date +%s) + 3600 )) | b64url)
SIG=$(printf '%s.%s' "$HEADER" "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" -binary | b64url)
curl -s -H "Authorization: Bearer $HEADER.$PAYLOAD.$SIG" http://127.0.0.1:3500/widgets

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.
  • 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 --dsn points at.

12.6 - SeaweedFS

Run SeaweedFS (the Go, Apache-2.0 S3-compatible object store) as a pgcli addon — single-node “weed mini” or a cross-host “weed server” cluster with filer metadata in a shared PostgreSQL, native S3 TLS, and one shared credential pair across the cluster

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:

  1. 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 runs weed server -filer -s3, one per host, all roles together — never four containers.
  2. 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 is weed command-line flags.
  3. 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 --dsn PostgreSQL. This is the natural home ground of pgcli: the store can sit next to (or in) a pgcli-managed instance.
  4. Native S3 TLS — --tls (self-signed) or --tls-cert/--tls-key (BYO, e.g. pg cert output) — 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 / --dsn only; 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_user is 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_password is the secret key (64 hex chars), generated or supplied via --root-password.
  • Under --cluster both 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

# default instance name "seaweedfs", four ports from the seaweedfs pool, loopback bind
pg addon install seaweedfs

# a named instance with an explicit data directory
pg addon install seaweedfs --name store --data-dir /srv/seaweedfs

# spread one node's volumes across several separately-mounted disks
pg addon install seaweedfs --name store --drive /mnt/sw-disk1 --drive /mnt/sw-disk2

# a fixed master port (volume/filer/s3 take the next three; gRPC twins +10000)
pg addon install seaweedfs --name store --port 8400

# expose the store on the network instead of loopback only
pg addon install seaweedfs --name store --listen 0.0.0.0

# supply your own access key / secret key (seaweedfs limits: key >= 8, secret >= 16 chars)
pg addon install seaweedfs --name store --root-user SW<...> --root-password <secret>

# native TLS (self-signed) — the S3 port becomes HTTPS-only
pg addon install seaweedfs --name store --tls

# join a cross-host cluster (see Multi-node cluster below) — credentials are MANDATORY here
pg addon install seaweedfs --name store --cluster c1 --advertise-host 10.0.0.11 \
  --replication 001 --root-user SW<...> --root-password <secret> \
  --dsn "postgres://admin:<pw>@10.0.0.11:35432/seaweed_store" \
  --peer 10.0.0.11:8400 --peer 10.0.0.12:8400 --peer 10.0.0.14:8400

The single-node output reports endpoints and the minted credentials:

  [OK] cluster leader: 127.0.0.1:8400.18400

✓ seaweedfs installed: "store"
  Container:     pgcli-seaweedfs-default-store
  Image:         docker.io/chrislusf/seaweedfs:4.48_large_disk
  Data:          /root/.pgcli/addon/seaweedfs/store/data
  S3 endpoint:   http://127.0.0.1:8403
  Ports:         master=8400 volume=8401 filer=8402 s3=8403 (gRPC twins +10000)
  Bucket:        store

  Access key:    SW<generated>
  Secret key:    <generated>
  Try it:        pg mc alias set store http://127.0.0.1:8403 SW<...> <secret> && pg mc ls store

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.1 keeps the store local; --listen 0.0.0.0 (or the listen key in pg.yaml) exposes it on the network. A cluster member always binds 0.0.0.0 regardless of --listen — peers must reach its gRPC twin (port + 10000) for Raft — while -ip still registers the single address peers dial.

-ip is 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 -ip explicitly (--listen value single-node, --advertise-host in 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:

# self-signed: pgcli generates cert+key under the instance dir (30-day leaf,
# SANs covering the bind/advertise address, 127.0.0.1 and localhost)
pg addon install seaweedfs --name store --tls

# bring your own: e.g. a `pg cert`-issued pair (a CA certificate is refused)
pg addon install seaweedfs --name store --tls-cert fullchain.pem --tls-key privkey.pem

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:

# each --drive must be a separately mounted disk; the drives fully replace
# --data-dir (combining the two is an error)
pg addon install seaweedfs --name store \
  --drive /mnt/sw-disk1 --drive /mnt/sw-disk2 --drive /mnt/sw-disk3

The summary and pg addon list then show the mapping instead of a single Data: line:

  Drive 1:       /mnt/sw-disk1 -> /data1
  Drive 2:       /mnt/sw-disk2 -> /data2
  Drive 3:       /mnt/sw-disk3 -> /data3

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 .vol files 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. install warns 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:

  1. 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-user and --root-password on 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.
  2. The shared filer metadata database. --dsn postgres://user:pass@host:port/db is required, must be dialable from every host — so use the host-published port / LAN address, never a container-local 127.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’s postgres2 store creates .system, filemeta and one table per bucket inside it on first use (verified). sslmode=disable is rendered into filer.toml, so reachability + pg_hba is 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):

pg exec -i demo "CREATE DATABASE seaweed_store"

Then, on each host, the same command with that host’s own --advertise-host (keys below are placeholders — use one pair everywhere):

# on 10.0.0.11 — the printed summary hands you the command for the others
pg addon install seaweedfs --name store --cluster c1 \
  --advertise-host 10.0.0.11 --replication 001 \
  --root-user SW<...> --root-password <secret> \
  --dsn "postgres://admin:<pw>@10.0.0.11:35432/seaweed_store" \
  --peer 10.0.0.11:8400 --peer 10.0.0.12:8400 --peer 10.0.0.14:8400

# on 10.0.0.12 — identical except --advertise-host
# on 10.0.0.14 — identical except --advertise-host

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 quorum and 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 and mb --ignore-existing stays a no-op.

After all three, any member shows the whole cluster:

$ pg weed --name store --shell "cluster.status"

cluster:
	id:       topo
	status:   unlocked
	nodes:    3
	topology: 1 DC, 3 disks on 1 rack
…

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):

# on host 3
pg addon stop seaweedfs --name store
# on host 1 — reads still succeed via host 2's gateway
pg mc cat store2/store/roundtrip.bin | sha256sum

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:

pg mc alias set store http://127.0.0.1:8403 SW<...> <secret>   # Linux, local
pg mc mb store/backups
pg mc cp ./dump.pglz store/backups/
pg mc ls store

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:

pg weed <args>...           # verbatim: /usr/bin/weed <args> in the container
pg weed --shell <line>...   # each line piped into `weed shell`, pre-wired to
                            # this instance's master and filer over loopback

The weed shell menu is the day-2 surface (command names verified against 4.48 — the shell help lists far more):

pg weed --shell "cluster.status"                     # topology, nodes, volumes
pg weed --shell "volume.list"                        # every volume, its replicas, sizes
pg weed --shell "s3.bucket.list"                     # buckets as seen by the filer
pg weed --shell "fs.ls /"                            # the filer namespace, root
pg weed --name store --shell "volume.list" "s3.bucket.list"   # several lines at once
pg weed flag.lookup 0,1,2                            # verbatim subcommand
pg weed shell                                        # interactive (needs a TTY)

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):

pg addon install seaweedfs --name store   # 8400-8403 + 18400-18403
pg addon install seaweedfs --name lake    # 8404-8407 + 18404-18407

--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:

namespace: default
seaweedfs_start_port: 8400    # seaweedfs's own pool (master/volume/filer/s3, four each, gRPC twins reserved)
addons:
  seaweedfs:
    store:
      container_name: pgcli-seaweedfs-default-store
      name: store
      image_tag: docker.io/chrislusf/seaweedfs:4.48_large_disk
      # data_dir: /srv/seaweedfs    # omit for <base-dir>/addon/seaweedfs/store/data
      # --- multi-disk (replaces data_dir; mutually exclusive with it) ---
      # drives:                      # one host dir per disk, mounted /data1../dataN,
      #   - /mnt/sw-disk1            #   and -dir lists them comma-joined
      #   - /mnt/sw-disk2
      listen: 127.0.0.1
      port: 8400                    # master anchor; volume/filer/s3 = next three
      volume_port: 8401
      filer_port: 8402
      s3_port: 8403
      root_user: SW<generated>         # s3.json access key
      root_password: <generated>       # s3.json secret key
      bucket: store                    # seeded on first boot (default = name)
      volume_size_mb: 1024             # -master.volumeSizeLimitMB (weed's own default 128 is tiny)
      autostart: false                 # pg autostart enable --seaweedfs --name store
      # --- multi-node cluster keys (omit for single-node) ---
      # cluster: c1                    # non-empty ⇒ cluster mode (weed server)
      # advertise_host: 10.0.0.11  # the -ip peers dial; required in cluster
      # replication: "001"             # placement code XYZ (X remote copies,
      #                                #   Y racks, Z data centers), immutable in-cluster
      # peers:                         # FULL Raft list, written on every node
      #   - 10.0.0.11:8400
      #   - 10.0.0.12:8400
      #   - 10.0.0.14:8400
      # filer_dsn: postgres://admin:<pw>@10.0.0.11:35432/seaweed_store  # required in cluster
      # --- TLS ---
      # tls: true                      # self-signed (or via --tls-cert/--tls-key BYO)
      # cert_file: …/cert.pem
      # key_file: …/key.pem

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

pg addon list
Infra add-ons (seaweedfs):
  seaweedfs (name: store)
    Status:      running
    Cluster:     c1 (replication 001, 3 peers configured)
    Peers:       10.0.0.11:8400,10.0.0.12:8400,10.0.0.14:8400
    Leader:      10.0.0.11:8400.18400
    Filer store: postgres 10.0.0.11:35432/seaweed_store
    Listen:      127.0.0.1
    Ports:       master=8400 volume=8401 filer=8402 s3=8403
    S3 URL:      http://127.0.0.1:8403
    Data:        /root/.pgcli/addon/seaweedfs/store/data
    Access key:  SW<generated>
    Bucket:      store
    Image:       docker.io/chrislusf/seaweedfs:4.48_large_disk
    Container:   pgcli-seaweedfs-default-store

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

pg addon start seaweedfs --name store
pg addon stop  seaweedfs --name store

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:

pg autostart enable --seaweedfs --name store

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

pg addon remove seaweedfs --name store               # container gone, data kept
pg addon remove seaweedfs --name store --clean-data  # also delete data dir + rendered files

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

  [i] The filer metadata database (10.0.0.11:35432/seaweed_store) was KEPT — pgcli never drops databases; drop it yourself if the data is disposable.

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

pg logs addon seaweedfs --name store      # last 50 lines
pg logs addon seaweedfs --name store -f   # follow

Limitations

What v1 deliberately does not do:

  • Cluster membership changes (add/remove/replacement nodes) are not orchestrated — --peer is 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 manual pg weed territory, and IAM in particular is the only way SeaweedFS itself shares credentials dynamically, out of scope here.
  • Credential rotation is a re-install with --force on every member; pgcli never edits a live s3.json in place.
  • One bucket is seeded (instance name); further buckets are plain pg mc mb away — they land in the same filer database automatically.
  • The filer.toml DSN renders sslmode=disable; if your PostgreSQL demands TLS on that leg, pgcli’s render does not fit it (BYO --dsn with TLS is not a v1 surface).

Troubleshooting

  • requires explicit shared credentials on a --cluster install. Expected by design — run the first node with an explicit --root-user/--root-password pair 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 --dsn cannot keep one namespace across gateways. Pre-create the database and pass a host-reachable URI (published port / LAN address — a container-local 127.0.0.1:5432 is 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-existing makes it a clean no-op if the bucket already landed.
  • Uploads 500 on a healthy-looking single node. Historically the missing -ip signature (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 bare mc mb against an existing bucket; pgcli’s own seed uses --ignore-existing, so see this only from manual mc use — add the flag.
  • Wrong-secret confusion: The request signature we calculated does not match. The pair does not match the node’s rendered s3.json — in a cluster, that means the nodes were installed with different credentials; re-run the install with the same pair + --force everywhere.
  • pulling seaweedfs image … fails on install. docker.io/chrislusf/seaweedfs:4.48_large_disk unreachable — check Docker Hub access or podman pull it first.

12.7 - RustFS

Run rustfs (the Rust S3-compatible object store) as a pgcli addon — single-node or erasure-coded, with the fixed container uid handled inside a purpose-built image

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:

  1. 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.
  2. Three topologies, no multi-node single-drive. rustfs speaks SNSD / SNMD / MNMD only — see Deployment modes.
  3. Its own TLS filenames. rustfs reads rustfs_cert.pem / rustfs_key.pem from RUSTFS_TLS_PATH, not MinIO’s public.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_user defaults to admin (rustfs imposes no minimum key length; anything non-empty works, and admin sidesteps the rustfsadmin warning);
  • root_password is generated on first install (or supplied via --root-password), stored in pg.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, then su-drops to the rustfs user and execs the unmodified upstream /entrypoint.sh. pgcli passes no --user flag 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), with 10001 only visible inside the container.

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

# default instance name "rustfs", ports from the shared pool, loopback bind
pg addon install rustfs

# a named instance with an explicit data directory
pg addon install rustfs --name store --data-dir /srv/rustfs

# fixed ports and a different root user
pg addon install rustfs --name store --api-port 9000 --console-port 9001 --root-user admin

# expose the store on the network instead of loopback only
pg addon install rustfs --name store --listen 0.0.0.0

# HTTPS via pgcli's self-signed CA (the prerequisite for a pgBackRest S3 repo)
pg addon install rustfs --name store --tls

# HTTPS with a certificate you already have (public-CA or private-CA cert)
pg addon install rustfs --name store --tls-cert /etc/ssl/rustfs.test.crt --tls-key /etc/ssl/rustfs.test.key

The output reports endpoints and the root credentials:

✓ rustfs installed: "store"
  Container:    pgcli-rustfs-default-store
  Image:        ghcr.io/mars-base/pgcli/pgcli-rustfs:1.0.0
  Data:         ~/pg/addon/rustfs/store/data
  S3 API:       http://127.0.0.1:9000
  Console:      http://127.0.0.1:9001

  Root user:     admin
  Root password: <generated>

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.1 keeps the store local. --listen 0.0.0.0 (or the listen key in pg.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:

pg backup fetch-ca <store-host>:9000
#   [OK] CA fetched from <store-host>:9000
#        saved:    ~/.pgcli/backup/repo-ca/ca-<store-host>-9000.crt
#        SHA-256:  c0f0…fe2e
pg backup setup --s3-ca-file ~/.pgcli/backup/repo-ca/ca-<store-host>-9000.crt

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.

pg addon install rustfs --name store \
  --tls-cert /etc/ssl/wildcard.example.com.crt \
  --tls-key  /etc/ssl/wildcard.example.com.key

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 --drive must sit on its own block device. If two drives share a device (st_dev), the rustfs process [FATAL]s at startup. pgcli launches detached, so pg addon install still exits 0 and the failure surfaces as a container that never becomes healthy — check pg logs addon rustfs --name store if 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:

pg addon install rustfs --name store \
  --drive /mnt/rustfs/disk1 --drive /mnt/rustfs/disk2 \
  --drive /mnt/rustfs/disk3 --drive /mnt/rustfs/disk4 --tls

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:

# on node 1 (10.0.0.11), four dedicated data disks:
pg addon install rustfs --name store \
  --listen 0.0.0.0 \
  --root-password '<shared-secret>' \
  --drive /mnt/rustfs/d1 --drive /mnt/rustfs/d2 --drive /mnt/rustfs/d3 --drive /mnt/rustfs/d4 \
  --endpoint http://10.0.0.11:9000 --endpoint http://10.0.0.12:9000 \
  --endpoint http://10.0.0.20:9000 --endpoint http://10.0.0.21:9000

# nodes 2-4: same command, own --drive, and the SAME --endpoint list
# AND the SAME --root-password value.

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:

pg mc alias set store http://127.0.0.1:9000 admin <password>    # Linux
pg mc mb store/backups
pg mc ls store
pg mc cp ./dump.pglz store/backups/

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):

pg addon install minio  --name store   # 9000 / 9001
pg addon install silo   --name lake    # 9002 / 9003
pg addon install rustfs --name archive # 9004 / 9005

Configuration

Instances live under the top-level addons.rustfs map in pg.yaml:

namespace: default
minio_start_port: 9000       # shared pool: minio, silo AND rustfs draw from this
addons:
  rustfs:
    store:
      container_name: pgcli-rustfs-default-store
      name: store
      image_tag: ghcr.io/mars-base/pgcli/pgcli-rustfs:1.0.0
      # data_dir: /srv/rustfs    # omit for <base-dir>/addon/rustfs/store/data
      # drives:                  # multi-drive (SNMD/MNMD): one host dir per
      #   - /mnt/rustfs/d1       # drive, mounted at /data/rustfs0../rustfsN
      #   - /mnt/rustfs/d2
      listen: 127.0.0.1
      api_port: 9000
      console_port: 9001
      root_user: admin
      root_password: <generated>   # written on first install
      autostart: false             # pg autostart enable --rustfs --name store
      # tls: true                  # serve HTTPS (self-signed CA, or BYO below)
      # cert_file: /etc/ssl/rustfs.test.crt   # BYO leaf(+chain), implies tls
      # key_file:  /etc/ssl/rustfs.test.key   # BYO private key, must pair with cert_file
      # endpoints:                 # omit for single-node; one per node for MNMD:
      #   - http://10.0.0.11:9000
      #   - http://10.0.0.12:9000

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
Infra add-ons (rustfs):
  rustfs (name: store)
    Status:      running
    Listen:      127.0.0.1
    API port:    9000
    Console port: 9001
    Console URL: http://127.0.0.1:9001/
    Data:        ~/pg/addon/rustfs/store/data
    Root user:   admin
    Image:       ghcr.io/mars-base/pgcli/pgcli-rustfs:1.0.0
    Container:   pgcli-rustfs-default-store

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

pg addon start rustfs --name store
pg addon stop  rustfs --name store

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:

pg autostart enable --rustfs --name store

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

pg addon remove rustfs --name store            # container gone, data kept
pg addon remove rustfs --name store --clean-data   # also delete the data directory

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

pg logs addon rustfs --name store      # last 50 lines
pg logs addon rustfs --name store -f   # follow

Troubleshooting

  • pulling rustfs image ... : ... on install. The wrapper tag ghcr.io/mars-base/pgcli/pgcli-rustfs:1.0.0 is not reachable — check network access to ghcr.io, or pre-pull it with podman pull.
  • A multi-drive install won’t become healthy. rustfs [FATAL]s when two drives share a physical device. Check pg logs addon rustfs --name store for the disk check firing, and make sure each --drive is 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. chown the mount point to the user who runs pg.
  • 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-data leaves a directory behind. Under rootless podman a subordinate-uid tree is removed via the podman unshare rm fallback; if that path is unavailable, remove it yourself with podman unshare rm -rf <dir>.

12.8 - HAProxy

Run HAProxy as a pgcli addon — a TCP load balancer in front of a Patroni cluster (unified or read/write split), or your own haproxy.cfg via –conf-file for arbitrary native proxying

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 machine does not expose to containers. On macOS NewHAProxyManager fails fast with a clear message; pg addon list still 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 httpchk health 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 uses on-marked-down shutdown-sessions to drop stale connections so clients reconnect to the new leader.
  • split — read/write separation: a _<name>_rw listener routes writes to the leader, and a separate _<name>_ro listener 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/--ha plus --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 full haproxy.cfg and 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 with haproxy -c before (re)starting the container, and never rewrites or deletes your original. After an edit, pg addon haproxy reload re-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, --listen and --node/--ha have 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 that pg ha manages, 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.

# unified: one port, everything to the leader, backends auto-derived from scope "app"
pg addon install haproxy --name lb --ha app

# split: rw + ro listeners, reads capped at 1MB replica lag
pg addon install haproxy --name lb --mode split --ha app --max-lag 1MB

# explicit backends (e.g. members on other hosts, or a scope pgcli doesn't manage)
pg addon install haproxy --name lb --mode split \
  --node node1=10.0.0.11:35532:8008 \
  --node node2=10.0.0.11:35533:8009 \
  --node node3=10.0.0.11:35534:8010

The output reports the assigned ports and a ready-to-use connect URL:

✓ haproxy installed: "lb"
  Container:    pgcli-haproxy-default-lb
  Image:        docker.io/library/haproxy:3.2.23-alpine
  Mode:         split
  Patroni:      app
  Backends:     3
  Read-write:   127.0.0.1:5000
  Read-only:    127.0.0.1:5001 (lag ≤ 1MB)
  Stats:        http://127.0.0.1:5002/
  Config:       ~/pg/addon/haproxy/lb/haproxy.cfg

  Connect via HAProxy:
    postgres://<user>@127.0.0.1:5000/<database>

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:

pg ha create app --member node3 --etcd m1
pg addon install haproxy --name lb --mode split --ha app --max-lag 1MB
# Backends: 2 -> 3, and haproxy.cfg gains: server node3 127.0.0.1:35534 ... check port 8011

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:

pg ha remove app --member node3
pg addon install haproxy --name lb --mode split --ha app --max-lag 1MB
# Backends: 3 -> 2, and the server node3 line is gone

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:

pg addon install haproxy --name lb --mode split \
  --node node1=10.0.0.11:35532:8008 \
  --node node2=10.0.0.12:35532:8008 \
  --node node3=10.0.0.13:35532:8008
# later: add one more --node spec (or drop one) and re-run — the list replaces wholesale

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:

pg addon install haproxy --name custom --conf-file /root/haproxy/lb.cfg

--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:

global
    maxconn 1000
    log stdout format raw local0 info

defaults
    log global
    mode tcp
    timeout client 30m
    timeout connect 4s
    timeout server 30m

# plain TCP relay to a PostgreSQL instance (pgcli derives nothing here)
frontend pg_relay
    bind 10.0.0.11:7000
    default_backend pg

backend pg
    server demo 127.0.0.1:35432 check

# an HTTP-mode listener for something unrelated
listen webstats
    mode http
    bind 127.0.0.1:7001
    http-request return status 200 content-type text/plain lf-string "haproxy custom proxy alive\n"

The summary then reports the custom mode instead of ports (there are none to report — they live in your file):

✓ haproxy installed: "custom"
  Container:    pgcli-haproxy-default-custom
  Image:        docker.io/library/haproxy:3.2.23-alpine
  Mode:         custom (user config — all listeners come from the file)
  Config:       /root/.pgcli/addon/haproxy/custom/haproxy.cfg (from /root/haproxy/lb.cfg)

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.cfg and bind-mounts that copy read-only. Your original is never rewritten, and pg addon remove deletes only pgcli’s copy — your file stays where you put it.
  • Edits apply via reload (seamless) or re-install (lossy). Change the file, then either pg addon haproxy reload to 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 with haproxy -c. Editing the rendered-mode knobs does nothing here — --mode, --rw-port/--ro-port/--stats-port, --max-lag and --listen describe 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 or pg addon start cannot 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 socket if you collide.
  • The container runs with host networking, like the rendered mode. A frontend bound to 127.0.0.1 is reachable from the host itself; to let other containers/hosts in, bind the host’s address (as pg_relay above 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 whatever stats section you wrote — pgcli does not add one.
  • Switching back is just an install without --conf-file: pass --node/--ha again 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):

global
    maxconn 4096
    log stdout format raw local0 info

defaults
    log global
    mode http
    retries 2
    timeout client 30m
    timeout connect 4s
    timeout server 30m
    timeout http-request 30s

frontend http_in
    bind 127.0.0.1:8180
    default_backend http_pool

backend http_pool
    # round-robin across the app servers; health-checked via GET /
    option httpchk GET /
    http-check expect status 200
    default-server inter 3s fall 3 rise 2
    server app1 127.0.0.1:8081 check
    server app2 127.0.0.1:8181 check
    # add more: server app3 127.0.0.1:<port> check

What each piece does, and what was observed live:

  • Multiple server lines — HAProxy round-robins new connections across the UP servers (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 HTTP GET / and only 200 counts as UP. Match the method and the expected code to what the app actually answers: a server that returns 405 to HEAD but 200 to GET needs the GET form, not the HEAD / default.
  • default-server inter 3s fall 3 rise 2 — probe every 3s, mark DOWN after 3 consecutive failures, return to service after 2 successes.
  • check on 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:

Server http_pool/app2 is DOWN, reason: Layer4 connection problem, info: "Connection refused", check duration: 0ms. 1 active and 0 backup servers left. ...

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 -c accepts nbthread 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 nbthread to pin a specific count.
  • cpu-map auto:1-N 0-M — optionally bind thread 1…N to CPU cores 0…M (validated).
  • nbproc no longer exists. Since HAProxy 2.5 it is refused outright (haproxy -c fails with “nbproc is not supported any more since HAProxy 2.5”). Use threads, not processes.
global
    nbthread 4
    cpu-map auto:1-4 0-3
    maxconn 4096
    log stdout format raw local0 info

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:

pg addon haproxy reload --name custom

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:

  1. re-copies your --conf-file to the mounted haproxy.cfg (verbatim);
  2. runs the copy through haproxy -c (check-only) in a throwaway container;
  3. 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-file instances only. A rendered instance rebuilds its config from --node/--ha on every install and takes no hand-edits, so its update path stays pg addon install haproxy. Running reload on it is refused with that advice.
  • The container must be running. reload signals the live master; if the instance is stopped, start it (pg addon start haproxy --name custom) — a fresh install also picks up the edited file.
  • reload is 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 the server list / 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:

pg addon install haproxy --name custom --conf-file /root/haproxy/lb.cfg --reload

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 —
# Write (always hits the current leader)
psql "host=127.0.0.1 port=5000 user=postgres dbname=postgres"

# Read (load-balanced across replicas)
psql "host=127.0.0.1 port=5001 user=postgres dbname=postgres"

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:

pg addon install haproxy --name lb --ha app \
  --rw-port 6000 --ro-port 6001 --stats-port 6002

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):

pg addon install haproxy --name lb1 --mode split --ha app     # 5000 / 5001 / 5002
pg addon install haproxy --name lb2 --ha report              # 5003 / 5004

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:

namespace: default
haproxy_start_port: 5000
addons:
  haproxy:
    lb:
      mode: split            # unified | split
      ha_scope: app          # scope the backends came from (for --ha re-derivation)
      listen: 127.0.0.1
      write_port: 5000
      read_port: 5001
      stats_port: 5002
      replica_max_lag: 1MB   # split mode only
      autostart: false       # pg autostart enable --haproxy --name lb
      image_tag: docker.io/library/haproxy:3.2.23-alpine
      # conf_file: /root/haproxy/lb.cfg   # custom mode (see above; skips targets/mode/ports)
      targets:
        - { name: node1, host: 127.0.0.1, pg_port: 35532, rest_port: 8008 }
        - { name: node2, host: 127.0.0.1, pg_port: 35533, rest_port: 8009 }

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

pg addon list
Infra add-ons (haproxy):
  haproxy (name: lb)
    Status:      running
    Mode:        split
    Patroni:     app
    Read-write:  127.0.0.1:5000
    Read-only:   127.0.0.1:5001
    Stats:       http://127.0.0.1:5002/
    Backends:    3
      - node1        127.0.0.1:35532 (check :8009)
      - node2        127.0.0.1:35533 (check :8010)
      - node3        127.0.0.1:35534 (check :8011)

A conf-file instance shows its source instead of ports and backends:

  haproxy (name: custom)
    Status:      running
    Mode:        custom (user config)
    Config:      /root/haproxy/lb.cfg

Start and stop

After a host reboot, bring an instance back without re-rendering config:

pg addon start haproxy --name lb
pg addon stop  haproxy --name lb

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:

pg autostart enable --haproxy --name lb

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

pg addon remove haproxy --name lb

Stops and removes the container and deletes its config directory.

Logs

pg logs addon haproxy --name lb       # last 50 lines
pg logs addon haproxy --name lb -f    # follow

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; /replica is the mirror image. A 000 means the REST API is down or the port is wrong.
  • --ha finds no members. --ha only derives local members that pg ha manages in pg.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_port and up) or the auto-assigner will treat it as taken.
  • A conf-file install fails haproxy -c but 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), check pg logs <name>.
  • reload says “container is not running”. Reload signals the live master, so the instance must be up first — pg addon start haproxy --name <name> (or a fresh pg addon install, which also picks up the edited file).
  • reload rejects the file with haproxy -c errors. 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

Run silo (Pigsty’s MinIO fork) as a pgcli addon — single-node or distributed S3-compatible object storage with web console

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-net bridge with its two ports published, so the Mac reaches the API and console on 127.0.0.1:<port> like any other addon. The public image is dual-arch (amd64 + arm64). Both clients — pg mcli (silo’s own) and pg 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_user defaults to admin;
  • root_password is generated on first install (or supplied explicitly via --root-password) and stored in pg.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

# default instance name "silo", ports from the shared pool (base 9000), loopback bind
pg addon install silo

# a named instance with an explicit data directory
pg addon install silo --name store --data-dir /srv/silo

# fixed ports and a different root user
pg addon install silo --name store --api-port 9000 --console-port 9001 --root-user admin

# expose the store on the network instead of loopback only
pg addon install silo --name store --listen 0.0.0.0

# HTTPS via pgcli's self-signed CA (the prerequisite for a pgBackRest S3 repo)
pg addon install silo --name store --tls

# HTTPS with a certificate you already have (public-CA or private-CA domain cert)
pg addon install silo --name store --tls-cert /etc/ssl/silo.test.crt --tls-key /etc/ssl/silo.test.key

The output reports endpoints and the root credentials:

✓ silo installed: "store"
  Container:    pgcli-silo-default-store
  Image:        docker.io/pgsty/silo:RELEASE.2026-09-16T00-00-00Z
  Data:         ~/pg/addon/silo/store/data
  S3 API:       http://127.0.0.1:9000
  Console:      http://127.0.0.1:9001

  Root user:     admin
  Root password: <generated>

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.1 keeps the store local. --listen 0.0.0.0 (or the listen key in pg.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:

pg backup fetch-ca <store-host>:9002
#   [OK] CA fetched from <store-host>:9002
#        saved:    ~/.pgcli/backup/repo-ca/ca-<store-host>-9002.crt
#        SHA-256:  c0f0…fe2e
pg backup setup --s3-ca-file ~/.pgcli/backup/repo-ca/ca-<store-host>-9002.crt

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.

pg addon install silo --name store \
  --tls-cert /etc/ssl/wildcard.example.com.crt \
  --tls-key  /etc/ssl/wildcard.example.com.key

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:

pg cert --host "silo.test,127.0.0.1,10.0.0.9" \
  --cert-file silo.crt --key-file silo.key
pg addon install silo --name store --tls-cert silo.crt --tls-key silo.key

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:

pg addon install silo --name store \
  --drive /mnt/minio/disk1 --drive /mnt/minio/disk2 \
  --drive /mnt/minio/disk3 --drive /mnt/minio/disk4 --tls

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.

# on node 1 (10.0.0.11), with a dedicated data disk mounted at /data:
pg addon install silo --name store \
  --listen 10.0.0.11 \
  --data-dir /data \
  --root-password '<shared-secret>' \
  --endpoint http://10.0.0.11:9000/data \
  --endpoint http://10.0.0.12:9000/data \
  --endpoint http://10.0.0.20:9000/data \
  --endpoint http://10.0.0.21:9000/data

# nodes 2-4: same command, own --listen, and the SAME --endpoint list AND the
# SAME --root-password value.

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:

# pick the URL for your platform — see "Endpoints by platform" in the mc page:
pg mcli alias set store http://127.0.0.1:9000 admin <password>    # Linux, local addon
pg mcli alias set store http://host.containers.internal:9000 admin <password>  # macOS, local addon
pg mcli mb store/backups
pg mcli ls store
pg mcli cp ./dump.pglz store/backups/

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 --:

pg mcli ls store -- --all
MC_HOST_store="http://admin:<password>@127.0.0.1:9000" pg mcli ls store

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:

pg addon install minio --name store    # 9000 / 9001
pg addon install silo  --name lake     # 9002 / 9003

Configuration

Instances live under the top-level addons.silo map in pg.yaml, keyed by instance name:

namespace: default
minio_start_port: 9000       # shared pool: minio AND silo draw from this
addons:
  silo:
    store:
      container_name: pgcli-silo-default-store
      name: store
      image_tag: docker.io/pgsty/silo:RELEASE.2026-09-16T00-00-00Z
      # data_dir: /srv/silo     # omit for <base-dir>/addon/silo/store/data
      # drives:                  # multi-drive (SNMD/MNMD): one host dir per drive,
      #   - /mnt/minio/disk1     # each mounted at its own /dataN — see SNMD/MNMD below
      #   - /mnt/minio/disk2
      listen: 127.0.0.1
      api_port: 9000
      console_port: 9001
      root_user: admin
      root_password: <generated>   # written on first install
      autostart: false             # pg autostart enable --silo --name store
      # tls: true                  # serve HTTPS (self-signed CA, or BYO below)
      # cert_file: /etc/ssl/silo.test.crt   # BYO leaf(+chain), implies tls
      # key_file:  /etc/ssl/silo.test.key   # BYO private key, must pair with cert_file
      # endpoints:                 # omit for single-node; see "Distributed / Cluster Mode"
      #   - http://10.0.0.11:9000/data
      #   - http://10.0.0.12:9000/data
      #   - http://10.0.0.20:9000/data
      #   - http://10.0.0.21:9000/data
      #   (with `drives` also set this is MNMD — one /dataN endpoint per drive on
      #    every node, and every node's list identical; see "Multi-Node Multi-Drive")

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
Infra add-ons (silo):
  silo (name: store)
    Status:      running
    Listen:      127.0.0.1
    API port:    9000
    Console port: 9001
    Console URL: http://127.0.0.1:9001/
    Data:        ~/pg/addon/silo/store/data
    Root user:   admin
    Image:       docker.io/pgsty/silo:RELEASE.2026-09-16T00-00-00Z
    Container:   pgcli-silo-default-store

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 silo --name store

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:

pg addon start silo --name store
pg addon stop  silo --name store

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:

pg autostart enable --silo --name store

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

pg addon remove silo --name store            # container gone, data kept
pg addon remove silo --name store --clean-data   # also delete the data directory

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

pg logs addon silo --name store      # last 50 lines
pg logs addon silo --name store -f   # follow

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 tag docker.io/pgsty/silo:RELEASE.2026-09-16T00-00-00Z is not reachable — check network/registry access to docker.io, or pre-pull it with podman pull.
  • Port collision on manual --api-port. Pick ports outside the auto pool (minio_start_port and 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 start after host reboot does nothing / fails. Check pg logs addon silo --name store -f — most often the data directory was deleted (with --clean-data or 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_URL is built from listen + API port; if you serve on 127.0.0.1 but access from another host, clients get redirected to the loopback URL. Set listen to the address clients can actually reach. (Cluster mode does not set MINIO_SERVER_URL at all.)
  • An alias set with pg mc isn’t visible to pg mcli (or vice versa). They share ~/.mc/config.json by design, so this means the alias really isn’t there — check pg mc alias list (same file), or pass MC_HOST_<name> in the environment. A native mcli reads ~/.mcli instead and won’t see the shared file unless MC_CONFIG_DIR points there.
  • macOS. Supported: the addon serves on the pgcli-net bridge with both ports published, so the Mac’s 127.0.0.1:<port> reaches them (the container binds 0.0.0.0 internally and MINIO_SERVER_URL advertises the loopback the Mac uses). After changing ports or credentials, --force recreate the container. For pg mcli, the container cannot use a 127.0.0.1 alias — see the endpoint note on the MinIO mc page.

12.10 - MinIO

Run MinIO as a pgcli addon — single-node or distributed S3-compatible object storage with web console

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-net bridge with its two ports published, so the Mac reaches the API and console on 127.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_user defaults to admin;
  • root_password is generated on first install (or supplied explicitly via --root-password) and stored in pg.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

# default instance name "minio", ports from the pool (base 9000), loopback bind
pg addon install minio

# a named instance with an explicit data directory
pg addon install minio --name store --data-dir /srv/minio

# fixed ports and a different root user
pg addon install minio --name store --api-port 9000 --console-port 9001 --root-user admin

# expose the store on the network instead of loopback only
pg addon install minio --name store --listen 0.0.0.0

# HTTPS via pgcli's self-signed CA (the prerequisite for a pgBackRest S3 repo)
pg addon install minio --name store --tls

# HTTPS with a certificate you already have (public-CA or private-CA domain cert)
pg addon install minio --name store --tls-cert /etc/ssl/minio.test.crt --tls-key /etc/ssl/minio.test.key

The output reports endpoints and the root credentials:

✓ minio installed: "store"
  Container:    pgcli-minio-default-store
  Image:        ghcr.io/mars-base/pgcli/pgcli-minio:20250422221226
  Data:         ~/pg/addon/minio/store/data
  S3 API:       http://127.0.0.1:9000
  Console:      http://127.0.0.1:9001

  Root user:     admin
  Root password: <generated>

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.1 keeps the store local. --listen 0.0.0.0 (or the listen key in pg.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:

pg backup fetch-ca <store-host>:9002
#   [OK] CA fetched from <store-host>:9002
#        saved:    ~/.pgcli/backup/repo-ca/ca-<store-host>-9002.crt
#        SHA-256:  c0f0…fe2e
pg backup setup --s3-ca-file ~/.pgcli/backup/repo-ca/ca-<store-host>-9002.crt

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.

pg addon install minio --name store \
  --tls-cert /etc/ssl/wildcard.hi.163.com.crt \
  --tls-key  /etc/ssl/wildcard.hi.163.com.key

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:

pg addon install minio --name store --tls-cert <new.crt> --tls-key <new.key> --force

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:

pg backup fetch-ca <store-host>:9010
#   [OK] CA fetched from <store-host>:9010
#        saved:    ~/.pgcli/backup/repo-ca/ca-<store-host>-9010.crt
#        SHA-256:  d4df…81e2
pg backup setup --s3-ca-file ~/.pgcli/backup/repo-ca/ca-<store-host>-9010.crt

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:

# A leaf cert valid for a hostname AND two IPs, ECDSA P-256 (the default),
# 825 days (the default validity) — the shape most BYO installs want:
pg cert --host "minio.test,127.0.0.1,10.0.0.9" \
  --cert-file minio.crt --key-file minio.key

# Then serve it:
pg addon install minio --name store --tls-cert minio.crt --tls-key minio.key

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:

pg addon install minio --name store \
  --drive /mnt/minio/disk1 --drive /mnt/minio/disk2 \
  --drive /mnt/minio/disk3 --drive /mnt/minio/disk4 --tls

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.

# on node 1 (10.0.0.11), with a dedicated data disk mounted at /data:
pg addon install minio --name store \
  --listen 10.0.0.11 \
  --data-dir /data \
  --root-password '<shared-secret>' \
  --endpoint http://10.0.0.11:9000/data \
  --endpoint http://10.0.0.12:9000/data \
  --endpoint http://10.0.0.20:9000/data \
  --endpoint http://10.0.0.21:9000/data

# nodes 2-4: same command, own --listen, and the SAME --endpoint list AND the
# SAME --root-password value.

--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:

✓ minio installed: "store"
  ...
  Distributed mode: 4 endpoints
    - http://10.0.0.11:9000/data
    - http://10.0.0.12:9000/data
    - http://10.0.0.20:9000/data
    - http://10.0.0.21:9000/data
  NOTE: every node's pg.yaml must carry the identical endpoint list AND
  identical root credentials, or the cluster will not form.

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); the 127.0.0.0/8 loopback 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 a stat of the data dir versus / and warns at install time when --data-dir is on the root device — the warning is advisory; point --data-dir at a mounted data disk.
  • MINIO_SERVER_URL is not set in cluster mode. It would differ per node (each advertises its own IP), and MinIO requires every MINIO_* env var to be byte-identical across nodes, so a per-node MINIO_SERVER_URL makes each node loop on Waiting for at least 1 remote servers with valid configuration. The endpoint list defines the addresses instead. Single-node still sets MINIO_SERVER_URL from listen.

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.yaml files 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.)

# once, anywhere — one leaf for the whole grid (list every node address; add any
# VIP or hostname clients will dial):
pg cert --host 10.0.0.11,10.0.0.12,10.0.0.20,10.0.0.21 \
        --cert-file grid.crt --key-file grid.key
# then copy grid.crt + grid.key to every node — identical files everywhere

# on node 1 (10.0.0.11), four data disks mounted and passed with --drive:
pg addon install minio --name store \
  --tls-cert grid.crt --tls-key grid.key \
  --listen 0.0.0.0 \
  --drive /mnt/minio/disk1 --drive /mnt/minio/disk2 \
  --drive /mnt/minio/disk3 --drive /mnt/minio/disk4 \
  --root-password '<shared-secret>' \
  --endpoint https://10.0.0.11:9000/data1 --endpoint https://10.0.0.11:9000/data2 \
  --endpoint https://10.0.0.11:9000/data3 --endpoint https://10.0.0.11:9000/data4 \
  --endpoint https://10.0.0.12:9000/data1 --endpoint https://10.0.0.12:9000/data2 \
  --endpoint https://10.0.0.12:9000/data3 --endpoint https://10.0.0.12:9000/data4 \
  --endpoint https://10.0.0.20:9000/data1 --endpoint https://10.0.0.20:9000/data2 \
  --endpoint https://10.0.0.20:9000/data3 --endpoint https://10.0.0.20:9000/data4 \
  --endpoint https://10.0.0.21:9000/data1 --endpoint https://10.0.0.21:9000/data2 \
  --endpoint https://10.0.0.21:9000/data3 --endpoint https://10.0.0.21:9000/data4

# nodes 2-4: same command, the SAME grid.crt/grid.key pair, own --drive paths,
# and the SAME 16-endpoint matrix AND the SAME --root-password value.

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-data deletes 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:

# pick the URL for your platform — see "Endpoints by platform" below:
pg mc alias set store http://127.0.0.1:9000 admin <password>    # Linux, local addon
pg mc alias set store http://host.containers.internal:9000 admin <password>  # macOS, local addon
pg mc alias set store http://10.0.5.7:9000 admin <password>     # either, remote store
pg mc mb store/backups
pg mc ls store
pg mc cp ./dump.pglz store/backups/

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

pg mc cp ./dump.pglz store/backups/        # upload a file from the cwd
pg mc cp store/backups/dump.pglz ./        # download into the cwd
pg mc mirror ./repo store/backups/         # or mirror a whole tree

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 --:

pg mc ls store -- --all
pg mc cp ./dir store/backups/ -- --recursive
pg mc rm store/old -- --recursive --force

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:

MC_HOST_store="http://admin:<password>@127.0.0.1:9000" pg mc ls store

Endpoints by platform: on Linux pg mc runs on the host network, so a 127.0.0.1 alias reaches a local addon instance directly. On macOS the container sits on the bridge network, where 127.0.0.1 is the container’s own loopback — point an alias at a local Mac addon via http://admin:<password>@host.containers.internal:9000, or at a remote store via its routable address. pg mc itself 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 the pg mc container 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:

pg addon install minio --name store    # 9000 / 9001
pg addon install minio --name archive  # 9002 / 9003

Configuration

Instances live under the top-level addons.minio map in pg.yaml, keyed by instance name:

namespace: default
minio_start_port: 9000
addons:
  minio:
    store:
      container_name: pgcli-minio-default-store
      name: store
      image_tag: ghcr.io/mars-base/pgcli/pgcli-minio:20250422221226
      # data_dir: /srv/minio     # omit for <base-dir>/addon/minio/store/data
      # drives:                  # multi-drive (SNMD/MNMD): one host dir per drive,
      #   - /mnt/minio/disk1     # each mounted at its own /dataN — see SNMD/MNMD below
      #   - /mnt/minio/disk2
      listen: 127.0.0.1
      api_port: 9000
      console_port: 9001
      root_user: admin
      root_password: <generated>   # written on first install
      autostart: false             # pg autostart enable --minio --name store
      # tls: true                  # serve HTTPS (self-signed CA, or BYO below)
      # cert_file: /etc/ssl/minio.test.crt   # BYO leaf(+chain), implies tls; see "Bring your own certificate"
      # key_file:  /etc/ssl/minio.test.key   # BYO private key, must pair with cert_file
      # endpoints:                 # omit for single-node; see "Distributed / Cluster Mode"
      #   - http://10.0.0.11:9000/data
      #   - http://10.0.0.12:9000/data
      #   - http://10.0.0.20:9000/data
      #   - http://10.0.0.21:9000/data
      #   (with `drives` also set this is MNMD — one /dataN endpoint per drive on
      #    every node, and every node's list identical; see "Multi-Node Multi-Drive")

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
Infra add-ons (minio):
  minio (name: store)
    Status:      running
    Listen:      127.0.0.1
    API port:    9000
    Console port: 9001
    Console URL: http://127.0.0.1:9001/
    Data:        ~/pg/addon/minio/store/data
    Root user:   admin
    Image:       ghcr.io/mars-base/pgcli/pgcli-minio:20250422221226
    Container:   pgcli-minio-default-store

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 minio --name store
mc alias set local http://127.0.0.1:9000 admin "$(pg addon password minio --name store)"

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:

pg addon start minio --name store
pg addon stop  minio --name store

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:

pg autostart enable --minio --name store

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

pg addon remove minio --name store            # container gone, data kept
pg addon remove minio --name store --clean-data   # also delete the data directory

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

pg logs addon minio --name store      # last 50 lines
pg logs addon minio --name store -f   # follow

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 tag ghcr.io/mars-base/pgcli/pgcli-minio:20250422221226 is not reachable — check network/registry access, or pre-pull it with podman pull.
  • Port collision on manual --api-port. Pick ports outside the auto pool (minio_start_port and up) or the auto-assigner will treat them as taken; also remember MinIO needs a consecutive pair.
  • pg addon start after host reboot does nothing / fails. Check pg logs addon minio --name store -f — most often the data directory was deleted (with --clean-data or 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_URL is built from listen + API port; if you serve on 127.0.0.1 but access from another host, clients get redirected to the loopback URL. Set listen to the address clients can actually reach. (Cluster mode does not set MINIO_SERVER_URL at all — see Distributed / Cluster Mode.)
  • Cluster stuck on Waiting for at least 1 remote servers with valid configuration. The nodes disagree. Check that every pg.yaml has the byte-identical endpoints list and root_password (podman inspect pgcli-minio-<ns>-<name> --format '{{json .Config.Env}}' on each node), and that no per-node MINIO_SERVER_URL crept back in.
  • macOS. Supported: the addon serves on the pgcli-net bridge with both ports published, so the Mac’s 127.0.0.1:<port> reaches them (the container binds 0.0.0.0 internally and MINIO_SERVER_URL advertises the loopback the Mac uses). After changing ports or credentials, --force recreate the container. For pg mc, the container cannot use a 127.0.0.1 alias — see the endpoint note above.

12.11 - Redis

Run Redis as a pgcli addon — a standalone key-value store for cache, session, ranking and counter data, with selectable major versions 7 and 8

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. requirepass is generated on the first install and stored in pg.yaml — an unauthenticated Redis is not a reachable state.
  • Version selection. It is the first version-selectable addon: --version 7|8 resolves through a built-in table to a pinned docker.io/library/redis tag (7 → 7.4.11, 8 → 8.10.2, the default). --image still 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. --aof adds the write-ahead log on top for crash-safe, no-eviction stores, and --save no turns 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 — --cluster turns instances into cluster-enabled members that you assemble once yourself with pg redis-cli -- --cluster create (masters-only by default; add per-master followers with --cluster-replicas N; see Native cluster). Read replicas are too — --replica-of turns 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:

  • requirepass is always on — an unauthenticated Redis is not a reachable state, even if --password is left empty by hand in pg.yaml.
  • Persistence defaults to RDB snapshots — Redis’s own save schedule, plus a final snapshot on shutdown (--stop-timeout 30 gives Redis the time to write it) and a replay from dump.rdb at start.
  • --aof adds the write-ahead log (--appendonly yes) alongside the snapshot — the durable, no-eviction shape. --appendfsync tunes its flush: always (durability) / everysec (the default) / no (throughput). The AOF lives in an appendonlydir/ under the data dir and is replayed before the snapshot, so it is the source of truth when both are on.
  • --save no disables snapshots — pair it with --aof for an AOF-only store (the summary then reads aof only); on its own it means a purely in-memory instance that loses its dataset on restart (the install warns).
  • --maxmemory implies eviction, defaulting to allkeys-lru — the cap means “cache, evict to stay under”. Pass --maxmemory-policy noeviction for a hard ceiling (writes fail past the cap instead of evicting), or omit --maxmemory entirely for no cap. The policy flag requires --maxmemory; --appendfsync requires --aof — the CLI rejects those combinations before it pulls or creates anything.
  • --replica-of makes a read replica — see Read replicas. The persistence/eviction knobs stay orthogonal: a replica may cap memory or enable AOF exactly like a master.
  • --cluster makes 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 --cluster group share the first member’s password automatically (a later --cluster member with a different --password is rejected before anything is pulled or created), and the per-master follower count set by --cluster-replicas N is 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 (or 8) → the table’s tag is stored in image_tag, the major in version;
  • --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

# default: major 8, instance name "redis", port auto-assigned from 6379
pg addon install redis

# a named cache instance on major 7, with a memory cap (evicts at the cap)
pg addon install redis --name legacy --version 7 --maxmemory 256mb

# loopback-only instead of the all-interfaces default
pg addon install redis --name cache --listen 127.0.0.1

# pin the password (stable across reinstalls) and a fixed port
pg addon install redis --name sessions --password 'S3ssions!' --port 6379

# a read-only replica of the local "sessions" instance (borrows its password,
# major and endpoint)
pg addon install redis --name sessions-ro --replica-of sessions

The install summary prints everything a client needs:

✓ redis installed: "cache"
  Container:  pgcli-redis-default-cache
  Version:    8
  Image:      docker.io/library/redis:8.10.2
  Data:       ~/pg/addon/redis/cache/data
  Address:    0.0.0.0:6379
  Role:       master
  Persistence: rdb snapshots (default save schedule)

  Password:    <generated>

  Client:      pg redis-cli ping
  Raw DSN:     redis://:<password>@0.0.0.0:6379/0
  NOTE: listening on every interface; the password above is the only gate.
        loopback-only: pg addon install redis --name cache --listen 127.0.0.1 --force

Security model. listen defaults to 0.0.0.0 — on Linux (host networking) the port is published on every interface, and requirepass is the only gate. That is convenient for app-on-another-host setups but means the password is load-bearing: pass --listen 127.0.0.1 to keep an instance loopback-only. pg addon list and the logs never print the password; reveal it on demand with pg addon password redis --name cache or pg addon list --show-password (or read pg.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:

pg redis-cli ping                              # PONG
pg redis-cli set session:42 '{"user":"alice"}' # ok — auth is injected
pg redis-cli get session:42
pg redis-cli lrange queue 0 -1                 # negative indexes pass through
pg redis-cli --name legacy zrevrange leaderboard 0 9

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:

# reuse a local addon's image + password, but talk to a remote host (e.g. a replica)
pg redis-cli --host 10.0.0.7 ping
pg redis-cli --host 10.0.0.7 --port 6380 --name cache info

# no local addon at all: default-major image, password via REDISCLI_AUTH
REDISCLI_AUTH=s3cret pg redis-cli --host 10.0.0.7 dbsize

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:

REDISCLI_AUTH=otherpass pg redis-cli dbsize

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).

# master (writable)
pg addon install redis --name cache

# same-host replica: it borrows the master's major, password and endpoint
pg addon install redis --name cache-ro --replica-of cache

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 install redis --name cache-r2 \
    --replica-of-host 10.0.0.7 --replica-of-port 6379 --password <master-password>

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:

# read traffic -> the replica
redis://:<password>@127.0.0.1:<replica-port>/0

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:

pg redis-cli --name cache-ro replicaof no one   # stop following the master
pg addon remove redis --name cache-ro          # keep the data, forget the role
pg addon install redis --name cache-ro         # reinstall as a plain master

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.

# 1. install the members — one --cluster <token> turns each into a cluster-enabled
#    node; the same token means "same cluster". Minimum 3 masters (Redis's own
#    quorum rule). masters-only is the default (3 installs, --cluster-replicas 0);
#    to give each master a follower, install 3*(1+N) members and pass
#    --cluster-replicas N to the first one (the rest inherit it) — see step 2.
pg addon install redis --name n1 --cluster app --maxmemory 10mb
pg addon install redis --name n2 --cluster app --maxmemory 10mb
pg addon install redis --name n3 --cluster app --maxmemory 10mb

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.

# 2. assemble once, through pg redis-cli's passthrough (pgcli never runs this)
pg redis-cli --name n1 -- --cluster create 127.0.0.1:6379 127.0.0.1:6380 127.0.0.1:6381 --cluster-replicas 0

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:

-> NOTE: native-cluster member of "app" — it comes up cluster-enabled but INCOMPLETE
   until you assemble the group once (see the "Cluster:" line below). pgcli does not
   run --cluster create for you.
...
  Cluster:     "app" — 3 masters configured. Assemble once (pgcli does not run this):
               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
               (bus port = client+10000; for cross-host peers open it on the firewall, and give each member --advertise-host)

--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:

pg addon install redis --name n1 --cluster app --cluster-replicas 1 --maxmemory 10mb
pg addon install redis --name n2 --cluster app --maxmemory 10mb   # inherits replicas=1
# …install to 6 members total, then:
pg redis-cli --name n1 -- --cluster create \
    127.0.0.1:6379 127.0.0.1:6380 127.0.0.1:6381 \
    127.0.0.1:6382 127.0.0.1:6383 127.0.0.1:6384 --cluster-replicas 1

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:

pg redis-cli --name n1 set foo bar      # might be served by n3 via MOVED
pg redis-cli --name n2 get foo          # same key, followed the redirect again
pg redis-cli --name n1 -- --cluster check 127.0.0.1:6379   # admin subcommands, not -c's job

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):

export REDISCLI_AUTH=<the group password>
pg redis-cli --host 10.10.0.158 --port 6379 -- -c get foo   # follows MOVED for you
pg redis-cli --host 10.10.0.158 --port 6379 -- -c set foo bar

-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:

pg redis-cli --host 10.10.0.158 --port 6379 -- -a <the group password> -c get foo

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:

    Role:        cluster member of "app" (127.0.0.1:6381)
    Cluster:     app (bus 16381)

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 --):

# one member's view of the whole cluster — look for cluster_state:ok / cluster_size:N
pg redis-cli --host <ip> --port <port> -- -c cluster info
# full group health check (slot coverage, replicas, links) — redis-cli's admin surface
pg redis-cli -- --cluster check <ip>:<port>

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:

# on 10.10.0.158 — the first member decides the group's one password; read it back:
pg addon install redis --name n1 --cluster app --advertise-host 10.10.0.158
PW=$(pg addon password redis --name n1)
# on 10.10.0.159 and 10.10.0.160 — carry that same password across:
pg addon install redis --name n2 --cluster app --advertise-host 10.10.0.159 --password "$PW"
pg addon install redis --name n3 --cluster app --advertise-host 10.10.0.160 --password "$PW"
# assemble once (from any host that reaches all three) — the operands are each
# member's advertised address + its port (one member per host here, each host's
# own pool starts at 6379, so all three are :6379):
pg redis-cli --name n1 -- --cluster create \
    10.10.0.158:6379 10.10.0.159:6379 10.10.0.160:6379 --cluster-replicas 0

--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---masterauth reconnect 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:

pg addon install redis  --name cache    # 6379
pg addon install redis  --name sessions # 6380

--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:

namespace: default
redis_start_port: 6379     # Redis's own pool, separate from minio_start_port
addons:
  redis:
    cache:
      container_name: pgcli-redis-default-cache
      name: cache
      version: "8"                       # major as selected/stored
      image_tag: docker.io/library/redis:8.10.2
      # data_dir: /srv/redis-cache       # omit for <base-dir>/addon/redis/cache/data
      listen: 0.0.0.0                    # default (all interfaces); 127.0.0.1 to tighten
      port: 6379
      password: <generated>              # written on first install
      # maxmemory: 256mb                 # optional cap
      # maxmemory_policy: noeviction     # only with a cap; default allkeys-lru
      # aof: true                        # write-ahead log on top of snapshots
      # appendfsync: always              # only with aof; default everysec
      # save: "900 1 300 10"             # RDB schedule; "no" disables snapshots
      # replica_host: 127.0.0.1          # set by --replica-of: this is a read replica
      # replica_port: 6379               # the master's port, paired with replica_host
      # cluster: app                     # set by --cluster: this is a native-cluster member
      # cluster_replicas: 1              # set by --cluster-replicas: followers per master for --cluster create (0 = masters-only; group-shared)
      # advertise_host: 10.10.0.158      # set by --advertise-host: peer-visible address (cross-host only)
      autostart: false                   # pg autostart enable --redis --name cache

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
Infra add-ons (redis):
  redis (name: cache)
    Status:      running
    Version:     8
    Address:     0.0.0.0:6379
    Role:        master
    Auth:        on (requirepass)
    Maxmemory:   256mb (allkeys-lru)
    Persistence: rdb + aof (default save schedule, appendonly yes)
    Data:        ~/pg/addon/redis/cache/data
    Image:       docker.io/library/redis:8.10.2
    Container:   pgcli-redis-default-cache
    Client:      pg redis-cli --name cache ping

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 redis --name cache
export REDISCLI_AUTH="$(pg addon password redis --name cache)"

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

pg addon start redis --name cache
pg addon stop  redis --name cache

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:

pg autostart enable --redis --name cache

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

pg addon remove redis --name cache            # container + config entry; data kept
pg addon remove redis --name cache --clean-data  # also delete the data dir

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

pg logs addon redis --name cache        # last 50 lines
pg logs addon redis --name cache -f     # follow

Troubleshooting

  • NOAUTH Authentication required from a raw client — expected: every instance has a requirepass. Get it with pg addon password redis --name cache, or use pg redis-cli, which injects it.
  • maxmemory evicting more than you wanted — under a --maxmemory cap the default policy is allkeys-lru (evict any key to stay under: a cache, not a hard store). Pass --maxmemory-policy noeviction to make the cap a hard ceiling instead (writes fail past it rather than evicting), or omit --maxmemory for 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 always when you cannot afford that. A graceful pg stop always flushes, so this only bites on an unclean kill.
  • Image not found at install — both majors are plain docker.io/library/redis images pulled on demand; on an air-gapped host, podman load the tar first (the catalog and export steps live in docs/images.md in the repo) and the pull is skipped.
  • A hand-edited version that is not in the table — install errors and suggests --image rather than silently rewriting your value.
  • Port already live — auto-assignment scans host listeners and skips them; pin --port when 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. Watch info replication; it settles at up and role: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 — --cluster installs 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-node for 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 --masterauth that 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.internal for pg 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 --aof when that gap matters (at everysec you still lose at most one second; always closes it); accept snapshots-only for cache/session data, or move durable data to PostgreSQL.

12.12 - Predixy

Run Predixy as a pgcli addon — a Redis protocol proxy that exposes a native cluster as one plain redis:// endpoint, so cluster-unaware clients need no MOVED handling

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 must AUTH this 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:

PW=$(pg addon password redis --name n1)

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.

# single-host cluster (members on 127.0.0.1:6379/6380/6381)
PW=$(pg addon password redis --name n1)
pg addon install predixy --name proxy \
  --backend 127.0.0.1:6379,127.0.0.1:6380,127.0.0.1:6381 \
  --password "$PW"

# cross-host cluster — one member per host at the default port
pg addon install predixy --name app-proxy \
  --backend 10.10.0.158:6379,10.10.0.159:6379,10.10.0.160:6379 \
  --password "$PW" --workers 2

The summary prints the one endpoint clients should be pointed at:

✓ predixy installed: "proxy"
  Container:    pgcli-predixy-default-proxy
  Image:        ghcr.io/mars-base/pgcli/predixy:7.0.1-alpine
  Listen:       0.0.0.0:7617
  Workers:      2
  Backends:     3 (127.0.0.1:6379, 127.0.0.1:6380, 127.0.0.1:6381)
  Config:       ~/pg/addon/predixy/proxy/predixy.conf

  Password:     <the one password>
  Raw DSN:      redis://:<password>@0.0.0.0:7617/0

  Client:       redis-cli -h 0.0.0.0 -p 7617 -a '<password>' ping
  The proxy absorbs MOVED: an ordinary redis client — no -c, no
  redirect handling — reads and writes the whole cluster key space
  through this one endpoint.

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:

redis-cli -h 127.0.0.1 -p 7617 -a "$PW" set foo bar    # OK — no MOVED, no -c
redis-cli -h 127.0.0.1 -p 7617 -a "$PW" get foo        # "bar"

(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):

# on the proxy's own host
pg redis-cli --host 127.0.0.1 --port 7617 set foo bar
pg redis-cli --host 127.0.0.1 --port 7617 get foo

# from a host with no local redis addon: the default-major image, password via env
REDISCLI_AUTH=$PW pg redis-cli --host 10.10.0.158 --port 7617 ping

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:

namespace: default
predixy_start_port: 7617
addons:
  predixy:
    proxy:
      container_name: pgcli-predixy-default-proxy
      name: proxy
      image_tag: ghcr.io/mars-base/pgcli/predixy:7.0.1-alpine
      listen: 0.0.0.0
      port: 7617
      workers: 2
      backend:
        - 127.0.0.1:6379
        - 127.0.0.1:6380
        - 127.0.0.1:6381
      password: <the cluster requirepass>   # written by install, never generated
      autostart: false                      # pg autostart enable --predixy --name proxy

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
Infra add-ons (predixy):
  predixy (name: proxy)
    Status:      running
    Address:     0.0.0.0:7617
    Workers:     2
    Backends:    3 (127.0.0.1:6379, 127.0.0.1:6380, 127.0.0.1:6381)
    Client DSN:  redis://:<password>@0.0.0.0:7617/0
    Image:       ghcr.io/mars-base/pgcli/predixy:7.0.1-alpine
    Container:   pgcli-predixy-default-proxy
    Config:      ~/pg/addon/predixy/proxy/predixy.conf

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

pg addon start predixy --name proxy
pg addon stop  predixy --name proxy

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

pg autostart enable --predixy --name proxy

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

pg addon remove predixy --name proxy

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

pg logs addon predixy --name proxy      # last 50 lines
pg logs addon predixy --name proxy -f   # follow

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 — ClusterServerPool is 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-alpine caps clients at ClientLimit 128 and 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 listen loopback 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 password from clients — the client must AUTH the one password; check it against pg 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-edited predixy.conf — re-install.
  • Commands hang or every key errors — a backend node is unreachable from the proxy host (wrong address, firewalled). pg logs addon predixy shows the dial failures; --backend must 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

Run pgAdmin 4 as a pgcli addon — the official PostgreSQL web administration UI, one container from the upstream dpage/pgadmin4 image, with an auto-generated web login and optional pre-seeded servers

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 with pg 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. --dsn or --pg-name <instance> pre-registers one server in the UI’s servers.json at 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:

# quick look at proj01
pg addon install pgadmin --name proj01 --pg-name proj01
# ... browse, query, done
pg addon remove proj01 --clean-data

# inspect a remote production read-replica
pg addon install pgadmin --name prod-ro \
    --dsn "postgres://reader@10.0.0.7:5432/proddb"
pg addon remove prod-ro --clean-data

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.json itself 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 + password only take effect on a fresh data dir. pgAdmin reads PGADMIN_DEFAULT_* while it initialises pgadmin4.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-data first, or pin --password to the original when reviving.
  • --dsn and --pg-name are mutually exclusive — both mean “seed this one server”, one by URI and the other by resolving a local instance’s endpoint. --name is 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.
  • listen defaults to loopback — the web UI is a login gate, so it stays on 127.0.0.1 unless you pass --listen 0.0.0.0 to reach it from another host (the summary then warns that the login is the only gate). Full picture in Listening and exposure.
  • email must survive the image’s validator — the entrypoint checks it with email_validator and rejects reserved TLDs (.local, .invalid, .test) outright, crash-looping on one. The default admin@pgcli.lan is 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

# default: instance name "pgadmin", port auto from 5050, loopback, admin@pgcli.lan
pg addon install pgadmin

# a named console with your own web-login email
pg addon install pgadmin --name console --email me@example.com

# pre-seed the UI with one locally-managed instance
pg addon install pgadmin --name dev --pg-name proj01

# pre-seed from an arbitrary remote DSN
pg addon install pgadmin --name remote \
    --dsn "postgres://readonly@10.0.0.7:5432/appdb"

The install summary prints the URL and the (generated) web login:

✓ pgAdmin installed: "console"
  Container:  pgcli-pgadmin-default-console
  Image:      docker.io/dpage/pgadmin4:9.18
  Data:       ~/pg/addon/pgadmin/console/data
  URL:        http://127.0.0.1:5050/
  Seeded:     proj01
  Seed auth:  password pre-configured (pgpass) — no prompt on first connect

  Login email:    me@example.com
  Login password: <generated>
  (Web login only — not a PostgreSQL password. Retrieve it later with `pg addon password pgadmin`.)

  Add servers to browse in the web UI itself, or reinstall with --dsn/--pg-name to pre-seed one.

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:

# change the bind address to expose the UI on the network
pg addon install pgadmin --listen 0.0.0.0 --force

# switch the seeded server to a different instance
pg addon install pgadmin --pg-name proj02 --force

# drop the seed entirely (no --dsn / --pg-name + --force)
pg addon install pgadmin --force

# retag to a different upstream image
pg addon install pgadmin --image docker.io/dpage/pgadmin4:9.19 --force

Two caveats worth stating:

  • email / password still only re-apply to an empty data dir. pgAdmin reads PGADMIN_DEFAULT_* while it initialises pgadmin4.db; once that file exists the stored account is authoritative and the env vars are ignored. --force alone 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, --force kills 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:

# loopback only (default) — reachable on the podman host itself
pg addon install pgadmin --name console --listen 127.0.0.1 --force

# every interface — reachable from other hosts; login is the ONLY gate
pg addon install pgadmin --name console --listen 0.0.0.0 --force

# back to loopback after exposing
pg addon install pgadmin --name console --listen 127.0.0.1 --force
  • On Linux the container shares the host network, so --listen 0.0.0.0 answers on <host-ip>:<port> and --listen 127.0.0.1 on 127.0.0.1:<port>. Widening to 0.0.0.0 puts 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.0 inside the podman machine and pgcli publishes the port, so listen is effectively widened there regardless, and you reach the UI from the Mac at 127.0.0.1:<port> via gvproxy. See Platform.

Retrieving the web login

pg addon password pgadmin --name console          # print bare, for scripting
pg addon password pgadmin --name console --file ./pw.txt   # mode 0600 file

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:

pg addon install pgadmin --name dev --pg-name proj01
# or
pg addon install pgadmin --name dev --dsn "postgres://app:secret@127.0.0.1:5432/appdb"

What the seed does and does not carry:

  • The target server’s password IS pre-configured when the DSN carries one. servers.json itself cannot hold a password (pgAdmin refuses to import one), so pgcli writes a libpq pgpass file beside it and points the seeded entry at it via ConnectionParameters.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) renders servers.json only, 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 a servers.json which 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 alongside servers.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=True is set whenever a seed exists, so a --force reinstall that changes --dsn/--pg-name re-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:

# pgAdmin on vm01 fronting vm01's own instance, UI reachable from other hosts
pg addon install pgadmin --listen 0.0.0.0 --pg-name demo --force
# servers.json -> Host: 127.0.0.1, Port: <demo's pg port>   (dialed in-container)

# seed a server on ANOTHER host instead — the host must be reachable from the
# pgAdmin container, since it is the container that connects
pg addon install pgadmin --listen 0.0.0.0 \
    --dsn "postgres://app@10.0.0.12:35432/appdb" --force

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:

pg addon install pgadmin --name console   # 5050
pg addon install pgadmin --name audit     # 5051

--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:

namespace: default
pgadmin_start_port: 5050     # pgAdmin's own pool
addons:
  pgadmin:
    console:
      container_name: pgcli-pgadmin-default-console
      name: console
      image_tag: docker.io/dpage/pgadmin4:9.18
      # data_dir: /srv/pgadmin         # omit for <base-dir>/addon/pgadmin/console/data
      listen: 127.0.0.1                # default; 0.0.0.0 to expose on the network
      host_port: 5050
      email: me@example.com            # web login account (PGADMIN_DEFAULT_EMAIL)
      password: <generated>            # web login password; written on first install
      # dsn: postgres://app@127.0.0.1:5432/appdb   # set by --dsn/--pg-name: the seed
      # server_name: app               # display name of the seeded server
      autostart: false                 # pg autostart enable --pgadmin --name console

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
Web add-ons (pgadmin):
  pgadmin (name: console)
    Status:      running
    URL:         http://127.0.0.1:5050/
    Login email: me@example.com
    Seeded:      proj01
    Data:        ~/pg/addon/pgadmin/console/data
    Image:       docker.io/dpage/pgadmin4:9.18
    Container:   pgcli-pgadmin-default-console

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

pg addon start pgadmin --name console
pg addon stop  pgadmin --name console

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:

pg autostart enable --pgadmin --name console

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

pg addon remove pgadmin --name console               # container + config entry; data kept
pg addon remove pgadmin --name console --clean-data  # also delete the data dir

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

pg logs addon pgadmin --name console        # last 50 lines
pg logs addon pgadmin --name console -f     # follow

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.db on the first init; the newly generated one is inert (install warns “reviving an existing pgAdmin data dir”). Use --clean-data to start fresh, or pg addon password pgadmin to confirm what’s stored, or reinstall with --password pinned to the original.
  • “pgAdmin could not start” / container exits immediately (or crash-loops) — the entrypoint needs both PGADMIN_DEFAULT_EMAIL and PGADMIN_DEFAULT_PASSWORD on a fresh dir; a hand-edited pg.yaml with 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 is admin@pgcli.lan and not .local.
  • Can’t reach the UI from another machine — listen defaults to 127.0.0.1. Reinstall --listen 0.0.0.0 --force and 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 chown running (e.g. someone passed --user 5050 directly 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/pgadmin4 is pulled on demand; on an air-gapped host, podman load the tar first (catalog and export steps in docs/images.md in 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_SERVER tuning) in front; pgcli does not wire it. The loopback default keeps the common case on 127.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 import for pgcli instances, or pg exec --dsn "..." -- pg_dump for arbitrary databases. See Data Import/Export for details.
  • Seed password lives on disk as plaintext — the pgpass file beside servers.json is 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 initialises pgadmin4.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, proxyBindHost widening loopback to 0.0.0.0 on the bridge) mirrors redis; it has not yet been exercised on a Mac.

12.14 - Nginx

Run nginx as a pgcli addon — an HTTP reverse proxy in front of web services, with path-based routing and optional TLS termination

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 location block forwards to one upstream (web app, API service, etc.), so clients reach multiple services through a single port.
  • File-driven configuration. pgcli renders nginx.conf from the backends you declare (or accepts a custom file via --conf-file) and bind-mounts it read-only into the official nginx:1.27-alpine image. TLS is optional: when enabled without a BYO cert, pgcli generates a self-signed leaf via tlsca.
  • 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 via nginx -t using a temporary container), reload (hot reload via nginx -s reload), and exec (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.conf from --upstream specs. Each upstream becomes an upstream block and a location block; 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 full nginx.conf path; 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 + variable proxy_pass, WebSocket, SNI, multiple locations per backend, upstream keepalive, etc.). --upstream and --conf-file are 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’s location block
  • error_<name>.log — error log for that backend’s location block

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:

pg cert --host git.example.com,10.0.0.9 \
        --cert-file /srv/gitea.crt --key-file /srv/gitea.key
pg addon install nginx --name proxy --listen 0.0.0.0 \
  --upstream name=gitea,path=/,backend=127.0.0.1:3080 \
  --tls --tls-cert /srv/gitea.crt --tls-key /srv/gitea.key \
  --http-port 80 --https-port 443

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. Repeat backend= within the same --upstream spec to add multiple servers (nginx round-robin), or use the shorthand backends=h1:p1,h2:p2,.... backend and backends are mutually exclusive within a single --upstream spec;
  • --conf-file <path> — a custom nginx.conf file (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)
# template mode: one upstream, explicit
pg addon install nginx --name proxy \
  --upstream name=web,path=/app,backend=127.0.0.1:8000

# template mode: multiple upstreams
pg addon install nginx --name proxy \
  --upstream name=web,path=/app,backend=127.0.0.1:8000 \
  --upstream name=api,path=/api,backend=127.0.0.1:3000

# single upstream with multiple servers (load balancing, nginx round-robin)
pg addon install nginx --name proxy \
  --upstream name=api,path=/,backends=127.0.0.1:3000,127.0.0.1:3001,127.0.0.1:3002

# template mode with TLS (self-signed cert)
pg addon install nginx --name proxy \
  --upstream name=web,path=/app,backend=127.0.0.1:8000 --tls

# custom mode: you provide the full config
pg addon install nginx --name custom \
  --conf-file /path/to/nginx.conf --http-port 8080

The install summary reports the assigned ports and the config path:

✓ nginx installed: "proxy"
  Container:  pgcli-nginx-default-proxy
  Image:      docker.io/library/nginx:1.27-alpine
  HTTP:       127.0.0.1:8080
  HTTPS:      (disabled)
  Backends:   2
    - web   /app → 127.0.0.1:8000
    - api   /api → 127.0.0.1:3000
  Config:     ~/pg/addon/nginx/proxy/nginx.conf
  Logs:       ~/pg/addon/nginx/proxy/log/

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:

# change the bind address to expose the proxy on the network
pg addon install nginx --listen 0.0.0.0 --force

# retag to a different upstream image
pg addon install nginx --image docker.io/library/nginx:1.28-alpine --force

Nginx subcommands

Beyond the generic addon lifecycle (install/start/stop/remove), nginx exposes three subcommands for day-to-day operations:

test — validate config syntax

pg addon nginx test --name proxy
pg addon nginx test --conf-file /path/to/nginx.conf

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:

# edit the config file (template or custom)
vim ~/pg/addon/nginx/proxy/nginx.conf

# validate syntax without affecting the running proxy
pg addon nginx test --name proxy
# nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
# nginx: configuration file /etc/nginx/nginx.conf test is successful

# only then reload
pg addon nginx reload --name proxy

--conf-file validates a standalone config file before installing it — useful for CI pipelines or pre-deploy checks:

# validate a config you haven't installed yet
pg addon nginx test --conf-file /tmp/new-nginx.conf --image docker.io/library/nginx:1.27-alpine

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

pg addon nginx reload --name proxy

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

pg addon nginx exec --name proxy -- cat /etc/nginx/nginx.conf
pg addon nginx exec --name proxy -- nginx -V
pg addon nginx exec --name proxy -- ls -la /var/log/nginx/

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-file is mutually exclusive with --upstream. Both mean “here is the config” — one by path, the other by declaring backends. The install rejects the combination.
  • --tls without --tls-cert/--tls-key generates 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-key without --tls is ignored. The flags only take effect when TLS is enabled.
  • --worker-connections injects into --conf-file mode. When both are set, pgcli patches the user’s events block (or adds one) to set the given value. The user’s worker_connections directive, if any, is replaced — not merged.
  • --listen defaults to loopback — the proxy stays on 127.0.0.1 unless you pass --listen 0.0.0.0 to reach it from another host. On Linux the container shares the host network, so 0.0.0.0 answers on <host-ip>:<port>; on macOS the bridge publishes the port and proxyBindHost widens loopback to 0.0.0.0 on 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:

pg addon install nginx --name proxy                          # 8080 (HTTP only)
pg addon install nginx --name proxy-tls --tls                # 8081 (HTTP) + 8082 (HTTPS)
pg addon install nginx --name proxy2                         # 8083

--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:

namespace: default
nginx_start_port: 8080     # nginx's own pool
addons:
  nginx:
    proxy:
      container_name: pgcli-nginx-default-proxy
      name: proxy
      image_tag: docker.io/library/nginx:1.27-alpine
      listen: 127.0.0.1
      http_port: 8080
      https_port: 0        # 0 = auto-assign (TLS only)
      worker_connections: 1024
      tls: false
      # tls_cert: /path/to/cert.pem   # BYO cert (tls: true required)
      # tls_key:  /path/to/key.pem
      # conf_file: /etc/nginx/custom.conf  # custom mode (skips backends)
      autostart: false     # pg autostart enable --nginx --name proxy
      backends:
        - { name: web,  path: /app, backends: ["127.0.0.1:8000"] }
        - { name: api,  path: /api, backends: ["127.0.0.1:3000"] }

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:

worker_processes auto;
error_log /var/log/nginx/error.log;
pid /tmp/nginx.pid;

events {
    worker_connections 60000;
    multi_accept on;
}

http {
    ##
    # Basic Settings
    ##

    sendfile on;
    tcp_nopush on;
    types_hash_max_size 2048;

    include /etc/nginx/mime.types;
    default_type application/octet-stream;

    ##
    # Logging
    ##

    log_format postdata '$remote_addr - $request_id [$time_local] "$request" '
                        '$status $body_bytes_sent $server_name "$request_body" '
                        '$request_length "$http_referer" "$http_user_agent" '
                        '"$http_x_forwarded_for" $request_time $upstream_response_time';

    access_log /var/log/nginx/access.log postdata;

    ##
    # SSL Settings
    ##

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers on;

    ##
    # Gzip Settings
    ##

    gzip on;

    ##
    # Rate Limiting (declare zones at http level, apply in server/location)
    ##

    # limit_req_zone $uri zone=api_fast:10m rate=5000r/s;
    # limit_req_zone $uri zone=api_slow:10m rate=100r/s;

    ##
    # Virtual Hosts
    ##

    # server { ... }  # see patterns below
}

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:

http {
    server {
        listen 127.0.0.1:9001;
        access_log /var/log/nginx/access_dns.log;
        error_log  /var/log/nginx/error_dns.log;

        resolver 8.8.8.8 8.8.4.4 ipv6=off;
        set $backend "https://api.example.com";

        location / {
            proxy_pass $backend;
        }
    }
}

Rate limiting per URL — limit_req_zone at the http level, applied per location. Use different zones for different rate tiers:

http {
    limit_req_zone $uri zone=api_fast:10m rate=5000r/s;
    limit_req_zone $uri zone=api_slow:10m rate=100r/s;

    server {
        listen 127.0.0.1:9001;
        access_log /var/log/nginx/access_ratelimit.log;
        error_log  /var/log/nginx/error_ratelimit.log;

        location /fast-api/ {
            limit_req zone=api_fast burst=10000 nodelay;
            limit_req_status 429;
            proxy_pass https://backend.example.com;
        }
        location /slow-api/ {
            limit_req zone=api_slow burst=200 nodelay;
            limit_req_status 429;
            proxy_pass https://backend.example.com;
        }
    }
}

WebSocket proxy — requires Upgrade and Connection headers plus HTTP/1.1:

http {
    server {
        listen 127.0.0.1:9001;
        access_log /var/log/nginx/access_ws.log;
        error_log  /var/log/nginx/error_ws.log;

        location / {
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "upgrade";
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_pass https://ws-backend.example.com;
            proxy_http_version 1.1;
        }
    }
}

Upstream with keepalive — reduces connection overhead for high-throughput backends:

http {
    upstream backend-pool {
        server 10.0.0.1:8080;
        server 10.0.0.2:8080;
        keepalive 10;
    }

    server {
        listen 127.0.0.1:9001;
        access_log /var/log/nginx/access_pool.log;
        error_log  /var/log/nginx/error_pool.log;

        location / {
            proxy_pass http://backend-pool;
            proxy_http_version 1.1;
            proxy_set_header Connection "";
        }
    }
}

Host-based routing with proxy_set_header Host — proxy to a backend by IP but set the correct virtual host:

http {
    server {
        listen 127.0.0.1:9001;
        access_log /var/log/nginx/access_host.log;
        error_log  /var/log/nginx/error_host.log;

        resolver 8.8.8.8 8.8.4.4 ipv6=off;

        location / {
            proxy_set_header Host api.internal.example.com;
            proxy_pass http://10.0.0.100;
            proxy_http_version 1.1;
        }
    }
}

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:

http {
    server {
        listen 127.0.0.1:9001;
        access_log /var/log/nginx/access_sni.log;
        error_log  /var/log/nginx/error_sni.log;

        resolver 8.8.8.8 8.8.4.4 ipv6=off;

        location / {
            proxy_set_header Host api.example.com;
            proxy_pass https://api.example.com;
            proxy_ssl_server_name on;
        }
    }
}

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:

http {
    limit_req_zone $uri zone=general:10m rate=5000r/s;
    limit_req_zone $uri zone=sensitive:10m rate=100r/s;

    server {
        listen 127.0.0.1:9001;
        access_log /var/log/nginx/access_backend.log;
        error_log  /var/log/nginx/error_backend.log;

        location / {
            limit_req zone=general burst=5000 nodelay;
            limit_req_status 429;
            proxy_pass https://backend.example.com;
        }
        location /admin/ {
            limit_req zone=sensitive burst=200 nodelay;
            limit_req_status 429;
            proxy_pass https://backend.example.com;
        }
    }
}

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:

http {
    log_format postdata '$remote_addr - $request_id [$time_local] "$request" '
                        '$status $body_bytes_sent $server_name "$request_body" '
                        '$request_length "$http_referer" "$http_user_agent" '
                        '"$http_x_forwarded_for" $request_time $upstream_response_time';

    server {
        listen 127.0.0.1:9001;
        access_log /var/log/nginx/access_api.log postdata;
        error_log  /var/log/nginx/error_api.log;

        location / {
            proxy_pass https://api.example.com;
        }
    }

    server {
        listen 127.0.0.1:9002;
        access_log /var/log/nginx/access_web.log postdata;
        error_log  /var/log/nginx/error_web.log;

        location / {
            proxy_pass https://web.example.com;
        }
    }
}

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:

worker_processes auto;
pid /tmp/nginx.pid;

events {
    worker_connections 1024;
}

http {
    # nginx's built-in default is 1 MiB — a bigger git push dies with HTTP 413.
    # 10m covers typical pushes; raise it (or use 0 to disable the check) if
    # you commit larger binaries.
    client_max_body_size 10m;

    # push/receive-pack streams can outlast the 60s default.
    proxy_read_timeout 300s;
    proxy_send_timeout 300s;

    upstream gitea {
        server 127.0.0.1:3080;
    }

    server {
        listen 0.0.0.0:80;
        access_log /var/log/nginx/gitea.access.log;
        error_log  /var/log/nginx/gitea.error.log;

        location / {
            proxy_pass http://gitea;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }
    }

    server {
        listen 0.0.0.0:443 ssl;
        # pgcli bind-mounts the --tls-cert / --tls-key files at these paths.
        ssl_certificate     /etc/nginx/certs/public.crt;
        ssl_certificate_key /etc/nginx/certs/private.key;
        ssl_protocols TLSv1.2 TLSv1.3;

        access_log /var/log/nginx/gitea.access.log;
        error_log  /var/log/nginx/gitea.error.log;

        location / {
            proxy_pass http://gitea;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }
    }
}
pg cert --host 10.0.0.9,git.internal \
        --cert-file /srv/gitea.crt --key-file /srv/gitea.key
pg addon install nginx --name proxy --conf-file /srv/gitea-nginx.conf \
  --tls --tls-cert /srv/gitea.crt --tls-key /srv/gitea.key \
  --http-port 80 --https-port 443

--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:

http {
    server {
        listen 127.0.0.1:9001;
        access_log /var/log/nginx/access_static.log;
        error_log  /var/log/nginx/error_static.log;
        root /srv/static/;

        location / {
            sendfile on;
            sendfile_max_chunk 2m;
            autoindex on;
            try_files $uri $uri/ =404;
        }
        location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg)$ {
            expires 1d;
            add_header Cache-Control "public";
            access_log off;
        }
        location ~ /\. {
            deny all;
        }
    }
}

List

pg addon list
Web add-ons (nginx):
  nginx (name: proxy)
    Status:      running
    HTTP:        127.0.0.1:8080
    HTTPS:       (disabled)
    Backends:    2
      - web   /app → 127.0.0.1:8000
      - api   /api → 127.0.0.1:3000
    Config:      ~/pg/addon/nginx/proxy/nginx.conf
    Logs:        ~/pg/addon/nginx/proxy/log/
    Image:       docker.io/library/nginx:1.27-alpine
    Container:   pgcli-nginx-default-proxy

Start and stop

pg addon start nginx --name proxy
pg addon stop  nginx --name proxy

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:

pg autostart enable --nginx --name proxy

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

pg addon remove nginx --name proxy

Stops and removes the container and deletes its config directory (including TLS certs and logs). An empty per-instance parent directory is pruned.

Logs

pg logs addon nginx --name proxy        # last 50 lines
pg logs addon nginx --name proxy -f     # follow

The container logs to stdout, so access and error events show up here. For per-backend logs, look at the bind-mounted log directory:

ls ~/pg/addon/nginx/proxy/log/
# access.log  access_web.log  access_api.log
# error.log   error_web.log   error_api.log

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 runs nginx -t in a temporary container and reports the exact line number.
  • --conf-file and --upstream together. The install rejects the combination — pick one mode.
  • Can’t reach the proxy from another machine. listen defaults to 127.0.0.1. Reinstall --listen 0.0.0.0 --force and accept that the proxy is then the only gate in front of the backends.
  • Image not found at install. docker.io/library/nginx is pulled on demand; on an air-gapped host, podman load the tar first (catalog and export steps in docs/images.md in the repo) and the pull is skipped.
  • Reload fails with “container not running”. pg addon nginx reload requires the container to be up. Start it first with pg 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.conf covers path-based routing to a handful of backends with optional TLS. Production configs that need limit_req_zone, resolver + variable proxy_pass, WebSocket, SNI, multiple locations per backend, or upstream keepalive should use --conf-file instead.
  • macOS is code-complete, lightly tested. The bridge path (published ports, proxyBindHost widening loopback to 0.0.0.0 on the bridge) mirrors the other bridge addons; it has been exercised but not yet under load.

12.15 - Grafana

Run the Grafana observability dashboard as a managed container.

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-net bridge 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

pg addon install grafana

The command pulls the image if needed, creates the container, and prints the URL and login password:

✓ Grafana installed: "grafana"
  Container:  pgcli-grafana-default-grafana
  URL:        http://0.0.0.0:3000/
  Login user:     admin
  Login password: <auto-generated>

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:

pg addon password grafana              # bare password on stdout
pg addon password grafana --file ./pw  # or write it to a 0600 file

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:

pg addon install grafana --name dash2   # second instance: next free port (3001 if taken)
pg addon install grafana --name dash3 --port 3100  # explicit port still wins

Grafana’s own ports share no number with any other addon’s pool bases; an explicit --port still takes precedence.

Configuration

addons:
  grafana:
    ui:
      name: ui
      image_tag: docker.io/grafana/grafana:13.2.4
      host_port: 3000
      listen: 0.0.0.0
      admin_user: admin
      password: <generated>
      autostart: false
      # data_dir: /srv/grafana-data    # optional, overrides the default location

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

pg addon list
Web add-ons (grafana):
  grafana (name: ui)
    Status:      running
    URL:         http://0.0.0.0:3000/
    Login user:  admin
    Data:        /home/you/.pgcli/addon/grafana/ui/data
    Image:       docker.io/grafana/grafana:13.2.4
    Container:   pgcli-grafana-ui

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:

pg addon stop grafana
pg addon start grafana

Auto-start on Boot

To have a Grafana instance started automatically on host boot, enable it:

pg autostart enable --grafana --name ui

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

pg addon remove grafana              # keeps the data dir — dashboards and login survive a reinstall
pg addon remove grafana --clean-data # deletes the data dir too (Grafana's data is gone)

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

pg logs addon grafana            # show recent console output
pg logs addon grafana -f         # follow
pg logs addon grafana -n 200     # last 200 lines (also -i / --tail)

Troubleshooting

  • install fails 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: through podman unshare chown under rootless podman (where the mapped subordinate uid is the only correct target), or a direct recursive chown when pgcli itself runs as root on a rootful host (sudo podman, or pg run as root). The manager shells out to the plain podman CLI, so this only fails outside those two cases — e.g. a non-root pg explicitly pointed at someone else’s rootful daemon. Run the install as root, or switch to rootless podman; a manual sudo 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_PASSWORD only initialises the login while grafana.db is absent. On an existing store use grafana-cli admin reset-admin-password <new> inside the container, or pg addon remove grafana --clean-data + reinstall (wipes the dashboards).
  • Dashboard unreachable from another machine — Listen defaults to 0.0.0.0, so the port is open on the host’s interfaces; a failure there is host firewall, not bind address. Check pg addon list for the port, then open it.
  • Port already allocated — the assigned port is held in pg.yaml and skipped for later instances; free it with pg addon remove first, 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 chown on 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-url flag — GF_SERVER_ROOT_URL is 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 (direct chown). 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

Run Prometheus, the metrics collection backend Grafana reads from, as a managed container.

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.0 default 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.1 for 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-net bridge 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

pg addon install prometheus

The command pulls the image if needed, renders a prometheus.yml, creates the container, and prints the URL, config path, and retention:

✓ Prometheus installed: "prometheus"
  Container:  pgcli-prometheus-default-prometheus
  Image:      docker.io/prom/prometheus:v3.15.0
  Data:       /home/you/.pgcli/addon/prometheus/prometheus/data
  Config:     /home/you/.pgcli/addon/prometheus/prometheus/prometheus.yml (rendered: 1 self job + 0 scrape job(s))
  Retention:  15d
  URL:        http://0.0.0.0:9090/

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.

# each --scrape spec declares a job; the name before = is required, and several
# comma-separated targets share that one job (the shape for a fleet of exporters)
pg addon install prometheus --name p1 \
  --scrape postgres-exporter=127.0.0.1:9187 \
  --scrape node=10.0.0.5:9100,10.0.0.6:9100

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:

# one windows job, three targets, each labelled with its node name
# (a spec with labels contains a space, so quote it as ONE shell argument)
pg addon install prometheus --name p1 \
  --scrape "windows=10.0.0.1:9182 nodename=api1" \
  --scrape "windows=10.0.0.2:9182 nodename=api2" \
  --scrape "windows=10.0.0.3:9182 nodename=api3"
# renders exactly the native per-node shape: one job_name, one labelled
# static_configs block per distinct label set
  - job_name: windows
    static_configs:
      - targets: ["10.0.0.1:9182"]
        labels:
          nodename: "api1"
      - targets: ["10.0.0.2:9182"]
        labels:
          nodename: "api2"
      - targets: ["10.0.0.3:9182"]
        labels:
          nodename: "api3"

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:

pg addon install prometheus --name p1 --scrape-append node=10.0.0.7:9100
# the stored node job grows a third target; its labels are untouched
pg addon install prometheus --name p1 --scrape-append "node=10.0.0.5:9100 env=prod"
# ...or an existing target gains a label in place (merged, still one scrape)

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:

pg addon install alertmanager --name am1
pg addon install prometheus --name p1 \
  --scrape postgres-exporter=127.0.0.1:9187 \
  --alertmanager-url 127.0.0.1:9093

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:

alerting:
  alertmanagers:
    - static_configs:
        - targets: ["127.0.0.1:9093"]
...
  - job_name: alertmanager
    static_configs:
      - targets: ["127.0.0.1:9093"]

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:

curl -s localhost:9090/api/v1/alertmanagers           # activeAlertmanagers lists the target
curl -s 'localhost:9090/api/v1/targets?state=any' | grep -c alertmanager

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:

# long-term store — single-node VictoriaMetrics on the loopback, or the
# vmcluster addon's vminsert (http://<host>:8480/insert/0/prometheus/api/v1/write)
pg addon install victoriametrics --name vm --listen 127.0.0.1

# the scrape backend pushes every sample into it
pg addon install prometheus --name p1 --listen 127.0.0.1 \
  --remote-write-url http://127.0.0.1:8428/api/v1/write

# same, with VictoriaMetrics's suggested batching under load
pg addon install prometheus --name p1 --listen 127.0.0.1 \
  --remote-write-url http://127.0.0.1:8428/api/v1/write \
  --remote-write-queue max_samples_per_send=10000 \
  --remote-write-queue capacity=20000 \
  --remote-write-queue max_shards=30

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:

remote_write:
  - url: "http://127.0.0.1:8428/api/v1/write"
    queue_config:
      max_samples_per_send: 10000
      capacity: 20000
      max_shards: 30

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:

# the remote-write queue is draining (pending ~0, samples_total rising)
curl -s 'localhost:9090/api/v1/query?query=prometheus_remote_storage_samples_pending'
# the sample actually landed in VictoriaMetrics
curl -s 'localhost:8428/api/v1/query?query=prometheus_build_info'

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:

pg addon install prometheus --name p1 --rule-file /srv/rules/alert.rules.yml

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:

rule_files:
  - "/etc/prometheus/rules/alert.rules.yml"

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:

# edit /srv/rules/alert.rules.yml ...
pg addon prometheus reload --name p1
curl -s localhost:9090/api/v1/rules    # groups reflect the edit; or watch pg logs addon prometheus

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:

pg addon install prometheus --name p1 --conf-file /etc/prometheus/my.yml

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:

# /etc/prometheus/my.yml
# pg addon install prometheus --name p1 --conf-file /etc/prometheus/my.yml
global:
  scrape_interval: 15s
  scrape_timeout: 15s
  evaluation_interval: 1m

alerting:
  alertmanagers:
    - static_configs:
        - targets:
            - localhost:9093 # a pgcli alertmanager on its default base port

rule_files:
  - /etc/prometheus/rules/*.rules.yml # pgcli's managed rules dir (--rule-file)

remote_write: []

scrape_configs:
  - job_name: prometheus
    static_configs:
      - targets:
          - localhost:9090 # this Prometheus itself
  - job_name: nodes
    static_configs:
      - targets:
          - 10.246.36.2:9100
        labels:
          nodename: lhcloud1
      - targets:
          - 10.246.36.3:9100
        labels:
          nodename: lhcloud2
      - targets:
          - 10.246.36.4:9100
        labels:
          nodename: lhcloud3

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:

c=$(podman ps --filter name=pgcli-prometheus --format '{{.Names}}' | head -1)
podman exec "$c" promtool check config /etc/prometheus/prometheus.yml

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)

pg addon prometheus reload --name p1

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.

pg addon install prometheus --name p2          # second instance: next free port (9091 if 9090 taken)
pg addon install prometheus --name p3 --port 9100  # explicit port still takes precedence

Configuration

addons:
  prometheus:
    p1:
      name: p1
      image_tag: docker.io/prom/prometheus:v3.15.0
      host_port: 9090
      listen: 0.0.0.0
      retention: 15d
      scrape:
        - postgres-exporter=127.0.0.1:9187
        - node=10.0.0.5:9100,10.0.0.6:9100
        - node=10.0.0.5:9100 env=prod   # same node again → ONE scrape carrying the union of labels
        - windows=10.0.0.1:9182 nodename=api1   # bare KEY=VALUE tokens = per-target labels
      scrape_interval: 15s
      autostart: false
      # alertmanager: [127.0.0.1:9093]        # render-mode only: forward fired alerts + a derived scrape job
      # remote_write: [http://127.0.0.1:8428/api/v1/write]  # render-mode only: push samples to a long-term store (VictoriaMetrics)
      # remote_write_queue: [max_samples_per_send=10000, capacity=20000]  # queue_config under every remote above
      # rule_files: [/srv/rules/alert.rules.yml]  # copied into <name>/rules, mounted at /etc/prometheus/rules
      # conf_file: /etc/prometheus/my.yml   # operator-file mode, skips rendering
      # data_dir: /srv/prometheus-data      # optional, overrides the default location

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

pg addon list
Web add-ons (prometheus):
  prometheus (name: p1)
    Status:      running
    URL:         http://0.0.0.0:9090/
    Config:      /home/you/.pgcli/addon/prometheus/p1/prometheus.yml [rendered (1 scrape job(s))]
    Retention:   15d
    Data:        /home/you/.pgcli/addon/prometheus/p1/data
    Image:       docker.io/prom/prometheus:v3.15.0
    Container:   pgcli-prometheus-p1

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:

pg addon stop prometheus
pg addon start prometheus

Auto-start on Boot

To have a Prometheus instance started automatically on host boot, enable it:

pg autostart enable --prometheus --name p1

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

pg addon remove prometheus              # keeps the TSDB — collected metrics survive a reinstall
pg addon remove prometheus --clean-data # deletes the TSDB too

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

pg logs addon prometheus            # show recent console output
pg logs addon prometheus -f         # follow
pg logs addon prometheus -n 200     # last 200 lines (also -i / --tail)

Troubleshooting

  • install fails with “chowning Prometheus data dir … to 65534:65534” — the image runs as nobody (uid/gid 65534) and never fixes ownership itself, so pgcli chowns the host data dir before creating the container: through podman unshare chown under rootless podman (where the mapped subordinate uid is the only correct target), or a direct recursive chown when pgcli itself runs as root on a rootful host. The manager shells out to the plain podman CLI, so this only fails outside those two cases — e.g. a non-root pg explicitly pointed at someone else’s rootful daemon. Run the install as root, or switch to rootless podman; a manual sudo 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 Linux 127.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 prometheus shows scrape failures.
  • --scrape-append aborted with “returned no data” — by design: an appended target must answer GET /metrics with 200 from the host before anything is written (the existing instance and pg.yaml stay 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 reload needs the container running; if the new prometheus.yml is malformed Prometheus keeps serving the last good config and logs the error. Check pg logs addon prometheus, fix the file, reload again.
  • Port already allocated — the assigned port is held in pg.yaml and skipped for later instances; free it with pg addon remove first, or pick another with --port N.
  • Remote API unreachable / unexpectedly reachable — on the default 0.0.0.0 a 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:9187 at it). Long-term storage and alerting are wired, though: --remote-write-url pushes scraped samples to a VictoriaMetrics addon (single-node or vmcluster), --alertmanager-url forwards fired alerts to a pgcli Alertmanager, and --rule-file loads alerting rules (all render-mode; --conf-file covers 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.0 bind is the network boundary. This is a materially weaker posture than Grafana’s password-guarded 0.0.0.0 — do not expose it on an untrusted network.
  • pgcli renders or copies prometheus.yml but does not validate it (there is no promtool check config gate at install). A bad rendered config is impossible, but a bad --conf-file surfaces 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 (direct chown). 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

Run Alertmanager, Prometheus’s alert deduplication/grouping/routing frontend, as a managed container.

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 alertmanager does not touch any prometheus.yml — and it does not need to: Prometheus points at this instance itself, with pg addon install prometheus --alertmanager-url <host:port of this instance>, which injects the alerting: section (and a scrape job watching this instance’s /metrics). On a hand-written Prometheus config (--conf-file), write the alerting: 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.0 default 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-net bridge 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

pg addon install alertmanager

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:

✓ Alertmanager installed: "alertmanager"
  Container:  pgcli-alertmanager-default-alertmanager
  Image:      docker.io/prom/alertmanager:v0.34.1
  Data:       /home/you/.pgcli/addon/alertmanager/alertmanager/data
  Config:     /home/you/.pgcli/addon/alertmanager/alertmanager/alertmanager.yml (rendered: no-op receiver)
  URL:        http://0.0.0.0:9093/

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:

# one URL → the route's receiver becomes web.hook with one webhook_configs entry
pg addon install alertmanager --name am1 --webhook-url http://127.0.0.1:5001/

# several URLs → all of them land under ONE web.hook receiver (fan-out to each)
pg addon install alertmanager --name am1 \
  --webhook-url http://127.0.0.1:5001/ \
  --webhook-url https://hooks.example.com/alert?token=abc

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:

pg addon install alertmanager --name am1 --conf-file /etc/alertmanager/my.yml

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:

# Complete Alertmanager config for `pg addon install alertmanager
# --name am1 --conf-file /path/to/this.yml` — everything pgcli would
# render is replaced by what follows; only the container flags
# (--web.listen-address, --storage.path, --cluster.listen-address=)
# stay pgcli's job.
global:
  resolve_timeout: 5m
  smtp_smarthost: "smtp.example.com:587"
  smtp_from: "alertmanager@example.com"
  smtp_auth_username: "alertmanager@example.com"
  smtp_auth_password: "CHANGE-ME"
  slack_api_url: "https://hooks.slack.com/services/T00000/B00000/XXXXXXXX"

inhibit_rules:
  # a critical alert mutes warning/info noise from the SAME alert on the SAME instance
  - source_matchers: ['severity = critical']
    target_matchers: ['severity =~ warning|info']
    equal: ['alertname', 'instance']

route:
  receiver: default
  group_by: ['alertname']
  group_wait: 30s
  group_interval: 5m
  repeat_interval: 4h
  routes:
    # critical goes to the on-call mailbox, and still falls through to default
    - matchers: ['severity = critical']
      receiver: oncall-email
      continue: true
    # storage team's alerts get their own Slack channel
    - matchers: ['team = storage']
      receiver: storage-slack
      group_by: ['alertname']
      group_wait: 10s

receivers:
  - name: default
    webhook_configs:
      - url: "http://127.0.0.1:5001/"

  - name: oncall-email
    email_configs:
      - to: "oncall@example.com"
        send_resolved: true

  - name: storage-slack
    slack_configs:
      - channel: "#storage-alerts"
        send_resolved: true

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):

pg addon install alertmanager --name am1 --conf-file /etc/alertmanager/my.yml
# edit /etc/alertmanager/my.yml ...
podman exec pgcli-alertmanager-default-am1 amtool check-config /etc/alertmanager/alertmanager.yml
pg addon alertmanager reload --name am1   # re-copies and SIGHUPs once the file is good

Reload (hot config change)

pg addon alertmanager reload --name am1

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 9094 by 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 of 9094; the number is not reserved, so the web-port pool is free to hand 9094 out 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).

pg addon install alertmanager --name am2          # second instance: next free port after 9093 (e.g. 9094 — the pool is plain numeric assignment; nothing reserves the disabled cluster port's number)
pg addon install alertmanager --name am3 --port 9200  # explicit port still takes precedence

Configuration

addons:
  alertmanager:
    am1:
      name: am1
      image_tag: docker.io/prom/alertmanager:v0.34.1
      host_port: 9093
      listen: 0.0.0.0
      webhook:
        - http://127.0.0.1:5001/
        - https://hooks.example.com/alert?token=abc
      autostart: false
      # conf_file: /etc/alertmanager/my.yml   # operator-file mode, skips rendering
      # data_dir: /srv/alertmanager-data      # optional, overrides the default location

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

pg addon list
Web add-ons (alertmanager):
  alertmanager (name: am1)
    Status:      running
    URL:         http://0.0.0.0:9093/
    Config:      /home/you/.pgcli/addon/alertmanager/am1/alertmanager.yml [rendered (2 webhook)]
    Data:        /home/you/.pgcli/addon/alertmanager/am1/data
    Image:       docker.io/prom/alertmanager:v0.34.1
    Container:   pgcli-alertmanager-am1

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:

pg addon stop alertmanager
pg addon start alertmanager

Auto-start on Boot

To have an Alertmanager instance started automatically on host boot, enable it:

pg autostart enable --alertmanager --name am1

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

pg addon remove alertmanager              # keeps silences + notification state — they survive a reinstall
pg addon remove alertmanager --clean-data # deletes them too

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

pg logs addon alertmanager            # show recent console output
pg logs addon alertmanager -f         # follow
pg logs addon alertmanager -n 200     # last 200 lines (also -i / --tail)

Troubleshooting

  • install fails with “chowning Alertmanager data dir … to 65534:65534” — the image runs as nobody (uid/gid 65534) and never fixes ownership itself, so pgcli chowns the host data dir before creating the container: through podman unshare chown under rootless podman (where the mapped subordinate uid is the only correct target), or a direct recursive chown when pgcli itself runs as root on a rootful host. The manager shells out to the plain podman CLI, so this only fails outside those two cases — e.g. a non-root pg explicitly pointed at someone else’s rootful daemon. Run the install as root, or switch to rootless podman; a manual sudo 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-op receiver: an install with no --webhook-url renders a config that never sends anything. Check the Config: line in pg addon list (it says no-op receiver vs rendered (N webhooks)). If a real webhook is configured, it is not connectivity-probed at install — reachability is only discovered when an alert fires, so check pg logs addon alertmanager for POST failures and confirm the endpoint accepts Alertmanager’s webhook payload.
  • --webhook-url aborted with a parse error — by design: every URL is validated (net/url parse, 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 reload needs the container running; if the new alertmanager.yml is rejected (a route referencing an undefined receiver, bad YAML) Alertmanager keeps serving the last good config and logs the error. Check pg logs addon alertmanager, fix the file, reload again.
  • Port already allocated — the assigned port is held in pg.yaml and skipped for later instances; free it with pg addon remove first, or pick another with --port N.
  • Remote API unreachable / unexpectedly reachable — on the default 0.0.0.0 a 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 a prometheus.yml nor 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-file mode write the alerting: block yourself). An alertmanager-aware prometheus.yml is 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 /-/reload cannot be disabled in the image (pgcli just doesn’t use it). The only protection for a 0.0.0.0 bind 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.yml but does not validate it (there is no amtool check-config gate at install). A bad rendered config is impossible, but a bad --conf-file surfaces 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 (direct chown). 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

Run Gitea — self-hosted Git service — as a managed container, with a no-wizard install and an auto-generated web login.

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-net bridge 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

pg addon install gitea

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:

✓ Gitea installed: "gitea"
  Container:  pgcli-gitea-gitea
  Image:      docker.io/gitea/gitea:28.1.0
  Data:       /home/you/.pgcli/addon/gitea/gitea/data
  Web URL:    http://localhost:3080/
  Git SSH:    disabled (HTTP clone/push only; enable with --ssh-port N --force)

  Login user:     gitea
  Login password: <auto-generated>

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:

git clone http://127.0.0.1:3080/<owner>/<repo>.git

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:

pg addon install gitea --name git1 --dsn "postgres://gitea:secret@127.0.0.1:35432/gitea"

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=true so Gitea neither writes the git user’s authorized_keys nor advertises ssh:// clone URLs. The s6 sshd is 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=false makes Gitea populate the git user’s authorized_keys (which sshd reads via AuthorizedKeysFile pointed into /data/gitea/ssh/); Gitea’s own built-in Go SSH listener is pinned off with START_SSH_LISTENER=false so it does not double-bind the same port in the same netns; and SSH_DOMAIN / SSH_PORT render the ssh://git@<host>:N/<owner>/<repo>.git clone URL.
pg addon install gitea --name git1 --ssh-port 2222   # explicit, or omit for pool

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.

# 1. a self-signed leaf whose SAN covers the host you clone by (IP and/or DNS)
pg cert --host 10.0.0.9,git.internal \
        --cert-file /srv/gitea.crt --key-file /srv/gitea.key

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”):

worker_processes auto;
pid /tmp/nginx.pid;

events {
    worker_connections 1024;
}

http {
    # Lift nginx's request-body cap so git pushes aren't rejected. nginx's
    # built-in default is 1 MiB — anything larger dies with HTTP 413 before
    # reaching Gitea. 10m covers typical pushes; raise it (or use 0 for no
    # limit) if you commit larger binaries.
    client_max_body_size 10m;
    proxy_read_timeout 300s;
    proxy_send_timeout 300s;

    upstream gitea {
        server 127.0.0.1:3080;    # the Gitea web port pgcli allocated
    }

    server {
        listen 0.0.0.0:80;
        location / {
            return 308 https://$host$request_uri;   # force HTTPS (308 keeps method+body,
        }                                           # so git push survives the redirect)
    }

    server {
        listen 0.0.0.0:443 ssl;
        # pgcli bind-mounts the --tls-cert / --tls-key files at these paths:
        ssl_certificate     /etc/nginx/certs/public.crt;
        ssl_certificate_key /etc/nginx/certs/private.key;
        ssl_protocols TLSv1.2 TLSv1.3;

        location / {
            proxy_pass http://gitea;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;   # what makes the displayed
        }                                                 # clone URL read https://…
    }
}
# 2. install the proxy on that file (recreate anytime with --force)
pg addon install nginx --name proxy --conf-file /srv/gitea-nginx.conf \
  --tls --tls-cert /srv/gitea.crt --tls-key /srv/gitea.key \
  --http-port 80 --https-port 443 --listen 0.0.0.0

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):

git clone https://10.0.0.9/<owner>/<repo>.git        # over the TLS proxy
git -c http.sslCAInfo=/srv/gitea.crt push ...        # trust the self-signed leaf

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.1 so it binds loopback only, and point the proxy’s backend at 127.0.0.1:3080 (same host) — works because both run on the host network.
  • pg addon nginx reload will not pick up an external edit to the mounted nginx.conf (the bind mount pins the original inode, so a sed -i/editor rewrite is invisible until the container restarts). Reload only applies changes pgcli itself wrote. After hand-editing the conf, restart with pg 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 --cacert hand-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:

pg addon install nginx --name proxy --conf-file /srv/gitea-nginx.conf \
  --tls \
  --tls-cert /etc/ssl/git.example.com/fullchain.pem \
  --tls-key  /etc/ssl/git.example.com/privkey.pem \
  --http-port 80 --https-port 443 --listen 0.0.0.0

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

pg addon nginx reload --name proxy

(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:

pg addon password gitea              # bare password on stdout
pg addon password gitea --file ./pw  # or write it to a 0600 file

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.

# a member, generated password (printed once, NOT stored in pg.yaml)
pg addon gitea user create --username alice --email alice@corp.example
# a member, pinned password
pg addon gitea user create --username bob   --password 'B0b-pin-pass'
# a second site admin
pg addon gitea user create --username carol --admin
# list everyone
pg addon gitea user list
# remove an account (must own nothing, or use --purge) ...
pg addon gitea user delete --username bob
# ... or drop everything the account owns
pg addon gitea user delete --username bob --purge
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:

pg addon install gitea --name git2                 # second instance: next free web port
pg addon install gitea --name git3 --port 3100     # explicit port still wins
pg addon install gitea --name git4 --ssh-port 0    # SSH disabled: parked sshd keeps its pool port

The two pools’ bases (3080 / 2222) are disjoint from every other addon’s pool.

Configuration

addons:
  gitea:
    git1:
      name: git1
      image_tag: docker.io/gitea/gitea:28.1.0
      host_port: 3080
      listen: 0.0.0.0
      admin_user: gitea
      password: <generated>
      ssh: true
      ssh_port: 2222
      dsn: postgres://gitea:secret@127.0.0.1:35432/gitea
      autostart: false
      # data_dir: /srv/gitea-data    # optional, overrides the default location

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

pg addon list
Web add-ons (gitea):
  gitea (name: git1)
    Status:      running
    Web URL:     http://localhost:3080/
    Git SSH:     ssh://git@localhost:2222/
    Database:    external PostgreSQL (127.0.0.1:35432/gitea)
    Login user:  gitea
    Data:        /home/you/.pgcli/addon/gitea/git1/data
    Image:       docker.io/gitea/gitea:28.1.0
    Container:   pgcli-gitea-git1

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:

pg addon stop gitea
pg addon start gitea

Auto-start on Boot

To have a Gitea instance started automatically on host boot, enable it:

pg autostart enable --gitea --name git1

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

pg addon remove gitea              # keeps the data dir — repos and login survive a reinstall
pg addon remove gitea --clean-data # deletes the data dir too (repositories are gone)

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

pg logs addon gitea            # show recent console output
pg logs addon gitea -f         # follow
pg logs addon gitea -n 200     # last 200 lines (also -i / --tail)

Troubleshooting

  • install fails 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: through podman unshare chown under rootless podman, or a direct recursive chown when pgcli itself runs as root on a rootful host. The manager shells out to the plain podman CLI, so this only fails outside those two cases — e.g. a non-root pg explicitly pointed at someone else’s rootful daemon. Run the install as root, or switch to rootless podman; a manual sudo 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 /data or --dsn database) use gitea admin user change-password inside the container, or wipe and start over (--clean-data, and drop the external DB by hand in dsn mode).
  • Clone URLs show localhost even though Gitea is on a server — a wildcard --listen 0.0.0.0 collapses to localhost in displayed URLs because 0.0.0.0 is 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 keeps SSH_PORT (what URLs show), the Gitea-side SSH_LISTEN_PORT, and the bare SSH_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 bare SSH_LISTEN_PORT=<pool port> for every instance, SSH on or off.
  • git push over 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.yaml and skipped for later instances; free them with pg addon remove first, 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 -rootless tag 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-file mode (the template’s 1 MiB client_max_body_size default breaks larger git push). Gitea rewrites the on-page clone URL from X-Forwarded-Proto + Host, so behind nginx’s default proxy headers the copy button reads https://<proxy>/… correctly. pgcli renders ROOT_URL as http://<host>:<web-port>/ and v1 exposes no knob to change that — the only visible effect so far is anything Gitea generates from ROOT_URL directly (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

Run VictoriaMetrics, a fast Prometheus-compatible time-series database, as a managed container.

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.0 default 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.1 for 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-net bridge 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

pg addon install victoriametrics

The command pulls the image if needed, creates the container, and prints the URL, retention, and any extra args:

✓ VictoriaMetrics installed: "vm"
  Container:  pgcli-victoriametrics-default-vm
  Image:      docker.io/victoriametrics/victoria-metrics:v1.154.1
  Data:       /home/you/.pgcli/addon/victoriametrics/vm/data
  Retention:  1 month(s)
  URL:        http://0.0.0.0:8428/

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.

# custom retention and search limits
pg addon install victoriametrics --name vm1 \
  --retention-months 12 \
  --extra-arg -search.maxUniqueTimeseries=1000000

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):

pg addon install victoriametrics --name vm1 \
  --extra-arg -search.maxUniqueTimeseries=1000000 \
  --extra-arg -selfScrapeInterval=10s

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:

podman logs pgcli-victoriametrics-default-vm1 | grep -E '(maxUniqueTimeseries|selfScrapeInterval)'
# 2026-10-22T...  -search.maxUniqueTimeseries="1000000"
# 2026-10-22T...  -selfScrapeInterval="10s"

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.

pg addon install victoriametrics --name vm2          # second instance: next free port (8429 if 8428 taken)
pg addon install victoriametrics --name vm3 --port 8500  # explicit port still takes precedence

Configuration

addons:
  victoriametrics:
    vm1:
      name: vm1
      image_tag: docker.io/victoriametrics/victoria-metrics:v1.154.1
      host_port: 8428
      listen: 0.0.0.0
      retention: "1"
      extra_args:
        - -search.maxUniqueTimeseries=1000000
        - -selfScrapeInterval=10s
      autostart: false
      # data_dir: /srv/victoriametrics-data      # optional, overrides the default location

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

pg addon list
Web add-ons (victoriametrics):
  victoriametrics (name: vm1)
    Status:      running
    URL:         http://0.0.0.0:8428/
    Retention:   1 month(s)
    Extra args:  -search.maxUniqueTimeseries=1000000 -selfScrapeInterval=10s
    Data:        /home/you/.pgcli/addon/victoriametrics/vm1/data
    Image:       docker.io/victoriametrics/victoria-metrics:v1.154.1
    Container:   pgcli-victoriametrics-default-vm1

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:

pg addon stop victoriametrics
pg addon start victoriametrics

Auto-start on Boot

To have a VictoriaMetrics instance started automatically on host boot, enable it:

pg autostart enable --victoriametrics --name vm1

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

pg addon remove victoriametrics              # keeps the TSDB — stored time series survive a reinstall
pg addon remove victoriametrics --clean-data # deletes the TSDB too

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

pg logs addon victoriametrics            # show recent console output
pg logs addon victoriametrics -f         # follow
pg logs addon victoriametrics -n 200     # last 200 lines (also -i / --tail)

Troubleshooting

  • A query returns no data — confirm VictoriaMetrics is running (pg addon list shows running), the health endpoint responds (curl -s http://<host>:<port>/-/healthy prints VictoriaMetrics is Healthy.), and you’re querying the correct time range (use query_range with explicit start/end/step). Check pg logs addon victoriametrics for ingestion errors.
  • Install fails with “container already exists” — the container name is derived from --name and the namespace; use --force to recreate, or pg addon remove first to clean up a partial install.
  • Extra args didn’t take effect — --extra-arg is applied at container creation time, not runtime. Reinstall with --force to recreate the container with the new flags. Check podman logs <container> to confirm the flags appear in the startup log.
  • Port already allocated — the assigned port is held in pg.yaml and skipped for later instances; free it with pg addon remove first, or pick another with --port N.
  • Remote API unreachable / unexpectedly reachable — on the default 0.0.0.0 a 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 use vmagent separately) 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.0 bind is the network boundary. This is the same posture as Prometheus — do not expose it on an untrusted network.
  • pgcli does not validate --extra-arg values (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

Run a BeeGFS parallel filesystem as a managed addon.

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 beegfs is refused there with a clear error.

The client is out of scope, on purpose. Mounting a BeeGFS filesystem needs the beegfs-client kernel 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-hint prints the exact host-side config a machine with the beegfs-client + beegfs-utils packages 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

pg addon install beegfs              # default filesystem named "beegfs"
pg addon install beegfs --name fs1
pg addon install beegfs --listen 10.0.0.5   # advertise a reachable mgmtd address

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 --force to recreate, or pg 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) → EnsureCluster recreates 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:

# vm01 — management + metadata on this host; conn-auth generated here:
pg addon install beegfs --name fs1 --roles mgmtd,meta --listen 192.0.2.10

# render the verbatim join commands (secret included) from vm01:
pg addon beegfs join-hint --name fs1

# vm02 — two data disks, one storage target each:
pg addon install beegfs --name fs1 --roles storage --mgmtd-host 192.0.2.10 \
    --node-id 2 --password <conn-auth> \
    --storage-target /mnt/d1 --storage-target /mnt/d2

# vm03 — another storage node (or --roles meta to move metadata off vm01):
pg addon install beegfs --name fs1 --roles storage --mgmtd-host 192.0.2.10 \
    --node-id 3 --password <conn-auth> --storage-target /mnt/d3

Clients mount from vm01’s address as usual — cross-host changes nothing about the kernel-module client.

Rules and boundaries, honestly stated:

  • --mgmtd-host is required whenever --roles excludes 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 to pg addon password beegfs, not after a registration timeout.
  • --node-id must be unique per service type across hosts (node IDs are per-type namespaces: meta_1 and storage_1 coexist). It seeds the storage target IDs: node 2’s targets are 201, 202, … (target IDs derive as node_id*100 + ordinal, capped at node_id 655).
  • Each pg.yaml entry stores only what its own host runs; check, start/stop, list and 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-hint says 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>:

<base>/addon/beegfs/<name>/mgmtd     mgmtd.sqlite — the node/target registry
<base>/addon/beegfs/<name>/meta      the metadata target
<base>/addon/beegfs/<name>/storage   the storage target (the actual file data)

--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-size tune. 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-target at it.
    # two disks → a mirror vdev (ZFS's RAID1; capacity = one disk, survives
    # either disk dying). raidz1/2 need 3+/4+ disks.
    zpool create bgtank mirror /dev/disk1 /dev/disk2
    zfs create -o recordsize=1M -o compression=lz4 bgtank/t1
    # ...then:  pg addon install beegfs --roles storage ... \
    #              --storage-target /bgtank/t1
    (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/mgmtd services 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:

mgmtd   8008  BeeMsg — meta/storage register here   (movable: --beemsg-port)
mgmtd   8010  gRPC   — the `beegfs` CLI dials here  (movable: --grpc-port)
meta    8005  metadata service                      (FIXED)
storage 8003  storage service                       (FIXED)

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:

# 1. the BeeGFS signing key (fingerprint 0683 AF2B 9AC5 BC7D 59B2 D623 40CE 4E24 3CD5 0804)
wget https://www.beegfs.io/release/beegfs_8.4/gpg/GPG-KEY-beegfs -O /etc/apt/trusted.gpg.d/beegfs.asc
# 2. the repo file (bookworm = Debian 12; use beegfs-trixie.list for Debian 13,
#    beegfs-jammy/noble.list for Ubuntu, …)
wget https://www.beegfs.io/release/beegfs_8.4/dists/beegfs-bookworm.list -O /etc/apt/sources.list.d/beegfs.list
apt update
# 3. client + tools; the module is DKMS-built against the kernel headers
apt install beegfs-client beegfs-utils beegfs-tools linux-headers-$(uname -r)

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:

pg addon beegfs mount-hint --name fs1
1. /etc/beegfs/conn.auth (mode 0600) — the SAME secret as the servers
2. /etc/beegfs/beegfs-client.conf — set in the main section:
     sysMgmtdHost=<mgmtd-host>          # + connMgmtdPort=<port> only if mgmtd's BeeMsg port was moved
3. /etc/beegfs/beegfs-mounts.conf (one mount per line, fields:
   <mountpoint> <client-conf> <fstype>):
     /mnt/beegfs /etc/beegfs/beegfs-client.conf beegfs

conn.auth gotcha: the client’s connection-auth file must end in a newline. echo <secret> > /etc/beegfs/conn.auth works; printf <secret> > … (no trailing newline) is rejected with tried to authenticate stream with wrong secret — the trailing byte is part of what both sides compare.

Then mount and verify:

systemctl restart beegfs-client            # reads beegfs-mounts.conf and mounts
df -h /mnt/beegfs
echo hi > /mnt/beegfs/t && cat /mnt/beegfs/t   # end-to-end write/read

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):

beegfs --tls-disable --mgmtd-addr=<mgmtd-host>:8010 node list

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):

B="beegfs --tls-disable --mgmtd-addr=<mgmtd-host>:8010"

$B node list                          # every node: mg / meta / storage / client
$B target list                        # storage targets, their node and state
$B entry info /mnt/beegfs/big.bin     # one file's stripe pattern and the
                                      # targets its chunks landed on
$B entry set /mnt/beegfs/data --num-targets 3 --chunk-size 4Mi
                                      # new files under data/ stripe across 3
                                      # targets, 4 MiB chunks (dirs set the
                                      # pattern for what is created after)
$B entry set /mnt/beegfs/data --num-targets 0   # back to the cluster default
$B health check                       # cluster-wide health report
$B stats server                       # live IO counters per server (8.x stats)

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

pg addon beegfs check             # default name
pg addon beegfs check --name fs1

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 password beegfs             # the "beegfs" filesystem
pg addon password beegfs --name fs1

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

pg addon beegfs license                     # what does mgmtd think of the cert?
pg addon beegfs license --name fs1 --set /path/license.pem   # load/replace, reloads mgmtd
pg addon beegfs license --name fs1 --clear                    # drop it again

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

pg addon 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:

pg addon stop beegfs     # storage → meta → mgmtd (reverse order, targets stay GOOD)
pg addon start beegfs    # mgmtd → meta → storage

A cross-host member runs only its own subset (a --roles storage host starts and stops just storage).

Auto-start on Boot

pg autostart enable --beegfs --name fs1

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

pg addon remove beegfs                # keeps the data dirs — reinstall revives the filesystem
pg addon remove beegfs --clean-data   # deletes them too (the data is gone)

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):

pg logs addon beegfs                       # mgmtd (default)
pg logs addon beegfs --service meta -f     # follow the metadata daemon
pg logs addon beegfs --service storage -n 200

--service is only valid for beegfs (a value like meta against another addon type is rejected).

Troubleshooting

  • install beegfs --name fs2 refuses 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-hint renders the commands), or pg addon remove the first one.
  • meta/storage stuck “not registered” in check — mgmtd must be serving first; if mgmtd’s container died, both block on registration until beegfsSetupTimeout (60 s) fails the install. Check pg logs addon beegfs --service mgmtd for why mgmtd exited (port already in use on the host, or an un-writable data dir).
  • install refuses on the meta_port/storage_port config check — a non-default value was hand-edited into pg.yaml. The daemons cannot bind those, so remove the pinned values and let them fall back to 8005/8003.
  • Client cannot mount / beegfs CLI says TLS error — the cluster runs with mgmtd TLS disabled; every client and every beegfs CLI call needs --tls-disable too, and /etc/beegfs/conn.auth must be the exact ConnAuth from pg addon password beegfs.
  • A join host fails fast with “wrong secret” — the --password passed to its install is not the cluster’s connection-auth value (BeeGFS logs tried to authenticate stream with wrong secret). The install aborts in seconds rather than timing out; re-run with the value from pg addon password beegfs on the mgmtd host.
  • pg addon beegfs license prints a verification error from mgmtd — the stored PEM is not valid for this system (certificates are system-bound — request one with beegfs license --get on the mgmtd host) or has expired. The container still runs and community functionality is unaffected; mgmtd simply behaves as unlicensed. --clear (or --set with the right PEM) fixes the config side.
  • Clone/mount to localhost fails from another machine — a wildcard --listen 0.0.0.0 renders sysMgmtdHost as a placeholder in mount-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; the beegfs gRPC 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 --get prints the system-bound URL) to the mgmtd container, and pg addon beegfs license shows/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 — see join-hint), and every host’s pg.yaml describes 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

Run VictoriaMetrics cluster edition — three components (vminsert + vmselect + vmstorage) as managed containers with cross-host support and horizontal scaling.

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.1 for 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:

pg addon install vmcluster --name vmc
# 1 host, 3 containers (vminsert + vmselect + vmstorage)
# --storage-node auto-derived as 127.0.0.1:<internalPorts>

Model 2 — Cross-host distribution

# Host A — storage node 1
pg addon install vmcluster --name vmc --roles storage --listen 10.0.0.1

# Host B — storage node 2
pg addon install vmcluster --name vmc --roles storage --listen 10.0.0.2

# Host C — ingestion + query layer
pg addon install vmcluster --name vmc --roles insert,select \
  --storage-node 10.0.0.1:8483:8484 \
  --storage-node 10.0.0.2:8483:8484

Model 3 — Horizontal scaling with replication

# replicationFactor=2 → each write goes to 2 vmstorage nodes
pg addon install vmcluster --name vmc --roles insert,select \
  --replication-factor 2 \
  --storage-node 10.0.0.1:8483:8484 \
  --storage-node 10.0.0.2:8483:8484

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

pg addon install vmcluster --name vmc

The command pulls the three images if needed, creates the containers in dependency order (storage → insert → select), and prints the summary:

✓ VMCluster installed: "vmc"
  Roles:       storage, insert, select (converged on this host)
  Containers:  pgcli-vmcluster-default-vmc-storage, pgcli-vmcluster-default-vmc-insert, pgcli-vmcluster-default-vmc-select
  Images:      docker.io/victoriametrics/vmstorage:v1.154.1-cluster, docker.io/victoriametrics/vminsert:v1.154.1-cluster, docker.io/victoriametrics/vmselect:v1.154.1-cluster
  Data:        /home/you/.pgcli/addon/vmcluster/vmc/data
  Retention:   1 month(s)
  Insert URL:  http://0.0.0.0:8480/ (vminsert — ingestion)
  Select URL:  http://0.0.0.0:8481/ (vmselect — queries)
  Storage URL: http://0.0.0.0:8482/ (vmstorage — admin/metrics)
  Internal:    TCP 8483 (vminsert), 8484 (vmselect)

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:

# Prometheus remote_write
curl -X POST -d 'test_metric{job="pgcli"} 42' \
  http://127.0.0.1:8480/insert/0/prometheus/api/v1/import/prometheus

# InfluxDB line protocol
curl -X POST -d 'cpu,host=vm01 value=42' \
  http://127.0.0.1:8480/insert/0/influx/write

# Graphite plaintext
echo "test.metric 42 $(date +%s)" | nc 127.0.0.1 8480

Query via vmselect:

# Prometheus query API
curl 'http://127.0.0.1:8481/select/0/prometheus/api/v1/query?query=test_metric'

# Range query
curl 'http://127.0.0.1:8481/select/0/prometheus/api/v1/query_range?query=test_metric&start=2024-01-01T00:00:00Z&end=2024-01-02T00:00:00Z&step=1h'

# Export
curl 'http://127.0.0.1:8481/select/0/prometheus/api/v1/export?match[]={__name__!=""}'

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):

  • +0 InsertPort (vminsert HTTP — ingestion endpoint)
  • +1 SelectPort (vmselect HTTP — query endpoint)
  • +2 StoragePort (vmstorage HTTP — admin/metrics endpoint)
  • +3 InternalInsertPort (vmstorage TCP — vminsert dials this)
  • +4 InternalSelectPort (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

addons:
  vmcluster:
    vmc:
      name: vmc
      container_prefix: pgcli-vmcluster-default-vmc
      insert_image_tag: docker.io/victoriametrics/vminsert:v1.154.1-cluster
      select_image_tag: docker.io/victoriametrics/vmselect:v1.154.1-cluster
      storage_image_tag: docker.io/victoriametrics/vmstorage:v1.154.1-cluster
      listen: 0.0.0.0
      insert_port: 8480
      select_port: 8481
      storage_port: 8482
      internal_insert_port: 8483
      internal_select_port: 8484
      roles:
        - storage
        - insert
        - select
      storage_nodes:
        - address: "10.0.0.1:8483"
          select_port: 8484
        - address: "10.0.0.2:8483"
          select_port: 8484
      replication_factor: 1
      retention: "1"
      autostart: false

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

  1. Create the new vmstorage on the target host:
# Host C — new storage node
pg addon install vmcluster --name vmc --roles storage --listen 10.0.0.3
  1. Recreate vminsert + vmselect on the access-layer host, appending the new --storage-node:
pg addon install vmcluster --name vmc --roles insert,select --force \
  --storage-node 10.0.0.1:8483:8484 \
  --storage-node 10.0.0.2:8483:8484 \
  --storage-node 10.0.0.3:8483:8484

--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

pg addon stop vmcluster
pg addon start vmcluster

Containers run with podman’s unless-stopped restart policy, so they come back automatically after a host reboot.

Autostart on boot

pg autostart enable --vmcluster --name vmc

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

pg addon remove vmcluster              # keeps vmstorage data — reinstall revives time series
pg addon remove vmcluster --clean-data # deletes vmstorage data too

Only vmstorage has persistent data; vminsert and vmselect are stateless and leave nothing behind.

Logs

pg logs addon vmcluster                # vmstorage logs (default service)
pg logs addon vmcluster --service insert   # vminsert logs
pg logs addon vmcluster --service select   # vmselect logs

Known limitations

  • No authentication. The cluster edition’s API runs arbitrary MetricsQL; the only protection for a 0.0.0.0 bind 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-arg values. A bad flag surfaces only in the container logs on startup.

12.22 - JuiceFS

Mount an S3-backed POSIX filesystem as a managed addon (host binary).

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 inside juicefs 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) accepting postgres://, redis:// or sqlite3://. 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 mc wrapper — the install prints the exact two lines:
pg mc alias set externals3 http://minio.example.com:9000 AK '***'
pg mc mb externals3/juicefs-data
pg addon install juicefs --name data \
    --s3-endpoint http://minio.example.com:9000 \
    --access-key AK --secret-key SK \
    --bucket juicefs-data \
    --dsn "sqlite3:///var/lib/juicefs/data.db"

Any engine works the same way — pick the URL:

# PostgreSQL: the database must already exist (pgcli never creates or drops
# it); juicefs builds its own jfs_* tables inside.
--dsn "postgres://jfs:pass@meta-host:5432/jfs?sslmode=disable"

# Redis (+TLS): the /N suffix selects the logical database.
--dsn "redis://:pass@redis-host:6379/0"
--dsn "rediss://user:pass@redis-host:6380/1"

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:

mkdir -p /mnt/cloudfs
pg addon install juicefs --name data \
    --s3-endpoint https://s3.amazonaws.com \
    --access-key AK --secret-key SK \
    --dsn "postgres://jfs:pass@meta-host:5432/jfs?sslmode=disable" \
    --bucket juicefs-data --mount-point /mnt/cloudfs

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):

# PostgreSQL metadata: create the instance, then the database (pgcli's
# install path never creates or drops it). `pg status` prints the admin
# connection URL — swap its /postgres path for the new database.
pg create meta-pg
pg exec meta-pg -c "CREATE DATABASE jfs"
pg status meta-pg

pg addon install juicefs --name data \
    --s3-endpoint http://minio-host:9000 --access-key AK --secret-key SK \
    --bucket juicefs-data \
    --dsn "postgres://admin:PASS@127.0.0.1:35432/jfs?sslmode=disable"
# Redis metadata (a pgcli redis addon; `pg addon password redis` returns the
# generated password bare — paste it in, and /N picks the logical database):
pg addon install redis --name mred
pg addon password redis --name mred
pg addon install juicefs --name r1 \
    --s3-endpoint http://minio-host:9000 --access-key AK --secret-key SK \
    --bucket juicefs-r1 --dsn "redis://:PASS@127.0.0.1:6379/1"
# MinIO as the object store (a pgcli minio addon — or silo/rustfs/garage/
# seaweedfs; create the bucket by hand, pgcli never does; the root user is
# admin unless --root-user said otherwise, and `pg addon password minio`
# returns the secret key bare):
pg addon install minio --name ms3
pg mc mb ms3/juicefs-data
pg addon install juicefs --name m1 \
    --s3-endpoint http://127.0.0.1:9000 --bucket juicefs-data \
    --access-key admin --secret-key "$(pg addon password minio --name ms3)" \
    --dsn "sqlite3:///var/lib/juicefs/m1.db"

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:

pg addon install minio --name ms3 --listen 0.0.0.0   # --force if already installed
# from the other host:
pg addon install juicefs --name m1 --s3-endpoint http://10.0.0.5:9000 …

(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

pg addon juicefs status --name data      # endpoint, bucket, meta URL, binary, mount state
pg addon juicefs umount --name data
pg addon juicefs mount --name data       # juicefs mount -d self-daemonizes
pg addon juicefs mount --name data --mount-point /mnt/cloudfs
                       # mount at a custom path (mkdir -p it first); while
                       # unmounted this also PERMANENTLY relocates the volume
pg addon start juicefs --name data       # top-level aliases of mount/umount
pg addon stop  juicefs --name data
pg logs addon juicefs --name data -f     # tail the mount log
pg addon password juicefs --name data    # the stored S3 secret key
pg addon remove juicefs --name data      # unmount + forget; bucket, metadata, binary stay
pg addon remove juicefs --name data --clean-data   # also deletes the addon host dir

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):

pg addon juicefs umount --name data
mkdir -p /srv/cloudfs
pg addon juicefs mount --name data --mount-point /srv/cloudfs
#   [i] mount point moved to /srv/cloudfs (saved)
# ✓ juicefs "data" mounted at /srv/cloudfs

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

pg autostart enable --juicefs --name data

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

Run VictoriaLogs, the log database of the VictoriaMetrics family, as a managed container.

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.1 for 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-net bridge 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

pg addon install victorialogs

The command pulls the image if needed, creates the container, and prints the URL and retention:

✓ VictoriaLogs installed: "vl"
  Container:  pgcli-victorialogs-default-vl
  Image:      docker.io/victoriametrics/victoria-logs:v1.53.0
  Data:       /home/you/.pgcli/addon/victorialogs/vl/data
  Retention:  binary default (--retention to change)
  URL:        http://0.0.0.0:9428/

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:

# ingest a JSON Lines log entry (the JSON content type is REQUIRED —
# without it VictoriaLogs answers 200 but silently drops the payload)
echo '{"_msg":"hello from pgcli","app":"demo"}' | \
  curl -s -X POST -H 'Content-Type: application/json' \
    http://127.0.0.1:9428/insert/jsonline --data-binary @-

# query with LogsQL
curl -s http://127.0.0.1:9428/select/logsql/query \
  --data-urlencode 'query=app:demo' --data-urlencode 'limit=10'

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:

http://<host>:<port>/select/vmui/

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/query response), and Live (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):

curl -s -X POST -H 'Content-Type: application/json' \
  -d '{"_msg":"hello from pgcli","app":"demo"}' \
  http://127.0.0.1:9428/insert/jsonline

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/.

curl -s http://127.0.0.1:9428/select/logsql/query \
  --data-urlencode 'query=_time:15m level:error' \
  --data-urlencode 'limit=20'

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:word matches the word within that field; field:=value is an exact match and field:~"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, top and 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:

# Relay-only: accept HTTP pushes on :9429 and forward them to VictoriaLogs.
pg addon install vlagent \
  --remote-write-url http://127.0.0.1:9428/insert/native

# Track host files: /var/log/nginx is mounted read-only into the container at
# the same path; --include/--exclude use vlagent's native glob syntax (NOT regex).
pg addon install vlagent \
  --log-path /var/log/nginx \
  --include '/var/log/nginx/**/*.log' \
  --exclude '/var/log/nginx/*.gz' \
  --remote-write-url http://127.0.0.1:9428/insert/native

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-url for 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 -kubernetesCollector and mount the host’s /var/log (plus /var/lib with the Docker runtime so runtime symlinks resolve) via --log-path. It follows /var/log/pods and /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 default hostname and file become 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.

# JSON Lines with explicit field mapping
curl -s -X POST -H 'Content-Type: application/json' \
  --data-binary @app.jsonl \
  'http://127.0.0.1:9428/insert/jsonline?_stream_fields=host,app&_time_field=@timestamp&_msg_field=message'

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):

pg addon install victorialogs --name vl1 \
  --extra-arg -search.maxQueryDuration=60s \
  --extra-arg -memory.allowedPercent=80

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):

podman inspect pgcli-victorialogs-default-vl1 --format '{{json .Config.Cmd}}'

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.

pg addon install victorialogs --name vl2          # second instance: next free port (9429 if 9428 taken)
pg addon install victorialogs --name vl3 --port 9500  # explicit port still takes precedence

Configuration

addons:
  victorialogs:
    vl1:
      name: vl1
      image_tag: docker.io/victoriametrics/victoria-logs:v1.53.0
      host_port: 9428
      listen: 0.0.0.0
      retention: "30d"
      extra_args:
        - -search.maxQueryDuration=60s
        - -memory.allowedPercent=80
      # data_dir: /srv/victorialogs-data      # optional, overrides the default location

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

pg addon list
Web add-ons (victorialogs):
  victorialogs (name: vl1)
    Status:      running
    URL:         http://0.0.0.0:9428/
    Retention:   30d
    Extra args:  -search.maxQueryDuration=60s -memory.allowedPercent=80
    Data:        /home/you/.pgcli/addon/victorialogs/vl1/data
    Image:       docker.io/victoriametrics/victoria-logs:v1.53.0
    Container:   pgcli-victorialogs-default-vl1

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:

pg addon stop victorialogs
pg addon start victorialogs

Autostart on boot

pg autostart enable --victorialogs --name vl

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

pg addon remove victorialogs              # keeps the log storage — stored logs survive a reinstall
pg addon remove victorialogs --clean-data # deletes the log storage too

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 /metrics stays 0). Add the header, or use an ingestion client that sets it.
  • A query returns no data — confirm VictoriaLogs is running (pg addon list shows running), 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). Check podman logs <container> for ingestion errors.
  • Install fails with “container already exists” — the container name is derived from --name and the namespace; use --force to recreate, or pg addon remove first to clean up a partial install.
  • Extra args didn’t take effect — --extra-arg is applied at container creation time, not runtime. Reinstall with --force to recreate the container with the new flags. Check podman 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.yaml and skipped for later instances; free it with pg addon remove first, or pick another with --port N.
  • Remote API unreachable / unexpectedly reachable — on the default 0.0.0.0 a 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 vlagent addon 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 (use vlalert separately). 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.0 bind is the network boundary. This is the same posture as VictoriaMetrics — do not expose it on an untrusted network.
  • pgcli does not validate --extra-arg values (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

Run vlagent, the official VictoriaLogs log agent, as a managed container: track any host log path and forward to VictoriaLogs

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.1 for 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-net bridge 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:

pg addon install vlagent \
  --remote-write-url http://127.0.0.1:9428/insert/native

Track a directory of host log files (the common case):

pg addon install vlagent \
  --log-path /var/log/nginx \
  --remote-write-url http://127.0.0.1:9428/insert/native

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.

✓ vlagent installed: "va"
  Container:  pgcli-vlagent-default-va
  Image:      docker.io/victoriametrics/vlagent:v1.53.0
  Data:       /home/you/.pgcli/addon/vlagent/va/data
  Listen:     http://0.0.0.0:9429/ (HTTP ingest: jsonline, ES bulk, Loki, OTLP)
  Remote:     http://127.0.0.1:9428/insert/native
  Mount:      /var/log/nginx (dir, read-only, same path in the container)
  Include:    /var/log/nginx/**/*.log
  Checkpoint: file-checkpoints.json under /var/lib/vlagent

Track a single file, or several paths with explicit globs and excludes:

# one file (default include is the file itself)
pg addon install vlagent --name va-app \
  --log-path /var/log/myapp/app.log \
  --remote-write-url http://127.0.0.1:9428/insert/native

# several paths, glob per path, excluding compressed archives
pg addon install vlagent --name va-all \
  --log-path /var/log/nginx --include '/var/log/nginx/*.log' --exclude '/var/log/nginx/*.gz' \
  --log-path /var/log/redis --include '/var/log/redis/**/*.log' \
  --remote-write-url http://127.0.0.1:9428/insert/native

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:

--include '/var/log/nginx/*.log'              # one level below
--include '/var/log/nginx/**/*.log'           # any depth
--include '/var/log/nginx/{access,error}.log' # alternatives
--include '/var/log/nginx/access.log.[0-9]'   # rotated files

* 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”:

--include '/var/log/*/*.log' --exclude '/var/log/nginx/*' \
--include '/var/log/nginx/*.log' --exclude '' \

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:

pg addon install vlagent --name va-ha \
  --log-path /var/log/myapp \
  --remote-write-url http://10.0.0.1:9428/insert/native \
  --remote-write-url http://10.0.0.2:9428/insert/native

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.

pg addon install vlagent --name va2 \
  --remote-write-url http://127.0.0.1:9428/insert/native   # next free port (9430 when 9429 is taken)

Configuration

addons:
  vlagent:
    va:
      name: va
      image_tag: docker.io/victoriametrics/vlagent:v1.53.0
      host_port: 9429
      listen: 0.0.0.0
      remote_write_urls:
        - http://127.0.0.1:9428/insert/native
      log_paths:
        - /var/log/nginx
      includes:
        - /var/log/nginx/**/*.log
      excludes:
        - /var/log/nginx/*.gz
      extra_args:
        - -fileCollector.refreshInterval=5s
      # data_dir: /srv/vlagent-data        # optional, overrides the default location

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

pg addon list
Log agents (vlagent):
  vlagent (name: va)
    Status:      running
    Listen:      http://0.0.0.0:9429/
    Remote:      http://127.0.0.1:9428/insert/native
    Log paths:   /var/log/nginx
    Includes:    /var/log/nginx/**/*.log
    Excludes:    /var/log/nginx/*.gz
    Data:        /home/you/.pgcli/addon/vlagent/va/data
    Image:       docker.io/victoriametrics/vlagent:v1.53.0
    Container:   pgcli-vlagent-default-va

Start and stop

pg addon stop vlagent
pg addon start vlagent

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

pg autostart enable --vlagent --name va

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

pg addon remove vlagent              # keeps buffers and file checkpoints
pg addon remove vlagent --clean-data # deletes them too

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 (--include must 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 like app.log.1 do 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 — file and hostname are stream fields by default; override with --extra-arg -fileCollector.streamFields=....
  • Extra args didn’t take effect — --extra-arg is applied at container creation time. Reinstall (config changes recreate automatically) or use --force; verify with podman inspect <container> --format '{{json .Config.Cmd}}'.
  • Container cannot read the logs (SELinux hosts) — pgcli mounts log paths with :ro only, deliberately not :z, because relabeling /var/log would modify the host. On enforcing-SELinux systems pass workload-appropriate labeling yourself (e.g. a shared container_log_t label 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.1 or a firewall.
  • pgcli does not validate --extra-arg values; a bad flag surfaces in the container logs at startup.
  • macOS is code-complete but untested; Linux is the validated platform.

12.25 - VLCluster

Run VictoriaLogs cluster edition — vlinsert + vlselect + vlstorage, all from one image, as managed containers with cross-host sharding.

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.1 for 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:

pg addon install vlcluster --name vlc --listen 127.0.0.1
# 1 host, 3 containers (vlstorage + vlinsert + vlselect)
# --storage-node auto-derived as 127.0.0.1:<StoragePort>

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.

# Host A — storage node 1
pg addon install vlcluster --name vlc --roles storage --listen 10.0.0.1

# Host B — storage node 2
pg addon install vlcluster --name vlc --roles storage --listen 10.0.0.2

# Host C — ingestion + query layer
pg addon install vlcluster --name vlc --roles insert,select --listen 10.0.0.3 \
  --storage-node 10.0.0.1:9432 \
  --storage-node 10.0.0.2:9432

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

pg addon install vlcluster --name vlc --listen 127.0.0.1

The command pulls the image if needed, creates the containers in dependency order (storage → insert → select), and prints the summary:

✓ VLCluster installed: "vlc"
  Roles:       storage, insert, select (converged on this host)
  Containers:  pgcli-vlcluster-default-vlc-storage, ...
  Image:       docker.io/victoriametrics/victoria-logs:v1.53.0
  Data:        /home/you/.pgcli/addon/vlcluster/vlc/data
  Retention:   binary default (--retention to change)
  Insert URL:  http://127.0.0.1:9430/ (vlinsert — ingestion)
  Select URL:  http://127.0.0.1:9431/ (vlselect — LogsQL queries)
  Storage URL: http://127.0.0.1:9432/ (vlstorage — internal APIs + admin)

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:

curl -X POST http://127.0.0.1:9430/insert/jsonline \
  -H 'Content-Type: application/json' \
  -d '{"_msg":"hello from vlcluster","app":"demo"}'

Query via vlselect:

curl 'http://127.0.0.1:9431/select/logsql/query?query=app:demo&limit=10'

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):

  • +0 InsertPort (vlinsert HTTP — ingestion endpoint)
  • +1 SelectPort (vlselect HTTP — query endpoint)
  • +2 StoragePort (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

addons:
  vlcluster:
    vlc:
      name: vlc
      container_prefix: pgcli-vlcluster-default-vlc
      image_tag: docker.io/victoriametrics/victoria-logs:v1.53.0
      listen: 0.0.0.0
      insert_port: 9430
      select_port: 9431
      storage_port: 9432
      roles:
        - storage
        - insert
        - select
      storage_nodes:
        - "10.0.0.1:9432"
        - "10.0.0.2:9432"
      retention: 30d
      autostart: false

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):

# New storage host
pg addon install vlcluster --name vlc --roles storage --listen 10.0.0.3

# Access-layer hosts: reinstall with the full node list
pg addon install vlcluster --name vlc --roles insert,select --listen 10.0.0.3 \
  --storage-node 10.0.0.1:9432 \
  --storage-node 10.0.0.2:9432 \
  --storage-node 10.0.0.3:9432

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

pg addon stop vlcluster
pg addon start vlcluster

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

pg autostart enable --vlcluster --name vlc

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

pg addon remove vlcluster              # keeps vlstorage data — reinstall revives stored logs
pg addon remove vlcluster --clean-data # deletes the data directory too

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

pg logs addon vlcluster                # vlstorage logs (default service)
pg logs addon vlcluster --service insert   # vlinsert logs
pg logs addon vlcluster --service select   # vlselect 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-url targets 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.0 bind 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 list reports only the roles configured on the current host; it does not probe remote storage nodes.
  • pgcli does not validate --*-extra-arg values. A bad flag surfaces only in the container logs on startup.

12.26 - ZeroFS

Mount an encrypted, self-maintaining S3 filesystem as a managed addon (host binary).

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:

pg addon install zerofs --name z1 \
    --s3-endpoint http://minio.example.com:9000 \
    --access-key AK --secret-key SK \
    --bucket zerofs-data
  • --s3-endpoint is a bare http(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-key are required, always together.
  • The bucket is never created by pgcli — provision it yourself with the pg mc wrapper; the install prints the exact two lines.
  • ZeroFS signs requests, so --s3-region is honoured (optional — zerofs itself defaults to us-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:

mkdir -p /mnt/cloudfs
pg addon install zerofs --name z1 \
    --s3-endpoint http://minio.example.com:9000 \
    --access-key AK --secret-key SK \
    --bucket zerofs-data --mount-point /mnt/cloudfs

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):

# MinIO as the object store (a pgcli minio addon — or silo/rustfs/garage/
# seaweedfs; create the bucket by hand, pgcli never does; the root user is
# admin unless --root-user said otherwise, and `pg addon password minio`
# returns the secret key bare — safe to inline):
pg addon install minio --name ms3
pg mc mb ms3/zerofs-data

pg addon install zerofs --name z1 \
    --s3-endpoint http://127.0.0.1:9000 --bucket zerofs-data \
    --access-key admin --secret-key "$(pg addon password minio --name ms3)"
# Redis as the fence backend (--conditional-put redis — for stores lacking
# conditional put; pgcli's MinIO needs none, this shows the wiring). `pg
# addon password redis` returns the generated password bare, paste it into
# the URL, /N picks the logical database:
pg addon install redis --name fence
pg addon password redis --name fence

pg addon install zerofs --name z2 \
    --s3-endpoint http://silo.example.com:9000 \
    --access-key AK --secret-key SK --bucket zerofs-fenced \
    --conditional-put redis --redis-dsn "redis://:PASS@127.0.0.1:6379/0"

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:

pg addon install minio --name ms3 --listen 0.0.0.0   # --force if already installed
# from the other host:
pg addon install zerofs --name z1 --s3-endpoint http://10.0.0.5:9000 …

(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 remove DESTROYS your only copy of the password — save it first. remove keeps the bucket but deletes the config entry, and the password lives only in that entry and its config.toml. Without the original password the kept bucket is permanently unreadable — no recovery path exists. Before any remove you 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:

pg addon password zerofs --name z1

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:

pg addon install zerofs --name z1 --encryption-password '<the stored one>'

(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

pg addon zerofs status --name z1     # server process, mount state, files, fence mode
pg addon zerofs umount --name z1     # detach the FUSE client
pg addon zerofs mount  --name z1     # attach it again (server must run)
pg addon zerofs mount  --name z1 --mount-point /mnt/cloudfs
                                     # custom path (mkdir -p it first); while
                                     # unmounted this relocates the volume too
pg addon zerofs stop   --name z1     # unmount + kill server
pg addon zerofs start  --name z1
pg addon start/stop zerofs --name z1 # top-level aliases (start=server, stop=full)
pg logs addon zerofs --name z1 -f                      # the server log
pg logs addon zerofs --name z1 --service mount -f      # the FUSE client log
pg addon remove zerofs --name z1     # stop + forget; bucket + encrypted data stay
                                     # 🔑 save the password FIRST: pg addon password zerofs --name z1
pg addon remove zerofs --name z1 --clean-data   # also deletes the addon host dir

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:

pg addon zerofs umount --name z1
mkdir -p /srv/cloudfs
pg addon zerofs mount --name z1 --mount-point /srv/cloudfs
#   [i] mount point moved to /srv/cloudfs (saved)
# ✓ zerofs "z1" mounted at /srv/cloudfs

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

pg autostart enable --zerofs --name z1

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

Install an embedded OLAP database as a managed host-binary addon.

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

# Default: v1.5.6 LTS, install httpfs extension (parquet/json are bundled)
pg addon install duckdb

# Pin a specific version
pg addon install duckdb --version 2.0.0

# Custom name and data directory
pg addon install duckdb --name analytics --data-dir /srv/duckdb

# Skip extension installation
pg addon install duckdb --no-extensions

# Re-install with different flags (entry already exists)
pg addon install duckdb --version 2.0.0 --force

Once installed, launch the interactive shell or run one-shot queries:

# Interactive shell (data dir is set as the working directory)
pg addon duckdb shell

# One-shot query against a local Parquet file
pg addon duckdb shell -- -c "SELECT * FROM read_parquet('/tmp/data.parquet')"

# Query S3 directly (needs the httpfs extension, installed by default)
duckdb -c "SELECT count(*) FROM read_parquet('s3://bucket/data/*.parquet')"

# Open a persistent database file
duckdb ~/.pgcli/addon/duckdb/duckdb/analytics.duckdb

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

pg addon duckdb status --name duckdb        # binary version, path, data dir
pg addon duckdb shell --name duckdb         # interactive DuckDB session
pg addon duckdb shell -- -c "SELECT 1"      # one-shot query (args after --)
pg addon list                               # duckdb appears under "Analytics add-ons"
pg addon start duckdb                       # no-op (CLI tool, not a daemon)
pg addon stop  duckdb                       # no-op (CLI tool, not a daemon)
pg addon remove duckdb --name duckdb        # forget config; binary and data dir stay
pg addon remove duckdb --name duckdb --clean-data   # also deletes the data directory

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

# Interactive server (stdin must stay open)
duckdb /path/to/db.duckdb -serve

# Background server (redirect stdin to keep alive)
nohup duckdb /path/to/db.duckdb -serve < /dev/zero > /tmp/duckdb-serve.log 2>&1 &

# Or use quack_serve() from SQL
duckdb -c "INSTALL quack; LOAD quack; CALL quack_serve(token='mytoken')"

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:

-- Server: bind to all interfaces on port 9494
CALL quack_serve(
  listen_uri='quack:0.0.0.0:9494',
  allow_other_hostname=true,  -- required for non-localhost binding
  disable_ssl=true,            -- optional, default is HTTPS
  token='mytoken'
);

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 restriction
  • disable_ssl=true — use plain HTTP (default is HTTPS with auto-generated cert)

Clients connect using the server’s IP address:

# Client connecting to remote server
duckdb -c "ATTACH 'quack:192.0.2.10:9494' AS qk (TOKEN 'mytoken'); CONNECT qk; SELECT * FROM t"

Security warning: Binding to 0.0.0.0 with disable_ssl=true exposes 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

# Using -connect flag (v2.0.0+)
duckdb -connect -c "SELECT * FROM t"

# Or via SQL (short form, localhost default)
duckdb -c "ATTACH 'quack:localhost' AS qk (TOKEN 'BAED...'); CONNECT qk; SELECT * FROM t"

# Explicit ip:port form (for remote or non-default port)
duckdb -c "ATTACH 'quack:127.0.0.1:9494' AS qk (TOKEN 'BAED...'); CONNECT qk; SELECT * FROM t"

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/zero or tail -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 duckdb remain 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:

INSTALL 'spatial';       -- geospatial functions
INSTALL 'sqlite';        -- read/write SQLite databases
INSTALL 'mysql';         -- query MySQL directly
LOAD 'httpfs';           -- load an installed extension in the current session

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

-- Single file
SELECT * FROM read_parquet('/tmp/events.parquet');

-- Wildcard: all parquet files in a directory
SELECT * FROM read_parquet('/data/logs/*.parquet');

-- Schema inference + count
SELECT count(*) FROM read_parquet('s3://bucket/year=2026/*.parquet');

-- Specific columns + filter
SELECT user_id, event, ts
FROM read_parquet('/data/events.parquet')
WHERE ts > '2026-01-01'
LIMIT 100;

JSON files

-- Auto-detect schema (handles JSON lines and nested JSON)
SELECT * FROM read_json_auto('/tmp/users.json');

-- Explicit schema with read_json
SELECT * FROM read_json('/tmp/users.json',
    columns = {'id': 'INTEGER', 'name': 'VARCHAR', 'email': 'VARCHAR'},
    format = 'newline_delimited');

-- Nested objects: extract fields with ->>
SELECT
    data->>'user_id' AS user_id,
    data->>'event'   AS event
FROM read_json_auto('/tmp/events.jsonl');

CSV files

-- Auto-detect header and types
SELECT * FROM read_csv_auto('/tmp/sales.csv');

-- Explicit column types (when auto-detection guesses wrong)
SELECT * FROM read_csv('/tmp/sales.csv',
    columns = {'id': 'INTEGER', 'amount': 'DOUBLE', 'date': 'DATE'},
    header = true);

-- TSV or custom delimiter
SELECT * FROM read_csv_auto('/tmp/data.tsv', delim = '\t');

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

-- Configure S3 credentials (v2.0.0+ syntax)
CREATE SECRET (
  TYPE S3,
  ENDPOINT 'minio.example.com:9000',
  KEY_ID 'admin',
  SECRET 'your-secret-key',
  USE_SSL false,
  URL_STYLE 'path'   -- required for MinIO and compatible stores
);

-- Query Parquet directly from S3
SELECT count(*) FROM read_parquet('s3://my-bucket/events/2026/*.parquet');

-- Query CSV from S3
SELECT * FROM read_csv_auto('s3://my-bucket/users.csv') LIMIT 10;

-- Query JSON from S3
SELECT * FROM read_json_auto('s3://my-bucket/config.json');

-- Write Parquet back to S3 (needs write permissions on the bucket)
COPY (SELECT * FROM read_parquet('/tmp/*.parquet') WHERE year = 2026)
  TO 's3://output-bucket/filtered/2026.parquet' (FORMAT parquet);

DuckDB v1.5.x — legacy SET commands

-- Configure S3 credentials in the DuckDB session (v1.5.x only)
SET s3_endpoint = 'minio.example.com:9000';
SET s3_access_key_id = 'admin';
SET s3_secret_access_key = 'your-secret-key';
SET s3_use_ssl = false;   -- true for https endpoints
SET s3_region = 'us-east-1';   -- 'us-east-1' works for MinIO (region is ignored)
SET s3_url_style = 'path';   -- MinIO and compatible stores use path-style

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':

# MinIO
pg addon install minio --name ms3
S3_SECRET=$(pg addon password minio --name ms3)

# or silo (same API surface, shares the port pool with minio)
pg addon install silo --name ms3
S3_SECRET=$(pg addon password silo --name ms3)

# or rustfs (Rust S3-compatible store, Linux only)
pg addon install rustfs --name ms3
S3_SECRET=$(pg addon password rustfs --name ms3)

# Create a bucket via the pg mc wrapper
pg mc alias set ms3 http://127.0.0.1:9000 admin "${S3_SECRET}"
pg mc mb ms3/my-bucket

# Upload test data (optional)
pg mc cp /tmp/data.parquet ms3/my-bucket/data.parquet

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):

# DuckDB v2.0.0+
duckdb -c "
  CREATE SECRET (
    TYPE S3,
    ENDPOINT '127.0.0.1:9000',
    KEY_ID 'admin',
    SECRET '${S3_SECRET}',
    USE_SSL false,
    URL_STYLE 'path'
  );
  SELECT count(*) FROM read_parquet('s3://my-bucket/data/*.parquet');
"

# DuckDB v1.5.x (legacy SET syntax)
duckdb -c "
  SET s3_endpoint = '127.0.0.1:9000';
  SET s3_access_key_id = 'admin';
  SET s3_secret_access_key = '${S3_SECRET}';
  SET s3_use_ssl = false;
  SET s3_url_style = 'path';
  SELECT count(*) FROM read_parquet('s3://my-bucket/data/*.parquet');
"

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:

-- ~/.duckdbrc (DuckDB v2.0.0+)
CREATE SECRET (
  TYPE S3,
  ENDPOINT 'minio.example.com:9000',
  KEY_ID 'admin',
  SECRET 'your-secret-key',
  USE_SSL false,
  URL_STYLE 'path'
);

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_STYLE cannot be set via environment variables. It defaults to virtual-host, which breaks MinIO/silo/rustfs (they require path-style). For these stores, use CREATE SECRET with URL_STYLE 'path' instead of env vars.

# Endpoint and SSL via DUCKDB_S3_*
export DUCKDB_S3_ENDPOINT='127.0.0.1:9000'
export DUCKDB_S3_USE_SSL='false'

# Credentials via AWS_* (standard variables, auto-detected by DuckDB)
export AWS_ACCESS_KEY_ID='admin'
export AWS_SECRET_ACCESS_KEY='your-secret-key'

# URL style must still be set in the session or startup file (v1.5.x)
duckdb -c "SET s3_url_style = 'path'; SELECT * FROM read_parquet('s3://my-bucket/data.parquet');"

# For v2.0.0, URL_STYLE must be set via CREATE SECRET — env vars alone are insufficient
# for MinIO/silo/rustfs (virtual-host style is not supported by these stores)
duckdb -c "
  CREATE SECRET (TYPE S3, ENDPOINT '127.0.0.1:9000', KEY_ID 'admin',
    SECRET 'your-secret-key', USE_SSL false, URL_STYLE 'path');
  SELECT * FROM read_parquet('s3://my-bucket/data.parquet');
"

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

Run node_exporter, the official Prometheus host-metrics exporter, as a managed container.

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 /proc reports 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 :ro only (never :z), so pgcli never relabels the host root filesystem.

There is NO login/password and no TLS. node_exporter just serves /metrics in plain text. Because Prometheus scraping it is usually on a different host, Listen defaults to 0.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.1 for 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

pg addon install nodeexporter

The command pulls the image if needed, creates the container with the three host mounts, and prints the scrape URL:

✓ node_exporter installed: "nodeexporter"
  Container:  pgcli-nodeexporter-default-nodeexporter
  Image:      docker.io/prom/node-exporter:v1.12.1
  Metrics:    http://0.0.0.0:9100/metrics
  Host mounts: /proc, /sys, / (read-only) → /host/proc, /host/sys, /rootfs

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:

curl -s http://127.0.0.1:9100/metrics | grep -E '^node_(memory_MemAvailable_bytes|load1|cpu_seconds_total)'

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):

pg addon install prometheus --name p1 --scrape node-exporter=127.0.0.1:9100
# remote Prometheus → use the host's LAN address instead of 127.0.0.1:
pg addon install prometheus --name p1 --scrape node-exporter=<host-ip>:9100

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:

pg addon install prometheus --name p1 --scrape-append node-exporter=127.0.0.1:9100

(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):

pg addon install nodeexporter --extra-arg --no-collector.cpufreq \
                              --extra-arg --collector.netdev.device-exclude='^veth.*'

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.

pg addon install nodeexporter --name ne2            # second instance: next free port (9101)
pg addon install nodeexporter --name ne3 --port 9200  # explicit port takes precedence

Configuration

addons:
  nodeexporter:
    nodeexporter:
      name: nodeexporter
      image_tag: docker.io/prom/node-exporter:v1.12.1
      host_port: 9100
      listen: 0.0.0.0
      extra_args:
        - --no-collector.cpufreq
      autostart: false

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

pg addon list
Web add-ons (nodeexporter):
  nodeexporter (name: nodeexporter)
    Status:      running
    Metrics:     http://0.0.0.0:9100/metrics
    Extra args:  --no-collector.cpufreq
    Image:       docker.io/prom/node-exporter:v1.12.1
    Container:   pgcli-nodeexporter-default-nodeexporter

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:

pg addon stop nodeexporter
pg addon start nodeexporter

Auto-start on Boot

To bring a node_exporter instance up on host boot:

pg autostart enable --nodeexporter --name nodeexporter

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

pg addon remove nodeexporter   # deletes the container; there is no data to clean

node_exporter keeps nothing on disk, so --clean-data has no effect beyond an informational note.

Logs

pg logs addon nodeexporter            # show recent console output
pg logs addon nodeexporter -f         # follow
pg logs addon nodeexporter -n 200     # last 200 lines (also -i / --tail)

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 run node_exporter by hand you must pass the /proc//sys// read-only mounts and the matching --path.* flags. Confirm with curl -s …/metrics | grep node_uname_info and compare node_memory_MemAvailable_bytes to the host free -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.1 exporter isn’t being scraped from another host.
  • A collector flag didn’t take effect — --extra-arg is applied at container creation. Reinstall with --force to recreate, then check pg logs addon nodeexporter for the flag on the startup line.
  • Port already allocated — the assigned port is held in pg.yaml and skipped for later instances; free it with pg addon remove first, or pick another with --port N.

Known limitations

  • No authentication, no TLS. node_exporter serves plain-text /metrics; the only protection for a 0.0.0.0 bind 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:ro mount 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-arg values; 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

Run InfluxDB 1.x as a managed addon container, with authentication, config overrides, backups and a passthrough influx CLI for local or remote servers

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, and pg 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

pg addon install influxdb --name metrics
✓ InfluxDB "metrics" installed
  Version:   1.13.1
  Image:     ghcr.io/mars-base/pgcli/pgcli-influxdb:1.13.1
  Container: pgcli-influxdb-metrics
  HTTP:      http://0.0.0.0:8086 (auth enabled)
  RPC:       127.0.0.1:8088 (backup/restore, loopback only)
  Admin:     admin
  Password:  <generated>
  Data dir:  /home/you/.pgcli/addon/influxdb/metrics/data
  Config:    /home/you/.pgcli/addon/influxdb/metrics/influxdb.conf

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:

pg addon install influxdb --name metrics2 --data-dir /srv/influx-data --listen 127.0.0.1

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

pg addon password influxdb --name metrics   # print the admin password
pg addon list                               # shows both instances and state

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

# Persist an override at install time
pg addon install influxdb --name metrics --config-option 'data.index-version="tsi1"'

# Change it on a live instance, applying immediately
pg addon influxdb config set --name metrics 'data.cache-max-memory-size=1000000000' --restart
pg addon influxdb config list --name metrics
pg addon influxdb config unset --name metrics data.cache-max-memory-size

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

pg influx --name metrics -- -execute "SHOW DATABASES"
pg influx --name metrics --database demo -- -execute "SELECT * FROM cpu LIMIT 5"
pg influx --name metrics                      # interactive shell

The host, port and credentials are injected automatically.

Remote InfluxDB 1.x

# host/port flags
pg influx --host db.example.com --username admin --ssl -- -execute "SHOW DATABASES"

# DSN (userinfo, database and TLS query options)
pg influx --dsn 'influxdb://admin:secret@db.example.com:8086/demo' -- -execute "SHOW MEASUREMENTS"
pg influx --dsn 'https://admin:secret@db.example.com:8086/demo?unsafe_ssl=true' -- -execute "SHOW DATABASES"

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

pg influx --host db.example.com --database demo -- -import -path /tmp/demo.txt

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:

# DDL
CREATE DATABASE demo

# DML
# CONTEXT-DATABASE: demo
# CONTEXT-RETENTION-POLICY: autogen
cpu,host=server01,region=us-west value=0.64 1434055562000000000
cpu,host=server02,region=us-west value=0.55 1434055562000000001
mem,host=server01 used_percent=42.1,free=123456789i 1434055563000000000

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.

# on any machine that can reach db.example.com
export INFLUX_USERNAME=admin
export INFLUX_PASSWORD=secret

pg influx --host db.example.com --database demo -- -execute "SHOW MEASUREMENTS"
pg influx --host db.example.com --database demo -- -execute "SELECT * FROM cpu LIMIT 10"
pg influx --host db.example.com -- -execute "CREATE DATABASE demo"
pg influx --host db.example.com -- -execute "CREATE RETENTION POLICY rp_30d ON demo DURATION 30d REPLICATION 1 DEFAULT"
pg influx --host db.example.com --database demo          # interactive shell

# HTTPS, and HTTPS with a self-signed certificate
pg influx --host influx.example.com --ssl --database demo -- -execute "SHOW MEASUREMENTS"
pg influx --host influx.example.com --ssl --unsafe-ssl --database demo -- -execute "SHOW MEASUREMENTS"

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:

# on the source host: offline export (the container is stopped for the duration)
pg addon influxdb export --name metrics --database demo
# transfer the file printed by the command (default under <instance dir>/exports/)
# to the target, then import it there

# logged in to the target host: import into the local instance
pg influx --name metrics --database demo -- -import -path /tmp/demo-export.txt

# or from any client that has the file: import into the remote server
pg influx --host target.example.com --database demo -- -import -path /tmp/demo-export.txt

Restores use the loopback-only RPC endpoint, so the backup directory must be present on the target host and restore run there:

# fresh target without a `demo` database: keep the original name
pg addon influxdb restore /tmp/influx-backup --name metrics --database demo

# if the target already has a `demo` database, `influxd restore` refuses to overwrite it;
# restore it under a new name instead
pg addon influxdb restore /tmp/influx-backup --name metrics --database demo --newdb demo_copy

Common operations

Databases

# pgcli convenience wrappers
pg addon influxdb database create demo --name metrics
pg addon influxdb database create demo --retention 30d --shard-duration 1d --rp rp_30d --name metrics
pg addon influxdb database list --name metrics
pg addon influxdb database drop demo --name metrics --yes   # --yes skips the confirmation

# the same via InfluxQL
pg influx --name metrics -- -execute "CREATE DATABASE demo"
pg influx --name metrics -- -execute "SHOW DATABASES"
pg influx --name metrics -- -execute "DROP DATABASE demo"

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.

# pgcli convenience wrappers
pg addon influxdb retention list   --name metrics --database demo
pg addon influxdb retention create --name metrics --database demo --rp rp_7d --duration 7d --default
pg addon influxdb retention alter  --name metrics --database demo --rp rp_7d --duration 14d --default
pg addon influxdb retention drop   --name metrics --database demo --rp rp_7d --yes

Equivalent InfluxQL:

# create a 30-day policy (REPLICATION must be 1 in OSS)
pg influx --name metrics -- \
  -execute "CREATE RETENTION POLICY rp_30d ON demo DURATION 30d REPLICATION 1"

# same, and make it the default for writes/queries without an explicit RP
pg influx --name metrics -- \
  -execute "CREATE RETENTION POLICY rp_30d ON demo DURATION 30d REPLICATION 1 DEFAULT"

# create a new database with a bounded default RP in one statement
pg influx --name metrics -- \
  -execute "CREATE DATABASE demo WITH DURATION 30d REPLICATION 1 SHARD DURATION 1d NAME rp_30d"

# change duration / default later
pg influx --name metrics -- \
  -execute "ALTER RETENTION POLICY rp_30d ON demo DURATION 90d DEFAULT"

# list and drop
pg influx --name metrics -- -execute "SHOW RETENTION POLICIES ON demo"
pg influx --name metrics -- -execute "DROP RETENTION POLICY rp_30d ON demo"

# write to / query a non-default policy explicitly
pg influx --name metrics -- -execute "INSERT INTO demo.rp_30d cpu,host=server01 value=0.64"
pg influx --name metrics -- -execute "SELECT * FROM demo.rp_30d.cpu LIMIT 10"
  • DURATION accepts h, d, w and INF; the minimum is 1h. SHARD DURATION is optional — by default it is derived from DURATION.
  • 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 POLICY deletes 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

pg influx --name metrics --database demo

Type InfluxQL directly (SHOW MEASUREMENTS, SELECT ..., use otherdb, …); exit or Ctrl-D leaves the shell. --database selects the initial database context.

Inspect the schema

# all measurements
pg influx --name metrics --database demo -- -execute "SHOW MEASUREMENTS"
# measurements matching a regex
pg influx --name metrics --database demo -- -execute "SHOW MEASUREMENTS WITH MEASUREMENT =~ /cpu.*/"
# field keys and their types
pg influx --name metrics --database demo -- -execute "SHOW FIELD KEYS FROM cpu"
# tag keys
pg influx --name metrics --database demo -- -execute "SHOW TAG KEYS FROM cpu"
# values of one tag
pg influx --name metrics --database demo -- -execute 'SHOW TAG VALUES FROM cpu WITH KEY = "host"'
# every series (measurement + tag set)
pg influx --name metrics --database demo -- -execute "SHOW SERIES FROM cpu"

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

# newest 10 points, human-readable timestamps
pg influx --name metrics --database demo -- -precision rfc3339 \
  -execute "SELECT * FROM cpu ORDER BY time DESC LIMIT 10"

# last hour
pg influx --name metrics --database demo -- \
  -execute "SELECT * FROM cpu WHERE time > now() - 1h"

# 5-minute averages per host
pg influx --name metrics --database demo -- \
  -execute "SELECT mean(value) FROM cpu WHERE time > now() - 1h GROUP BY time(5m), host"

# row count
pg influx --name metrics --database demo -- -execute "SELECT COUNT(value) FROM cpu"

# JSON output for scripting
pg influx --name metrics --database demo -- -format json -pretty \
  -execute "SELECT * FROM cpu LIMIT 5"

Write and delete

# write a point (line protocol; timestamp optional — server time is used)
pg influx --name metrics --database demo -- \
  -execute "INSERT cpu,host=server01,region=us-west value=0.64"

# delete points older than 30 days (now() is supported)
pg influx --name metrics --database demo -- \
  -execute "DELETE FROM cpu WHERE time < now() - 30d"

# an unqualified DELETE affects every retention policy; prefix the policy to scope it
pg influx --name metrics --database demo -- \
  -execute "DELETE FROM autogen.cpu WHERE time < now() - 30d"

# drop a whole measurement / selected series
pg influx --name metrics --database demo -- -execute "DROP MEASUREMENT cpu"
pg influx --name metrics --database demo -- \
  -execute "DROP SERIES FROM cpu WHERE host = 'server01'"

For bulk writes use pg influx ... -- -import -path FILE — see Export and import.

Users and permissions

pg influx --name metrics -- -execute "SHOW USERS"
pg influx --name metrics -- -execute 'SHOW GRANTS FOR "admin"'
pg influx --name metrics -- -execute 'CREATE USER "readonly" WITH PASSWORD '\''change-me'\'''
pg influx --name metrics -- -execute 'GRANT READ ON demo TO "readonly"'
pg influx --name metrics -- -execute 'REVOKE ALL PRIVILEGES ON demo FROM "readonly"'
pg influx --name metrics -- -execute 'DROP USER "readonly"'

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

pg addon influxdb status --name metrics        # state, endpoints, version, paths
pg logs addon influxdb --name metrics          # last 50 container log lines
pg logs addon influxdb --name metrics -n 200   # last 200 lines
pg logs addon influxdb --name metrics -f       # follow

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).

URL=http://db.example.com:8086
AUTH='admin:secret'     # prefer --netrc-file: a literal -u value is visible in ps

# health check — 204, no auth required
curl -i "$URL/ping"

# run InfluxQL (GET /query)
curl -s -G "$URL/query" \
  --data-urlencode 'db=demo' \
  --data-urlencode 'q=SHOW MEASUREMENTS' \
  -u "$AUTH"

# create a database and a retention policy
curl -s -XPOST "$URL/query" -u "$AUTH" \
  --data-urlencode 'q=CREATE DATABASE demo'
curl -s -XPOST "$URL/query" -u "$AUTH" \
  --data-urlencode 'q=CREATE RETENTION POLICY rp_30d ON demo DURATION 30d REPLICATION 1 DEFAULT'

# write one point (POST /write) — 204 on success
curl -s -o /dev/null -w '%{http_code}\n' -XPOST "$URL/write?db=demo&precision=s" -u "$AUTH" \
  --data-binary 'cpu,host=web01,region=us-east value=0.42 1793069000'

# write many points from a line-protocol file (one point per line)
curl -s -XPOST "$URL/write?db=demo" -u "$AUTH" --data-binary @points.txt

# query with pretty JSON (timestamps stay RFC3339 unless epoch= is given)
curl -s -G "$URL/query" -u "$AUTH" \
  --data-urlencode 'db=demo' \
  --data-urlencode 'q=SELECT * FROM cpu ORDER BY time DESC LIMIT 10' \
  --data-urlencode 'pretty=true'

# the same with Unix-second timestamps
curl -s -G "$URL/query" -u "$AUTH" \
  --data-urlencode 'db=demo' --data-urlencode 'epoch=s' \
  --data-urlencode 'q=SELECT * FROM cpu LIMIT 10'

# drop a database
curl -s -XPOST "$URL/query" -u "$AUTH" --data-urlencode 'q=DROP DATABASE demo'
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 returns 204; malformed points return 400 with a partial write message that names the dropped points.
  • curl -u user:pass exposes the password in the process list. Prefer a mode-0600 netrc file: machine db.example.com login admin password secret, then curl --netrc-file ~/.netrc-influx ....
  • For day-to-day local work pg influx is 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:

pg addon influxdb backup --name metrics                        # whole instance
pg addon influxdb backup --name metrics --database demo
pg addon influxdb backup --name metrics --since 2026-10-01T00:00:00Z
pg addon influxdb backup --name metrics --start 2026-10-01T00:00:00Z --end 2026-10-02T00:00:00Z

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 mc add-on:

pg mc alias set store https://s3.example.com ACCESS_KEY SECRET_KEY   # once
pg mc mb store/influxdb-backups
pg mc cp /path/to/backup-demo store/influxdb-backups/ -- --recursive

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:

# 1. back up the database before replacing it
pg addon influxdb backup --name metrics --database demo --out /path/to/backup-demo

# 2. drop the old database
pg addon influxdb database drop demo --name metrics --yes

# 3. restore it under the original name
pg addon influxdb restore /path/to/backup-demo --name metrics --database demo

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:

pg addon influxdb restore /path/to/backup-demo --name metrics --database demo --newdb demo_copy

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.

pg addon influxdb export --name metrics --database demo
pg addon influxdb export --name metrics --out /tmp/all.txt --compress --no-start
pg addon influxdb export --name metrics --start 2026-10-01T00:00:00Z --end 2026-10-02T00:00:00Z

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 as DURATION 0s and 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:

pg addon influxdb database create demo_copy --name metrics
pg addon influxdb import /tmp/demo.txt --name metrics --database demo_copy
pg addon influxdb import /tmp/demo.txt --name metrics --precision ms

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

pg addon start influxdb --name metrics     # starts the container
pg addon stop influxdb --name metrics
pg addon influxdb status --name metrics    # state, ping, endpoints, version
pg logs addon influxdb --name metrics -f   # container logs
pg addon remove influxdb --name metrics    # removes the container and pg.yaml entry, keeps data
pg addon remove influxdb --name metrics --clean-data   # also deletes the config/backups and data dir

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:

pg autostart enable --influxdb --name metrics
pg autostart status
pg autostart disable --influxdb --name metrics

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.1 and keep it local.
  • RPC is loopback-only. The backup/restore RPC protocol has no authentication; pgcli pins it to 127.0.0.1 and 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 — wire pg addon influxdb backup into 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

# SECTION.KEY=VALUE for a table entry; KEY=VALUE for a top-level entry
pg addon influxdb config set --name metrics 'data.index-version="tsi1"' --restart
pg addon influxdb config set --name metrics 'http.max-row-limit=50000' --restart
pg addon influxdb config unset --name metrics data.index-version

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 as https-client-ca.paths or http.headers.
  • Such settings require editing <base_dir>/addon/influxdb/<name>/influxdb.conf directly and restarting the container (pg addon stop|start influxdb). Ordinary start/stop keeps the file; install --force and any config set/unset re-render it and will drop hand edits.
  • --config-option replaces 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

Run pg_exporter, Pigsty’s declarative PostgreSQL metrics exporter, as a managed container — over pgcli instances or any remote PostgreSQL.

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 from pg.yaml on 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.

--dsn and --admin-dsn never 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-dsn modes: pgcli creates the low-privilege monitor role (a pg_monitor member, CONNECTION LIMIT 5) and hands the container that role’s URL. To create it, pgcli needs admin rights once: for --pg-name the admin credentials are derived automatically; for --admin-dsn you supply an admin URL that runs the single CREATE ROLE/GRANT batch and is never stored in pg.yaml. The generated monitor password is printed once and retrievable via pg addon password pgexporter. In the dial-as-is --dsn mode pgcli creates nothing — it is your job (or a prior --admin-dsn install) 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, Listen defaults to 0.0.0.0; the only protection is the network boundary. Restrict it to your monitoring network, or --listen 127.0.0.1 for 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.1 target). On macOS the container runs on the pgcli-net bridge and cannot reach a local instance’s 127.0.0.1 DSN — there, use --dsn pointed 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):

pg addon install pgexporter --pg-name demo

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:

✓ pg_exporter installed: "pgexporter"
  Container:  pgcli-pgexporter-pgexporter
  Image:      docker.io/pgsty/pg_exporter:1.5.0
  Metrics:    http://0.0.0.0:9630/metrics
  Target:     instance demo
  Monitor as: pgexporter (pg_monitor)

  Monitor password: pgx_3f9a…            # printed ONCE
  (printed once — retrieve it later with `pg addon password pgexporter --name pgexporter`)

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:

pg addon install pgexporter --name ext \
  --admin-dsn "postgres://admin:adminpw@db.example.com:5432/app"

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):

pg addon install pgexporter --name ext \
  --dsn "postgres://pgexporter:existingpw@db.example.com:5432/app"

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:

curl -s http://127.0.0.1:9630/metrics | grep -E '^pg_(up|version_info)'
curl -s http://127.0.0.1:9630/up        # 200 == the target is reachable

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:

pg addon install pgexporter --name pe2 --pg-name demo --monitor-user pgexporter2

Wire it into Prometheus

The exporter is only useful once something scrapes it. A pgcli Prometheus instance does, via a one-line --scrape job:

pg addon install prometheus --name p1 --scrape pg=127.0.0.1:9630
# remote Prometheus → use the host's LAN address instead of 127.0.0.1:
pg addon install prometheus --name p1 --scrape pg=<host-ip>:9630

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:

pg addon install prometheus --name p1 --scrape-append pg=127.0.0.1:9630

(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):

pg addon install pgexporter --pg-name demo --config-file /srv/collectors.yml
# …edit /srv/collectors.yml, then:
pg addon pgexporter reload --name pgexporter

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):

pg addon install pgexporter --pg-name demo \
  --extra-arg --auto-discovery=false \
  --extra-arg --tag=env=prod

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.

pg addon install pgexporter --name pe2 --monitor-user pgexporter2 --pg-name demo   # second instance: next free port (9631)
pg addon install pgexporter --name pe3 --port 9700 --monitor-user pgexporter3 --pg-name demo  # explicit port takes precedence

(Each extra instance on the same target needs its own --monitor-user — see Repeated installs and one monitor user.)

Configuration

addons:
  pgexporter:
    pgexporter:
      name: pgexporter
      image_tag: docker.io/pgsty/pg_exporter:1.5.0
      host_port: 9630
      listen: 0.0.0.0
      target_instance: demo              # local mode
      monitor_user: pgexporter
      monitor_dsn: postgres://pgexporter:…@127.0.0.1:5432/demo?sslmode=disable   # what the container dials
      monitor_password: "…"              # A SECRET — the generated monitor password
      autostart: false
    ext:
      name: ext
      host_port: 9631
      listen: 0.0.0.0
      admin_dsn: postgres://db.example.com:5432/app   # --admin-dsn mode: credential-FREE address only
      monitor_user: pgexporter
      monitor_dsn: postgres://pgexporter:…@db.example.com:5432/app
      monitor_password: "…"

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

pg addon list
Web add-ons (pgexporter):
  pgexporter (name: pgexporter)
    Status:      running
    Metrics:     http://0.0.0.0:9630/metrics
    Target:      instance demo
    Monitor as:  pgexporter (pg_monitor)
    Extra args:  --auto-discovery=false
    Image:       docker.io/pgsty/pg_exporter:1.5.0
    Container:   pgcli-pgexporter-pgexporter

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

pg addon password pgexporter          # print the stored monitor-role 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:

pg addon stop pgexporter
pg addon start pgexporter

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

pg autostart enable --pgexporter --name pgexporter

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 addon remove pgexporter   # deletes the container; there is no data to clean

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

pg logs addon pgexporter            # show recent console output
pg logs addon pgexporter -f         # follow
pg logs addon pgexporter -n 200     # last 200 lines (also -i / --tail)

Troubleshooting

  • pg_up 0 / no pg_* metrics — the monitor role can’t connect. Check /up returns 200, that the target’s pg_hba.conf admits 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 --dsn target, confirm the DSN’s host/port is reachable from the exporter host (a 127.0.0.1 DSN means the target is local to the exporter).
  • pg_up went 0 right after installing a second exporter on the same target — both share the default pgexporter role, 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_monitor yields 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.1 exporter is being scraped from another host.
  • --dsn and --admin-dsn are mutually exclusive — you passed two remote URLs; give exactly one: --admin-dsn to have pgcli create the monitor role (its host/port/database become the target), or --dsn to dial a URL with credentials you already manage.
  • has an admin_dsn target but no stored monitor password — a --force reinstall of an --admin-dsn instance 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-arg and 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.1 PG; use --dsn at a reachable address on Mac.
  • Port already allocated — free it with pg addon remove first, or pick another with --port N.

Known limitations

  • No authentication, no TLS, and admin endpoints exposed. pg_exporter serves plain-text /metrics plus /reload, /explain, /stat; the only protection for a 0.0.0.0 bind 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-privilege pg_monitor role’s credential, not an admin one; the --admin-dsn used to create it is never persisted. Protect pg.yaml accordingly.
  • The official image is scratch-based — it carries no system CA bundle, so a TLS (sslmode=require) --dsn to 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 --dsn mode works there. Linux is the tested platform.
  • pgcli does not validate --extra-arg values; a bad flag surfaces only in the container logs on startup.

12.31 - influx2tsdb

Run the influx2tsdb proxy as a managed container — InfluxDB 1.x HTTP API (Line Protocol + InfluxQL) on top of PostgreSQL + TimescaleDB, with automatic retention and compression jobs

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/DELETE arbitrary 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 timescaledb on startup, but the extension package must be installed in the instance. On a pgcli instance:

    pg extension install timescaledb -i tsdb --auto-restart
  • 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:

pg addon install influx2tsdb --pg-name tsdb
✓ influx2tsdb "influx2tsdb" installed and running
  Version:   1.3.2
  Image:     ghcr.io/mars-base/pgcli/pgcli-influx2tsdb:1.3.2
  Container: pgcli-influx2tsdb-influx2tsdb
  Target:    instance tsdb
  InfluxDB database: tsdb_db
  Endpoint:  http://<host>:8087 (InfluxDB 1.x HTTP API; binds all interfaces, no auth)
  Pool:      10 connections

Remote or unmanaged target — pass the full DSN:

pg addon install influx2tsdb --dsn 'postgres://user:pass@db.example.com:5432/metrics?sslmode=disable' \
  --database metrics --port 8087

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):

pg addon install influx2tsdb --name metrics2 --pg-name tsdb

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; time is the hypertable’s partition column
  • proxy metadata lives in the target’s public schema: _influx_databases, _influx_meta, _retention_policy

Because the data is plain PostgreSQL, you can inspect and join it with pg exec:

pg exec -i tsdb 'SELECT * FROM "tsdb_db"."cpu" ORDER BY time DESC LIMIT 5'
pg exec -i tsdb "SELECT time_bucket('1 minute', time) AS m, avg(value) FROM \"tsdb_db\".\"cpu\" GROUP BY 1 ORDER BY 1 DESC LIMIT 5"

Write and query

The API follows InfluxDB 1.x. Writes return 204; malformed line protocol returns 400.

URL=http://127.0.0.1:8087
DB=tsdb_db

# single point
curl -sS -o /dev/null -w '%{http_code}\n' -XPOST "$URL/write?db=$DB" \
  --data-binary 'cpu,host=web01 value=0.42 1700000000000000000'

# batch
curl -sS -o /dev/null -w '%{http_code}\n' -XPOST "$URL/write?db=$DB" --data-binary '
cpu,host=a value=10.5,ok=true,state="up" 1700000000000000000
cpu,host=b value=20.5,ok=false,state="down" 1700000060000000000'

# InfluxQL
curl -sS -G "$URL/query" --data-urlencode "db=$DB" \
  --data-urlencode 'q=SELECT * FROM "cpu" ORDER BY time DESC LIMIT 5'
curl -sS -G "$URL/query" --data-urlencode "db=$DB" \
  --data-urlencode 'q=SELECT mean("value") FROM "cpu" WHERE time > now() - 1h GROUP BY time(5m), "host"'

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):

CLI="pg influx --host 127.0.0.1 --port 8087"

Databases and retention policies:

$CLI -- -execute "SHOW DATABASES"
$CLI -- -execute "CREATE DATABASE demo"
$CLI --database demo -- -execute \
  "CREATE RETENTION POLICY rp1 ON demo DURATION 1d REPLICATION 1"
$CLI --database demo -- -execute \
  "ALTER RETENTION POLICY rp1 ON demo DURATION 2d DEFAULT"
$CLI --database demo -- -execute "SHOW RETENTION POLICIES ON demo"
# A default policy cannot be dropped directly: set another policy as DEFAULT first
$CLI --database demo -- -execute \
  "ALTER RETENTION POLICY autogen ON demo DURATION INF REPLICATION 1 DEFAULT"
$CLI --database demo -- -execute "DROP RETENTION POLICY rp1 ON demo"
$CLI -- -execute "DROP DATABASE demo"

Writes (line protocol):

$CLI --database tsdb_db -- -execute \
  'INSERT cpu,host=web01 value=42,ok=true 1700000000000000000'
$CLI --database tsdb_db -- -execute \
  'INSERT cpu,host=web02,region=us value=7.5 1700000060000000000'

Queries and metadata:

$CLI --database tsdb_db -- -execute \
  "SELECT * FROM cpu WHERE host = 'web01' AND time >= '2023-11-14 22:13:20'"
$CLI --database tsdb_db -- -execute \
  'SELECT mean(value) FROM cpu GROUP BY time(1m),host'
$CLI --database tsdb_db -- -execute 'SHOW MEASUREMENTS'
$CLI --database tsdb_db -- -execute 'SHOW TAG KEYS FROM cpu'
$CLI --database tsdb_db -- -execute 'SHOW TAG VALUES FROM cpu WITH KEY = host'
$CLI --database tsdb_db -- -execute 'SHOW FIELD KEYS FROM cpu'

Deletes and output formats:

$CLI --database tsdb_db -- -execute "DELETE FROM cpu WHERE host = 'web01'"
$CLI --database tsdb_db -- -execute 'DROP MEASUREMENT cpu'
$CLI --database tsdb_db -- -format json -execute 'SELECT * FROM cpu'
$CLI --database tsdb_db -- -format csv  -execute 'SELECT * FROM cpu'
$CLI --database tsdb_db -- -precision ms -execute 'SELECT * FROM cpu'

Bulk import (influx 1.x import format):

cat >/tmp/influx-import.txt <<'DATA'
# DDL
CREATE DATABASE demo

# DML
# CONTEXT-DATABASE: demo
# CONTEXT-RETENTION-POLICY: autogen
weather,location=us-midwest temperature=82 1700000000000000000
weather,location=us-midwest temperature=83 1700000060000000000
DATA

$CLI -- -import -path /tmp/influx-import.txt

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 KEYS and SHOW TAG VALUES without FROM return missing FROM clause (native InfluxDB makes it optional); WITH KEY =~ /regex/ is not supported either.
  • SHOW SERIES is not supported (unsupported query: SHOW SERIES).
  • A default retention policy cannot be dropped directly (same as native InfluxDB); set another policy as DEFAULT first.
  • -precision ns/u/ms/s/m/h all work; rfc3339 does 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:00 offset), and Grafana’s interpolated $timeFilter epoch-millisecond form (for example 1793083539315ms) worked too:

    curl -sS -G "$URL/query" --data-urlencode "db=$DB" \
      --data-urlencode "q=SELECT * FROM \"cpu\" WHERE time >= '2023-11-14 22:13:20' AND time <= '2023-11-14 22:15:20'"
  • 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 with epoch=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).

GRAFANA_PW=$(pg addon password grafana)   # Grafana admin password
G=http://127.0.0.1:3000

# 1. Create the InfluxDB/InfluxQL data source
curl -sS -u "admin:$GRAFANA_PW" \
  -H 'Content-Type: application/json' \
  -XPOST "$G/api/datasources" -d '{
    "name": "influx2tsdb",
    "type": "influxdb",
    "access": "proxy",
    "url": "http://127.0.0.1:8087",
    "database": "tsdb_db",
    "user": "",
    "password": "",
    "isDefault": true,
    "jsonData": {"httpMode": "GET", "version": "InfluxQL"}
  }'

# Look up the uid by name (or read datasource.uid from the POST response)
UID=$(curl -sS -u "admin:$GRAFANA_PW" \
  "$G/api/datasources/name/influx2tsdb" \
  | python3 -c 'import sys,json; print(json.load(sys.stdin)["uid"])')

# 2. Health check
curl -sS -u "admin:$GRAFANA_PW" \
  "$G/api/datasources/uid/$UID/health"
# {"message":"datasource is working. 0 measurements found","status":"OK"}

# 3. Write one point through the proxy (nanosecond timestamp)
curl -sS -o /dev/null -w '%{http_code}\n' \
  -XPOST "http://127.0.0.1:8087/write?db=tsdb_db" \
  --data-binary "cpu,host=api value=42 $(date +%s%N)"

# 4. Read through Grafana's data source proxy (InfluxQL)
curl -sS -u "admin:$GRAFANA_PW" -G \
  "$G/api/datasources/proxy/uid/$UID/query" \
  --data-urlencode 'db=tsdb_db' \
  --data-urlencode 'epoch=ms' \
  --data-urlencode 'q=SELECT last("value") FROM "cpu" WHERE time >= now() - 1h'

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" and httpMode: "GET" in jsonData match 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" (returns 200).

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:

SELECT add_retention_policy('"tsdb_db"."cpu"', INTERVAL '86400 seconds',
                            schedule_interval => INTERVAL '1 hour');
SELECT add_compression_policy('"tsdb_db"."cpu"', INTERVAL '6 hours',
                              schedule_interval => INTERVAL '1 hour');
SELECT set_chunk_time_interval('"tsdb_db"."cpu"', INTERVAL '1 hour');

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:

pg exec -i tsdb "
  SELECT job_id, application_name, schedule_interval, proc_name,
         hypertable_schema, hypertable_name, config
  FROM timescaledb_information.jobs
  WHERE hypertable_schema = 'tsdb_db' ORDER BY job_id"

A 1-day policy produces a job like:

 job_id |  application_name        | schedule_interval |    proc_name     | config
--------+--------------------------+-------------------+------------------+-----------------------------------------
   1050 | Retention Policy [1050]  | 01:00:00          | policy_retention | {"drop_after": "24:00:00", ...}
   1051 | Columnstore Policy [1051]| 01:00:00          | policy_compression | {"compress_after": "06:00:00", ...}

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 POLICY triggers 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_interval from timescaledb_information.jobs when 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 INF default) 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 DURATION or from the retention duration (1h minimum, retention/24, capped at 1 day).
  • Changing a policy to INF removes the TimescaleDB retention job at the next sync; data then stops expiring.
  • Compression is applied automatically. Tags become compress_segmentby, time descending becomes compress_orderby, and compress_after is derived from the retention duration (retention/4, capped at 1 day; 15m minimum).

Verify from PostgreSQL:

# active policies and last run
pg exec -i tsdb "SELECT application_name, schedule_interval, last_run_started_at, last_successful_finish
                 FROM timescaledb_information.job_stats s
                 JOIN timescaledb_information.jobs j USING (job_id)
                 WHERE proc_name IN ('policy_retention','policy_compression')"

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.yaml and in the container’s argv. Use a dedicated role limited to the target database, and protect the config file.

Lifecycle and autostart

pg addon start influx2tsdb --name influx2tsdb
pg addon stop influx2tsdb --name influx2tsdb
pg addon influx2tsdb status --name influx2tsdb
pg logs addon influx2tsdb --name influx2tsdb -f
pg addon remove influx2tsdb --name influx2tsdb          # container + pg.yaml entry

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:

pg autostart enable --influx2tsdb --name influx2tsdb
pg autostart status
pg autostart disable --influx2tsdb --name influx2tsdb

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 the public._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.

# local target; discovers URL and database from pg addon influx2tsdb status
test/addon/test_influx2tsdb.sh --instance tsdb

# explicit endpoint
test/addon/test_influx2tsdb.sh --url http://127.0.0.1:8087 --db tsdb_db

# keep the test measurements/database for inspection
test/addon/test_influx2tsdb.sh --instance tsdb --keep

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_compression jobs, with drop_after / compress_after matching 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 INF removing the retention job while the compression job stays and falls back to 1 day;
  • DROP DATABASE cleaning up all jobs for that schema;
  • asserting that pg_cron is not installed.
# local target; discovers endpoint, database and instance from
# pg addon influx2tsdb status
test/addon/test_influx2tsdb_retention.sh

# explicit endpoint
test/addon/test_influx2tsdb_retention.sh --instance tsdb \
  --url http://127.0.0.1:8087 --db tsdb_db

# skip the proxy-stop phase (jobs/config only)
test/addon/test_influx2tsdb_retention.sh --skip-proxy-stop

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 SERIES is not supported and SHOW TAG KEYS / FIELD KEYS / TAG VALUES require FROM. 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 follows epoch=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’s public schema.

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

Run a single-node ClickHouse columnar OLAP database as a managed addon container, with authentication, SQL helpers, users/grants, config overrides and BACKUP/RESTORE

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. --listen defaults to 127.0.0.1 — pass 0.0.0.0 explicitly 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

pg addon install clickhouse --name ch1
✓ ClickHouse "ch1" installed
  Version:   26.8.36.3
  Image:     docker.io/clickhouse/clickhouse-server:26.8.36.3
  Container: pgcli-clickhouse-ch1
  HTTP:      http://127.0.0.1:8123
  TCP:       127.0.0.1:9000
  Admin:     admin
  Password:  <generated>
  Data dir:  /home/you/.pgcli/addon/clickhouse/ch1/data
  Backups:   /home/you/.pgcli/addon/clickhouse/ch1/backups
  Config:    /home/you/.pgcli/addon/clickhouse/ch1/config.d/zz-pgcli.xml

A second instance takes the next free HTTP/TCP ports from its pools (for example 8124/9001) and can use a custom data dir:

pg addon install clickhouse --name analytics --data-dir /srv/clickhouse-data --listen 0.0.0.0

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 --listen chose) plus listen_try
  • http_port / tcp_port
  • logger.level, logger.log, logger.errorlog, logger.console, rotation (size, count)
  • a local disk named backups at /var/lib/clickhouse-backups, allowed for BACKUP/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

pg addon password clickhouse --name ch1   # print the admin password
pg addon list                             # shows the Analytics add-ons section

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:

# HTTP interface (Basic auth)
curl -sS 'http://127.0.0.1:8123/?query=SELECT%20version()' \
  --user "admin:$(pg addon password clickhouse --name ch1)"

# Native protocol
clickhouse-client --host 127.0.0.1 --port 9000 --user admin --password

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

pg addon clickhouse shell --name ch1                  # bash inside the container
pg addon clickhouse query --name ch1 'SELECT version()'
pg addon clickhouse query --name ch1 --database demo 'SELECT count() FROM events'
pg addon clickhouse query --name ch1 --file schema.sql
echo "SELECT 1" | pg addon clickhouse query --name ch1 -

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.

CREATE TABLE demo.events
(
    id   UInt64,
    ts   DateTime,
    name String
)
ENGINE = MergeTree
ORDER BY id;
pg addon clickhouse query --name ch1 --database demo --file events.sql
echo "INSERT INTO demo.events VALUES (1, now(), 'boot')" | \
  pg addon clickhouse query --name ch1 --database demo -
pg addon clickhouse query --name ch1 --database demo 'SELECT * FROM demo.events ORDER BY id'

Databases

pg addon clickhouse database create demo --name ch1
pg addon clickhouse database create logs --engine Atomic --comment 'application logs' --if-not-exists --name ch1
pg addon clickhouse database list --name ch1
pg addon clickhouse database drop demo --name ch1 --yes

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

# User with an explicit password and default database
pg addon clickhouse user create app --password 'change-me' --default-database demo --name ch1

# Password omitted: generated and printed once
pg addon clickhouse user create reader --default-database demo --readonly --name ch1

# Restrict by source address / host name (repeatable; default is HOST ANY)
pg addon clickhouse user create etl --host-ip 10.0.0.0/8 --host-ip 192.168.1.10 --name ch1
pg addon clickhouse user create svc --host-name app.internal --name ch1
pg addon clickhouse user create localonly --host-local --name ch1

pg addon clickhouse user list --name ch1
pg addon clickhouse user drop app --name ch1 --yes

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 + write on one database
pg addon clickhouse grant app --read --write --database demo --name ch1

# DDL keywords = CREATE, ALTER, DROP, TRUNCATE, OPTIMIZE
pg addon clickhouse grant app --ddl --database demo --name ch1

# Table scope (requires an explicit database)
pg addon clickhouse grant reader --read --database demo --table events --name ch1

# Explicit list, wildcard scope, grant option
pg addon clickhouse grant app --privileges 'SELECT,INSERT' --name ch1
pg addon clickhouse grant app --all --with-grant-option --name ch1

pg addon clickhouse grant list app --name ch1
pg addon clickhouse grant app --read --database demo --revoke --name ch1

--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.

# At install time
pg addon install clickhouse --name ch1 \
  --config-option max_server_memory_usage=2147483648 \
  --config-option logger.level=warning

# On a live instance
pg addon clickhouse config list --name ch1
pg addon clickhouse config get max_server_memory_usage --name ch1
pg addon clickhouse config set max_server_memory_usage=2147483648 --name ch1
pg addon clickhouse config set logger.level=debug query_log.flush_interval_milliseconds=1000 --name ch1
pg addon clickhouse config unset logger.level --name ch1
pg addon clickhouse config show --name ch1        # full generated XML
  • config set KEY=VALUE ... accepts one or more pairs, validates them, rewrites the XML and pg.yaml, then restarts a running container so the change takes effect. Pass --no-restart to 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 get prints the effective value of a custom override or a managed setting; config list shows managed settings first, then the custom overrides sorted by key.
  • config show renders the same XML that is written to config.d/zz-pgcli.xml; it does not touch the file or the container.
  • --config-option at 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:

pg addon clickhouse config set max_server_memory_usage=2147483648 --name ch1
pg addon clickhouse config set background_pool_size=8 max_concurrent_queries=100 --name ch1
pg addon clickhouse config set query_log.flush_interval_milliseconds=1000 --name ch1
pg addon clickhouse config set merge_tree.max_suspicious=0 --name ch1

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:

pg addon stop clickhouse --name ch1
pg addon clickhouse config unset background_pool_size --no-restart --name ch1
pg addon start clickhouse --name ch1

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.

pg addon clickhouse backup --name ch1 --database demo
# ✓ backup written: <instance dir>/backups/demo-20261027-120000.zip (… bytes)

# Explicit name or nested path inside the backups dir
pg addon clickhouse backup --name ch1 --database demo --out nightly.zip
pg addon clickhouse backup --name ch1 --database demo --out archive/2026-q4.zip

pg addon clickhouse backups --name ch1
pg addon clickhouse restore demo-20261027-120000.zip --name ch1 --database demo
pg addon clickhouse restore demo-20261027-120000.zip --name ch1 --database demo --as demo_restored
  • --database is required for both commands: it names the database recorded in the backup (there is no “all databases” form in v1).
  • --out is 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).
  • restore takes 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 NEW to restore alongside it.
  • Restore itself is online, but it needs free space and a running instance; backups lists .zip files 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.

# Off-host copy
rsync -a /home/you/.pgcli/addon/clickhouse/ch1/backups/ backup-host:/backups/clickhouse-ch1/

Lifecycle

pg addon start clickhouse --name ch1      # create if missing, then wait for /ping
pg addon stop clickhouse --name ch1
pg addon clickhouse status --name ch1     # state, ping, endpoints, version, paths, override count
pg logs addon clickhouse --name ch1 -f    # container logs (stdout/stderr)
pg addon remove clickhouse --name ch1     # remove container + pg.yaml entry, keep data
pg addon remove clickhouse --name ch1 --clean-data   # also delete the instance dir and a custom data dir

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:

pg autostart enable --clickhouse --name ch1
pg autostart status
pg autostart disable --clickhouse --name ch1

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. --listen defaults to 127.0.0.1. Authentication is always on, but a network bind still widens the attack surface; use 0.0.0.0 only on trusted networks.
  • Password storage. The admin password lives in pg.yaml (0644 by default); protect that file and prefer pg addon password clickhouse over 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-data rather 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_usage is the process-wide limit in bytes. Its default 0 keeps ClickHouse’s ratio rule, max_server_memory_usage_to_ram_ratio (default 0.9 of 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_queries and background_pool_size are also server-level settings and work with config set. The server-wide limit covers all concurrent queries together, so plan for peak per-query memory × concurrency inside that budget.
  • Query-level. max_memory_usage, max_bytes_before_external_group_by, max_bytes_before_external_sort and max_threads are user/profile settings, not server XML, so config set does not apply them. Set them for one session with SET in a clickhouse-client started from pg addon clickhouse shell (one-shot query calls do not share session state), or define them on a SQL-created user with CREATE USER ... SETTINGS ....
  • Spill instead of OOM. max_bytes_before_external_group_by / max_bytes_before_external_sort let heavy aggregations and sorts spill to disk above the threshold, trading latency for a lower memory ceiling. JOIN is usually the biggest consumer — size for the right-hand table or avoid large joins.

Check the live value:

pg addon clickhouse query --name ch1 "SELECT name, value, changed FROM system.server_settings WHERE name = 'max_server_memory_usage'"

Example — cap the whole server at 8 GiB and allow 20 concurrent queries:

pg addon clickhouse config set max_server_memory_usage=8589934592 max_concurrent_queries=20 --name ch1

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

Run Apache Kafka 4.x brokers in KRaft mode (no ZooKeeper) as a managed addon — single-node or a multi-host static controller quorum, with topic management, rendered server.properties and host networking

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 --listen defaults to 127.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

pg addon install kafka --name k1
✓ Kafka "k1" installed
  Version:     4.4.0
  Image:       docker.io/apache/kafka:4.4.0
  Container:   pgcli-kafka-k1
  Broker:      127.0.0.1:9092
  Controller:  127.0.0.1:19093
  Node ID:     1
  Cluster ID:  <22-character id>
  Data dir:    /home/you/.pgcli/addon/kafka/k1/data
  Config:      /home/you/.pgcli/addon/kafka/k1/config/server.properties

A second instance takes the next free broker/controller ports from their pools (for example 9093/19094, unless those ports are in use):

pg addon install kafka --name k2 --data-dir /srv/kafka-data --listen 0.0.0.0 --advertise-host 192.0.2.10

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:

# node 1 on 192.0.2.11
pg addon install kafka --name prod --cluster prod --node-id 1 \
  --listen 0.0.0.0 --advertise-host 192.0.2.11 \
  --broker-port 9092 --controller-port 19093 \
  --peer 2@192.0.2.12:19093 --peer 3@192.0.2.13:19093

# node 2 on 192.0.2.12
pg addon install kafka --name prod --cluster prod --node-id 2 \
  --listen 0.0.0.0 --advertise-host 192.0.2.12 \
  --broker-port 9092 --controller-port 19093 \
  --peer 1@192.0.2.11:19093 --peer 3@192.0.2.13:19093

# node 3 on 192.0.2.13
pg addon install kafka --name prod --cluster prod --node-id 3 \
  --listen 0.0.0.0 --advertise-host 192.0.2.13 \
  --broker-port 9092 --controller-port 19093 \
  --peer 1@192.0.2.11:19093 --peer 2@192.0.2.12:19093

How the identity is assembled:

  • Cluster ID. Derived from --cluster with a deterministic hash, so every host computes the same value without passing --cluster-id. If you pass --cluster-id it must equal the derived value.
  • Voters. ID@HOST:PORT entries are sorted by node id; this node’s entry comes from --node-id, --advertise-host and --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. --listen must be 0.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:

pg addon kafka cluster status --name prod    # leader + 3 voters
pg addon kafka status --name prod            # cluster, node x of N, voter, voters, RF/minISR
pg addon list                                # shows "cluster prod" and the voter list

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.replicas and 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 NAME uses 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=2 allows.
  • With min.insync.replicas=2, a partition whose ISR drops below 2 rejects acks=all writes (the producer default since Kafka 3.0). pg addon kafka topic describe NAME shows the ISR.

TLS

Enable the SSL listener with --tls:

pg addon install kafka --name k1 --tls                 # SSL on the first free port from 9093
pg addon install kafka --name k1 --tls --tls-port 9093

install and status then print both endpoints and the CA path:

  Broker:      127.0.0.1:9092
  TLS:         127.0.0.1:9093 (CA .../addon/kafka/k1/config/tls/ca.crt)
  Controller:  127.0.0.1:19093

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:

# On each host, copy out that node's CA, then concatenate them on the client.
for v in vm01 vm02 vm03; do
  ssh $v 'cat ~/.pgcli/addon/kafka/prod/config/tls/ca.crt'
done > kafka-ca-bundle.pem

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:

# 1. Issue the server certificate. Include every address clients dial,
#    especially --advertise-host.
pg cert --host 127.0.0.1,192.0.2.11 \
  --cert-file kafka.crt --key-file kafka.key

# 2. Merge certificate (chain) + private key into one PEM.
cat kafka.crt kafka.key > kafka-server.pem

# 3. Install with the merged file — --tls is implied.
pg addon install kafka --name k1 --tls-pem ./kafka-server.pem

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:

# On node 1, whose --advertise-host is 192.0.2.11:
pg cert --host 192.0.2.11 --cert-file kafka-node1.crt --key-file kafka-node1.key
cat kafka-node1.crt kafka-node1.key > kafka-node1.pem
pg addon install kafka --name prod --cluster prod --node-id 1 --force ... --tls-pem ./kafka-node1.pem

# Clients trust every node: concatenate the per-node certificates (or CAs).
cat kafka-node1.crt kafka-node2.crt kafka-node3.crt > kafka-ca-bundle.pem

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):

security.protocol=SSL
ssl.truststore.type=PEM
ssl.truststore.location=/path/to/addon/kafka/k1/config/tls/ca.crt

librdkafka-based clients:

security.protocol=SSL
ssl.ca.location=/path/to/addon/kafka/k1/config/tls/ca.crt

Verify the listener with OpenSSL:

openssl s_client -connect 127.0.0.1:9093 \
  -CAfile <base_dir>/addon/kafka/k1/config/tls/ca.crt -verify_return_error </dev/null

Produce and consume over TLS from inside the container (the TLS dir is mounted read-only with the config dir):

pg addon kafka shell --name k1 -- bash -lc \
  'printf "security.protocol=SSL\nssl.truststore.type=PEM\nssl.truststore.location=/mnt/shared/config/tls/ca.crt\n" > /tmp/client.properties
   printf "hello\n" | /opt/kafka/bin/kafka-console-producer.sh --bootstrap-server 127.0.0.1:9093 --producer.config /tmp/client.properties --topic demo'

pg addon kafka shell --name k1 -- bash -lc \
  'printf "security.protocol=SSL\nssl.truststore.type=PEM\nssl.truststore.location=/mnt/shared/config/tls/ca.crt\n" > /tmp/client.properties
   /opt/kafka/bin/kafka-console-consumer.sh --bootstrap-server 127.0.0.1:9093 --consumer.config /tmp/client.properties --topic demo --from-beginning --max-messages 1'
  • 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-pem you 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:

process.roles=broker,controller
node.id=1
listeners=PLAINTEXT://127.0.0.1:9092,CONTROLLER://127.0.0.1:19093
advertised.listeners=PLAINTEXT://127.0.0.1:9092
inter.broker.listener.name=PLAINTEXT
controller.listener.names=CONTROLLER
listener.security.protocol.map=CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT
controller.quorum.voters=1@127.0.0.1:19093
log.dirs=/var/lib/kafka/data

With --tls the listener lines and security section become:

listeners=PLAINTEXT://127.0.0.1:9092,SSL://127.0.0.1:9093,CONTROLLER://127.0.0.1:19093
advertised.listeners=PLAINTEXT://127.0.0.1:9092,SSL://127.0.0.1:9093
listener.security.protocol.map=CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,SSL:SSL
ssl.keystore.type=PEM
ssl.keystore.location=/mnt/shared/config/tls/server.pem
ssl.client.auth=none

For a three-node cluster (node 2 of prod shown; --listen 0.0.0.0):

process.roles=broker,controller
node.id=2
listeners=PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:19093
advertised.listeners=PLAINTEXT://192.0.2.12:9092
inter.broker.listener.name=PLAINTEXT
controller.listener.names=CONTROLLER
listener.security.protocol.map=CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT
controller.quorum.voters=1@192.0.2.11:19093,2@192.0.2.12:19093,3@192.0.2.13:19093
log.dirs=/var/lib/kafka/data
offsets.topic.replication.factor=3
transaction.state.log.replication.factor=3
transaction.state.log.min.isr=2
default.replication.factor=3
min.insync.replicas=2

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

pg addon kafka topic create demo --name k1
pg addon kafka topic create events --partitions 3 --replication-factor 1 --name k1
pg addon kafka topic create logs --config retention.ms=604800000 --name k1
pg addon kafka topic list --name k1
pg addon kafka topic list --exclude-internal --name k1
pg addon kafka topic describe demo --name k1
pg addon kafka topic delete demo --name k1 --yes
pg addon kafka topic retention demo 7d --name k1   # set retention.ms
pg addon kafka topic retention demo --name k1      # show current overrides
  • --partitions defaults to the broker’s num.partitions (upstream default 1). --replication-factor 0 (the default) means “the instance’s RF” — 1 on a single node, min(3, voters) in a cluster (so 3 on a three-node cluster).
  • --config KEY=VALUE is repeatable and passes topic-level settings through to kafka-topics.sh.
  • delete asks for confirmation unless --yes is given; deleting a topic deletes all of its messages.
  • retention NAME [DURATION] shows or sets retention.ms. Durations accept ms/s/m/h/d/w suffixes (30m, 12h, 7d, 4w), plain milliseconds, or -1 / infinite / forever to 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):

pg addon kafka shell --name k1 -- bash -lc \
  'printf "hello\nworld\n" | /opt/kafka/bin/kafka-console-producer.sh --bootstrap-server 127.0.0.1:9092 --topic demo'

pg addon kafka shell --name k1 -- bash -lc \
  '/opt/kafka/bin/kafka-console-consumer.sh --bootstrap-server 127.0.0.1:9092 --topic demo --from-beginning --max-messages 2'

--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:

pg addon kafka topic create e2e --partitions 3 --replication-factor 3 --name prod

# Produce on node 1 through the local broker:
pg addon kafka shell --name prod -- bash -lc \
  'printf "alpha\nbeta\n" | /opt/kafka/bin/kafka-console-producer.sh --bootstrap-server 192.0.2.11:9092 --topic e2e'

# Consume on node 3 from node 2's broker:
pg addon kafka shell --name prod -- bash -lc \
  '/opt/kafka/bin/kafka-console-consumer.sh --bootstrap-server 192.0.2.12:9092 --topic e2e --from-beginning --max-messages 2'

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):

# PLAINTEXT
printf 'alpha\nbeta\n' | kcat -P -b 192.0.2.11:9092 -t e2e
kcat -C -q -b 192.0.2.12:9092 -t e2e -o beginning -e -c 2   # first two messages
kcat -C -q -b 192.0.2.13:9092 -t e2e -o -1 -e -c 1         # the last message

For TLS, point kcat at the CA bundle from Cluster TLS and select the SSL protocol (the TLS listener is on port 9093):

printf 'gamma\n' | kcat -P -b 192.0.2.11:9093 -t e2e \
  -X security.protocol=SSL -X ssl.ca.location=kafka-ca-bundle.pem
kcat -C -q -b 192.0.2.12:9093 -t e2e -o -1 -e -c 1 \
  -X security.protocol=SSL -X ssl.ca.location=kafka-ca-bundle.pem
  • -o beginning / -o -1 start at the first / last message; -e exits at EOF and -c N stops after N messages; -q prints 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 NAME to consume as a group (topics become positional arguments) — see Consumer groups.

Consumer groups

kafka-consumer-groups.sh is wrapped for the two everyday queries:

pg addon kafka group list --name k1
pg addon kafka group describe demo-group --name k1
  • list prints the group ids that have committed offsets on this cluster.
  • describe prints the group state, its members and per-partition CURRENT-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.id is set by the client, not by pgcli. To create one for a smoke test, consume with --group:
pg addon kafka shell --name prod -- bash -lc \
  '/opt/kafka/bin/kafka-console-consumer.sh --bootstrap-server 192.0.2.11:9092 --topic e2e --group demo-group --from-beginning --max-messages 2'
pg addon kafka group list --name prod
pg addon kafka group describe demo-group --name prod

From a host, the same thing with kcat uses -G; in group mode topics are positional arguments, not -t:

kcat -C -q -G demo-group -b 192.0.2.11:9092 \
  -X auto.offset.reset=earliest -c 2 e2e
pg addon kafka group list --name prod
pg addon kafka group describe demo-group --name prod

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 Empty state; its offsets and lag are still visible until the offsets expire.

Status and quorum

pg addon kafka status --name k1          # container state, endpoints, ids, paths
pg addon kafka cluster status --name k1  # KRaft metadata quorum leader and voters
pg logs addon kafka --name k1 -f         # broker logs (stdout/stderr)

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:

ClusterId:              <derived id>
LeaderId:               1
CurrentVoters:          [{"id": 1, ...}, {"id": 2, ...}, {"id": 3, ...}]

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:

pg addon install kafka --name k1 \
  --config-option num.partitions=3 \
  --config-option log.retention.hours=168

--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

pg addon start kafka --name k1            # start, create if missing, wait for readiness
pg addon stop kafka --name k1
pg addon remove kafka --name k1           # remove container + pg.yaml entry, keep data/config
pg addon remove kafka --name k1 --clean-data   # also delete the instance dir and a custom data dir

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:

pg autostart enable --kafka --name prod   # run on each host
pg autostart status
pg autostart disable --kafka --name prod

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. --cluster installs 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 --cluster there 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; --tls adds an encrypted listener but ssl.client.auth=none means 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 — --tls only 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.connect is 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. --version must be a real upstream tag.
  • JVM heap. The image default is -Xms1G -Xmx1G. Raise it with --heap when 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-data instead of deleting the directory by hand; pgcli handles the permission fallback.
  • Ports. Broker defaults to 9092; the controller defaults to 19093 to avoid the Alertmanager default 9093. 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

Run a RabbitMQ broker as a managed addon — single node or a multi-host cluster formed by etcd peer discovery, AMQP plus the management web UI/API, optional TLS (generated CA or bring your own certificate), and queue management (list/declare/inspect, publish/get, bind/unbind, purge/delete)

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). --listen defaults to 127.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

pg addon install rabbitmq --name rmq
✓ RabbitMQ "rmq" installed
  Version:    4.4.0
  Image:      docker.io/library/rabbitmq:4.4.0-management
  Container:  pgcli-rabbitmq-default-rmq
  AMQP:       amqp://127.0.0.1:5672
  Management: http://127.0.0.1:15672
  Admin:      admin
  Password:   <generated>
  Data dir:   /home/you/.pgcli/addon/rabbitmq/rmq/data
  Config:     /home/you/.pgcli/addon/rabbitmq/rmq/config/rabbitmq.conf

⚠️ 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):

pg addon install rabbitmq --name rmq2 --data-dir /srv/rabbitmq-data --listen 0.0.0.0

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:

pg addon password rabbitmq --name rmq

Management UI and API

The management web UI is served on the management port:

standalone: http://127.0.0.1:15672      (default --listen 127.0.0.1)
cluster:    http://<member-ip>:15672    (any member, e.g. http://192.0.2.11:15672)

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:

pw="$(pg addon password rabbitmq --name rmq)"
curl -fsS -u "admin:$pw" http://127.0.0.1:15672/api/overview

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@host must 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 only rabbit@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 local 0600 files and publishes their contents to etcd, and every member then installs with --secrets-from-etcd. pgcli stores both values in pg.yaml and the 0600 secret file, so start and autostart do not need etcd or the original files. The older --erlang-cookie-file / --admin-password-file pair still works when you prefer to distribute files yourself. These are cluster-level secrets: AMQP clients need neither.
  • Same version. Use the same --version on every member.
  • Same cluster name. A different --cluster value creates a separate cluster sharing the etcd store; pgcli isolates them with cluster_formation.etcd.cluster_name.

On the management host, generate and publish the shared secrets once:

pg addon rabbitmq secret generate --cluster pgcli-lab \
  --etcd-endpoints 192.0.2.10:2379

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):

/pgcli/rabbitmq/clusters/pgcli-lab/secrets/erlang_cookie
/pgcli/rabbitmq/clusters/pgcli-lab/secrets/admin_password

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):

# on 192.0.2.11
pg addon install rabbitmq --name mq --cluster pgcli-lab \
  --node-name rabbit@192.0.2.11 \
  --etcd-endpoints 192.0.2.10:2379 \
  --secrets-from-etcd

# on 192.0.2.12
pg addon install rabbitmq --name mq --cluster pgcli-lab \
  --node-name rabbit@192.0.2.12 \
  --etcd-endpoints 192.0.2.10:2379 \
  --secrets-from-etcd

# on 192.0.2.13
pg addon install rabbitmq --name mq --cluster pgcli-lab \
  --node-name rabbit@192.0.2.13 \
  --etcd-endpoints 192.0.2.10:2379 \
  --secrets-from-etcd

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

/rabbitmq/discovery/rabbitmq/clusters/<cluster-name>/nodes/<node-name>

so several clusters can share one etcd as long as their --cluster names differ.

Queue semantics

  • In cluster mode pg addon rabbitmq queue declare defaults 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 classic still 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=false and quorum + --auto-delete remain 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

pg addon rabbitmq cluster status --name mq
pg addon rabbitmq cluster forget rabbit@192.0.2.13 --name mq --yes

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:

pg addon rabbitmq shell --name mq
rabbitmq-queues rebalance quorum

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-name and the Erlang cookie are immutable. Re-running install --force with any of them changed fails; --etcd-endpoints can be updated.
  • A standalone instance cannot be converted to a cluster member (or vice versa): remove it with --clean-data and install again. First-time cluster installation also requires an empty data directory.
  • --advertise-host, --node-id, --peer and --join do not apply to RabbitMQ; the node identity is --node-name. Plaintext --admin-password is 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 rerun install --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 makes install --force fail.
  • 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. --tls protects client listeners only. Keep 4369 and 25672 on 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.

pg addon install rabbitmq --name rmq --tls
pg addon install rabbitmq --name rmq --tls --tls-port 5671

install and status then print both sets of endpoints and the CA path:

  AMQP:       amqp://127.0.0.1:5672
  Management: http://127.0.0.1:15672
  AMQPS:      amqps://127.0.0.1:5671
  Mgmt TLS:   https://127.0.0.1:15671
  CA cert:    /home/you/.pgcli/addon/rabbitmq/rmq/config/tls/ca.crt

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:

openssl s_client -connect 127.0.0.1:5671 \
  -CAfile "$HOME/.pgcli/addon/rabbitmq/rmq/config/tls/ca.crt" \
  -verify_ip 127.0.0.1 -verify_return_error </dev/null

pw="$(pg addon password rabbitmq --name rmq)"
curl -fsS --cacert "$HOME/.pgcli/addon/rabbitmq/rmq/config/tls/ca.crt" \
  -u "admin:$pw" https://127.0.0.1:15671/api/overview

AMQP URL with TLS (e.g. for Python/pika or Go clients):

amqps://admin:<password>@127.0.0.1:5671/
  • TLS is transport-only. ssl_options.verify = verify_none and fail_if_no_peer_cert = false mean 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-port without --tls is rejected. Disable TLS with --tls=false --force; the generated files stay under config/tls/ and are reused if TLS is re-enabled.

Bring your own certificate (pg cert)

pg cert --host localhost,127.0.0.1,192.0.2.10 \
  --cert-file rmq.crt --key-file rmq.key

pg addon install rabbitmq --name rmq \
  --tls-cert rmq.crt --tls-key rmq.key

--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 --listen address (a mismatch is a hard error; use --listen 0.0.0.0 or 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.

# declare and inspect
pg addon rabbitmq queue list --name rmq
pg addon rabbitmq queue declare orders --name rmq
pg addon rabbitmq queue info orders --name rmq
pg addon rabbitmq queue info orders --name rmq --json

# publish and consume
pg addon rabbitmq queue publish orders --payload '{"id":1}' --name rmq
echo '{"id":2}' | pg addon rabbitmq queue publish orders --payload-file - --name rmq
# standalone classic queue: persist the message across a broker restart
pg addon rabbitmq queue publish orders --payload '{"id":3}' --property delivery_mode=2 --name rmq
pg addon rabbitmq queue get orders --count 10             # peek: messages stay queued
pg addon rabbitmq queue get orders --count 10 --consume   # ack: remove them
pg addon rabbitmq queue get orders --json

# bindings (a real exchange is required; the default exchange cannot be bound)
pg addon rabbitmq queue bind orders --exchange amq.topic --routing-key 'order.*' --name rmq
pg addon rabbitmq queue bindings orders --name rmq
pg addon rabbitmq queue unbind orders --exchange amq.topic --routing-key 'order.*' --name rmq

# cleanup
pg addon rabbitmq queue purge orders --name rmq --yes
pg addon rabbitmq queue delete orders --name rmq --yes
pg addon rabbitmq queue delete orders --name rmq --if-empty --if-unused
  • list prints name, type, messages, ready, unacked and consumers; an empty vhost prints no queues.
  • declare creates 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=false is rejected before the request is sent. --type classic or --type quorum overrides the default explicitly; quorum queues must be durable and cannot be auto-delete. Repeatable --argument KEY=VALUE sets 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) sets x-message-ttl: a message that waits in the queue longer is dropped, or dead-lettered when the queue has x-dead-letter-exchange. A single message can carry its own TTL with --property 'expiration="60000"'.
  • Automatic deletion. --auto-delete deletes the queue when its last consumer disconnects. --expires 24h sets x-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 with basic.get; publishing alone does not reset the timer. The value must be positive (0 is rejected by RabbitMQ). Both can also be set with --argument.
  • info shows type, state, durable, auto-delete, message counters, consumers, memory, policy, arguments and node. --json prints the raw management API document. Counters are updated by RabbitMQ’s stats collector, so they can lag a few seconds behind publish/get.
  • publish sends one message. Exactly one of --payload or --payload-file is required (use - to read stdin). --exchange defaults to amq.default (the default exchange, which routes to the queue named by the routing key); --routing-key defaults to the queue name. Repeatable --property KEY=VALUE sets 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=2 to queue publish, or set delivery_mode=2 in the application’s AMQP client. No queue argument or policy can enable it (--argument delivery_mode=2 has 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 (never remove --clean-data); a lost disk is unrecoverable. Cluster quorum queues persist and replicate every message regardless of delivery mode.
  • get peeks by default (ack_requeue_true); --consume acknowledges and removes the returned messages (ack_requeue_false). --count defaults to 1; --json prints the raw API response.
  • bind / unbind require --exchange and refuse amq.default (the default exchange is bound implicitly). --routing-key defaults to the queue name. Repeatable --argument supports argument-based (headers) bindings. unbind without --argument removes every binding from that exchange with the given routing key; with --argument it removes only the exactly matching binding.
  • bindings lists a queue’s bindings and their arguments (--json for the raw document).
  • purge removes every message but keeps the queue; delete removes the queue itself. Both ask for confirmation unless --yes is 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 --name and --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:

# Queue-level TTL: every message expires 24 hours after it was published
pg addon rabbitmq queue declare orders --message-ttl 24h --name rmq

# Per-message TTL: this message expires 60 seconds after publishing
# (`expiration` is a string property in milliseconds)
pg addon rabbitmq queue publish orders --payload '{"id":1}' \
  --property 'expiration="60000"' --name rmq

# Idle queue deletion (not a message TTL): the queue itself and its
# messages are removed after 1 hour without consumers, redeclare or basic.get
pg addon rabbitmq queue declare scratch --expires 1h --name rmq

# Capacity cap instead of a time limit: keep at most 10000 messages
pg addon rabbitmq queue declare backlog --argument x-max-length=10000 --name rmq

# Route expired messages to a dead-letter exchange instead of dropping them
pg addon rabbitmq queue declare dlq-demo --message-ttl 24h \
  --argument x-dead-letter-exchange=dlx --name rmq

--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:

pg addon rabbitmq shell --name rmq
rabbitmqctl set_policy orders-ttl '^orders$' \
  '{"message-ttl":86400000,"max-length":10000}' --apply-to queues
rabbitmqctl list_policies

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

pg addon rabbitmq status --name rmq       # state, endpoints, TLS, admin, EPMD/dist, paths
pg addon start rabbitmq --name rmq        # start (create if missing), wait for readiness
pg addon stop rabbitmq --name rmq
pg addon remove rabbitmq --name rmq       # remove container + pg.yaml entry, keep data/config/secrets
pg addon remove rabbitmq --name rmq --clean-data   # also delete the instance dir and a custom data dir
pg logs addon rabbitmq --name rmq -f

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:

pg autostart enable --rabbitmq --name rmq
pg autostart status
pg autostart disable --rabbitmq --name rmq

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:

listeners.tcp.default = 127.0.0.1:5672

management.tcp.ip = 127.0.0.1
management.tcp.port = 15672

default_user_tags.administrator = true
default_permissions.configure = .*
default_permissions.read = .*
default_permissions.write = .*

distribution.listener.interface = 127.0.0.1
distribution.listener.port_range.min = 25672
distribution.listener.port_range.max = 25672

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:

cluster_formation.peer_discovery_backend = etcd
cluster_formation.etcd.endpoints.1 = 192.0.2.10:2379
cluster_formation.etcd.cluster_name = pgcli-lab

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:

pg addon install rabbitmq --name rmq --force \
  --config-option heartbeat=60 \
  --config-option log.file.level=debug

--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_PASS only seed an empty Mnesia database on first boot. That is why pgcli reconciles the account on every start with rabbitmqctl change_password (falling back to add_user when the user does not exist), then sets the administrator tag and full .* / .* / .* permissions on vhost /.
  • Changing --admin-user on an existing instance creates/reconciles the new account but does not delete the previous one. Remove stale accounts in the management UI or with rabbitmqctl delete_user.
  • Changing --admin-password (or reviving a stored secret) is applied on the next install --force or start. 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 by pg addon rabbitmq secret generate) or from --admin-password-file plus --erlang-cookie-file. Plaintext --admin-password is rejected. Both values are then persisted in pg.yaml and the 0600 secret file.
  • Passwords are stored both in pg.yaml and in the 0600 secret file; pg addon password rabbitmq --name NAME prints 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) and 25672 (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_plugins is 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.1 or 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:// and http:// stay enabled when --tls is on; clients that need encryption must select the amqps:// / 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. --version must 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 backup command in this release.
  • Data ownership. Data files are owned by uid/gid 999. Use --clean-data instead of deleting the directory by hand; pgcli handles the permission fallback through podman 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

Run Apache RocketMQ 5.x as a managed addon — three containers (NameServer + broker + proxy), standalone or a multi-host controller-mode cluster with automatic broker failover, topic and message helpers, rendered configs and host networking

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, 8080 and 8081. 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

pg addon install rocketmq --name rmq

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:

✓ RocketMQ "rmq" installed
  Version:     5.5.0
  Image:       docker.io/apache/rocketmq:5.5.0
  Containers:  pgcli-rocketmq-rmq-namesrv, pgcli-rocketmq-rmq-broker, pgcli-rocketmq-rmq-proxy
  Namesrv:     127.0.0.1:9876
  Broker:      127.0.0.1:10911 (HA 10912, VIP 10909)
  Proxy:       127.0.0.1:8080 (remoting), 127.0.0.1:8081 (gRPC)
  Mode:        single-node (no controller, no replicas)
  JVM heap:    broker 512m, namesrv 256m, proxy 256m
  Data dir:    /home/you/.pgcli/addon/rocketmq/rmq/data
  Config:      /home/you/.pgcli/addon/rocketmq/rmq/config

To let clients on other hosts connect, advertise the host’s LAN address (the proxy config and broker both use it):

pg addon install rocketmq --name rmq --advertise-host 10.0.0.11

A second standalone instance takes the next free port from each pool; it does not reuse the first instance’s ports:

pg addon install rocketmq --name rmq2 --data-dir /srv/rocketmq-data

On a small VM, lower all three heaps to keep the three JVMs inside memory:

pg addon install rocketmq --name rmq \
  --heap 384m --namesrv-heap 192m --proxy-heap 192m

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 --cluster name on every host.
  • A unique --controller-id per host (n0, n1, n2, …).
  • That host’s --advertise-host as a concrete non-loopback IPv4 address.
  • --peer ID@HOST entries 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, 8080 and 8081. 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:

# on 10.0.0.11 (controller n0)
pg addon install rocketmq --name rmq --cluster prod --controller-id n0 \
  --advertise-host 10.0.0.11 \
  --peer n1@10.0.0.12 --peer n2@10.0.0.13

# on 10.0.0.12 (controller n1)
pg addon install rocketmq --name rmq --cluster prod --controller-id n1 \
  --advertise-host 10.0.0.12 \
  --peer n0@10.0.0.11 --peer n2@10.0.0.13

# on 10.0.0.13 (controller n2)
pg addon install rocketmq --name rmq --cluster prod --controller-id n2 \
  --advertise-host 10.0.0.13 \
  --peer n0@10.0.0.11 --peer n1@10.0.0.12

The first two installs are expected to finish with a warning such as:

[!] RocketMQ broker is running, but it has not registered with the controller yet
(broker not listed in clusterList) — install the remaining nodes with the same
--cluster and --peer values ...

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:

pg addon rocketmq status --name rmq
pg addon rocketmq cluster status --name rmq

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:

rocketmq "rmq" — cluster prod: 3 broker(s), 1 master(s)
#Cluster Name  #Broker Name  #BID  #Addr             #Version  #InTPS(LOAD)  #OutTPS(LOAD)
prod           prod          0     10.0.0.13:10911   V5_5_0    0.00(0,0ms)   0.00(0,0ms)
prod           prod          1     10.0.0.11:10911   V5_5_0    0.00(0,0ms)   0.00(0,0ms)
prod           prod          2     10.0.0.12:10911   V5_5_0    0.00(0,0ms)   0.00(0,0ms)

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:

# find the master (BID 0) in cluster status, then on that host:
pg addon stop rocketmq --name rmq

# on any member, poll until another BID 0 appears:
pg addon rocketmq cluster status --name rmq

# start the old broker again; it rejoins as a replica:
pg addon start rocketmq --name rmq

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, controller 9877) 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:

pg addon rocketmq topic create surge --queues 3 --name rmq
pg addon rocketmq topic list --name rmq
pg addon rocketmq topic describe surge --name rmq

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:

pg addon rocketmq send surge "hello" --key k1 --tags demo --name rmq

pg addon rocketmq shell --service namesrv --name rmq -- \
  sh mqadmin queryMsgByKey -t surge -k k1 -n 127.0.0.1:9876

send prints the sendMessage result, including the message id, send status and queue. Pull messages back with a bounded debug consumer:

pg addon rocketmq consume surge --count 10 --timeout 30s --name rmq

# one queue, from the start
pg addon rocketmq consume surge --queue 0 --offset 0 --count 50 --name rmq

# messages in a time window (epoch milliseconds or yyyy-MM-dd#HH:mm:ss:SSS)
pg addon rocketmq consume surge --begin 1793264528000 --end 1793264535000 \
  --count 0 --name rmq

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:

pg addon rocketmq topic delete surge --name rmq --yes

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:

pg addon rocketmq retention show --name rmq

pg addon rocketmq retention set --hours 24 --delete-when "04;16" \
  --disk-ratio 80 --name rmq

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):

pg addon install rocketmq --name rmq --force \
  --config-option flushDiskType=SYNC_FLUSH

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-option replaces 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, --peer and 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-data and install it again in the desired mode.
  • Custom data directory: pointing --data-dir at 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

pg addon start rocketmq --name rmq
pg addon stop rocketmq --name rmq
pg addon remove rocketmq --name rmq --clean-data

pg logs addon rocketmq --name rmq --service broker -f
pg logs addon rocketmq --name rmq --service namesrv
pg logs addon rocketmq --name rmq --service proxy

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:

pg autostart enable --rocketmq --name rmq
pg autostart status

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

Clone guide for pgcli

Create a new instance whose data is copied from an existing one, streamed directly — no temp file on disk.

# Clone the default instance
pg clone test02

# Clone a specific instance
pg clone test02 -i proj01

# Clone a remote database via connection string
pg clone test02 --dsn postgres://user:pass@host:5432/db

# Custom data directory for the new instance
pg clone test02 -i proj01 --base-dir /data/pg

What happens

  1. Pre-flight check — the source is verified before anything is created:
    • Local instance: container must be running
    • --dsn: an authenticated SELECT 1 must succeed (catches wrong password, unreachable host)
  2. Create — a new instance entry is added to the config with a random password, its own container name, data directory and auto-assigned port
  3. Start — the new instance is started (same workflow as pg start)
  4. 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 --dsn target reachable); a bad source fails immediately with no side effects
  • The new instance name must not already exist in config
  • --dsn and --instance are mutually exclusive: with --dsn the 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

Data Import/Export guide for pgcli

Export and import databases to dump files. Supports custom format (recommended) and plain SQL, with automatic gzip compression. Also supports piping between instances.

# Export to custom format (recommended, fastest restore)
pg export -i proj01 -o backup.dump

# Export to SQL format (human-readable)
pg export -i proj01 -o backup.sql

# Export with gzip compression (auto-detected from .gz extension)
pg export -i proj01 -o backup.dump.gz
pg export -i proj01 -o backup.sql.gz

# Export specific database
pg export -i proj01 -d mydb -o backup.dump

# Export with custom compression level (0-9)
pg export -i proj01 -o backup.sql.gz --compress=9

# Export with verbose output (show progress)
pg export -i proj01 -o backup.dump -v

# Import from custom format
pg import -i proj02 backup.dump

# Import from SQL format
pg import -i proj02 backup.sql

# Import compressed file (auto-detected from .gz extension)
pg import -i proj02 backup.dump.gz

# Import to specific database
pg import -i proj02 -d mydb backup.dump

# Import with cleanup (drop existing objects before restore)
pg import -i proj02 --clean backup.dump

# Import with verbose output
pg import -i proj02 backup.dump -v

# Pipe between instances (no temp file)
pg export -i proj01 | pg import -i proj02
pg export -i proj01 -d mydb | pg import -i proj02 -d mydb --clean

# Pipe across hosts via SSH
pg export -i proj01 | ssh user@remote "pg import -i proj02"
ssh user@remote "pg export -i proj01" | pg import -i proj02
ssh user@host1 "pg export -i proj01" | ssh user@host2 "pg import -i proj02"

# Work with remote databases via connection string (--dsn)
pg export --dsn postgres://user:pass@host:5432/mydb -o backup.dump
pg import --dsn postgres://user:pass@host:5432/mydb backup.dump --clean
pg export -i proj01 | pg import --dsn postgres://user:pass@host:5432/mydb
pg export --dsn postgres://user:pass@host1:5432/db1 | pg import --dsn postgres://user:pass@host2:5432/db2

# DSN can also be used for local instances (useful when ports differ from defaults)
pg export --dsn postgres://admin:pass@127.0.0.1:35432/mydb | pg import --dsn postgres://admin:pass@127.0.0.1:35433/mydb --clean

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
  • .sql or .sql.gz → plain SQL format
  • .gz extension 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

PostgreSQL health inspection report guide for pgcli

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 — --html writes one self-contained file you can mail or archive; --json feeds 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

# Local instance: terminal summary with the health score
pg inspect -i myinst

# Remote database (passwords are never shown in output)
pg inspect --dsn postgres://user:pass@host:5432/db

# The raw section tables: queries, indexes, vacuum/tables, settings, sessions
pg inspect -i myinst --full

# Self-contained HTML report, optionally opened in a browser
pg inspect -i myinst --html /tmp/report.html --open

16 - Configuration Tuning

Hardware-aware postgresql.conf tuning for pgcli instances and Patroni clusters

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> --apply gets 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 inspect flags settings — the inspection report can point at undersized shared_buffers / low cache-hit ratios; pg tune is 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>)

pg tune -i myinst                       # recommendation table only
pg tune -i myinst --apply               # write it into pgcli's managed block
pg tune -i myinst --revert              # remove the applied tune again

--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>)

pg tune --ha app                        # read specs from the leader, print table
pg tune --ha app --apply --auto-restart # write to the DCS; Patroni rolls it out
pg tune --ha app --revert --auto-restart

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

pg tune -i myinst --apply-conf /tmp/tune.conf              # save the recommendation…
$EDITOR /tmp/tune.conf                                     # …hand-edit it…
pg tune -i myinst --apply --apply-conf /tmp/tune.conf      # …apply the file instead

--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

pg tune --dsn postgres://user:pass@host:5432/db

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

pg tune --web-tune                  # start server on http://127.0.0.1:10864
pg tune --web-tune --listen :8080   # custom port
pg tune --web-tune --open           # start server and open browser

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_cost 4 for hdd and for dw-on-SSD (with a warning about catastrophic index scans), 1.1 otherwise; effective_io_concurrency by storage class (Linux only).
  • Parallelism from CPU count (omitted below 4 cores); autovacuum_max_workers at ≥16/≥32 cores; version gates: jit=off ≥12, wal_compression on/lz4 ≥10/≥15, io_method/io_workers ≥18 (io_method is always worker — io_uring additionally needs a --with-liburing build and kernel support, and a mismatch makes the server fail to start, so it is not auto-recommended), desktop-only wal_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

PostgreSQL KV cache functions for lightweight Redis replacement

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

# Deploy both BYTEA and JSONB variants (default)
pg kv -i myinst --database myapp

# Deploy only BYTEA (generic binary values)
pg kv -i myinst --database myapp --type bytea

# Deploy only JSONB (structured JSON with field updates)
pg kv -i myinst --database myapp --type json

# Target a remote database via DSN
pg kv --dsn postgres://user:pass@host:5432/otherdb --database myapp

# Preview SQL without executing
pg kv -i myinst --database myapp --dry-run

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

CREATE UNLOGGED TABLE kv_cache (
    key TEXT PRIMARY KEY,
    value BYTEA NOT NULL,
    expires_at TIMESTAMPTZ NOT NULL
);

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

-- Write a cache entry (text converted to bytea)
SELECT kv_set('session:abc123', 'user_id=42'::bytea, 3600);

-- Read as text
SELECT kv_get_text('session:abc123');
-- Returns: user_id=42

-- Read as raw bytea (hex encoding)
SELECT kv_get('session:abc123');
-- Returns: \x757365725f69643d3432

-- Check if key exists (returns NULL if expired or missing)
SELECT kv_get('session:xyz') IS NOT NULL;

-- Delete a key
SELECT kv_del('session:abc123');

-- Cleanup expired entries (run periodically via cron or application)
SELECT kv_cleanup_batch(1000);

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

CREATE UNLOGGED TABLE kv_cache_json (
    key TEXT PRIMARY KEY,
    value JSONB NOT NULL,
    expires_at TIMESTAMPTZ NOT NULL,
    created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_kv_cache_json_gin ON kv_cache_json USING GIN (value);

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

-- Write a JSON object
SELECT json_set('user:42', '{"name":"Alice","age":30,"roles":["admin","user"]}'::jsonb, 3600);

-- Read a field as text
SELECT json_get_field('user:42', 'name');
-- Returns: Alice

-- Read a field as JSONB (preserves type)
SELECT json_get_field_json('user:42', 'age');
-- Returns: 30 (as integer)

-- Read the whole object
SELECT json_get('user:42');
-- Returns: {"age": 30, "name": "Alice", "roles": ["admin", "user"]}

-- Check if object contains a subset
SELECT json_contains('user:42', '{"age":30}'::jsonb);
-- Returns: t

-- Update a single field (preserves other fields, smart type conversion)
SELECT json_update_field('user:42', 'age', '31');
SELECT json_get_field('user:42', 'age');
-- Returns: 31 (still an integer, not a string)

-- Add a new field
SELECT json_update_field('user:42', 'email', 'alice@example.com');

-- Update field and reset TTL
SELECT json_update_field('user:42', 'age', '32', 7200);

-- Update field without changing expiration
SELECT json_update_field('user:42', 'age', '33', NULL);

-- Delete a key
SELECT json_del('user:42');

-- Cleanup expired entries
SELECT json_cleanup_batch(1000);

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:

  1. Application-level cron — call kv_cleanup_batch(1000) every 5-10 minutes from your app or a cron job.
  2. pg_cron extension — if installed, schedule SELECT kv_cleanup_batch(1000) via cron.schedule.
  3. 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:

# Check function count (should be 15 for --type all)
pg exec -i myinst -c "SELECT COUNT(*) FROM pg_proc WHERE proname LIKE 'kv_%' OR proname LIKE 'json_%'"

# List all KV functions
pg exec -i myinst -c "SELECT proname FROM pg_proc WHERE proname LIKE 'kv_%' OR proname LIKE 'json_%' ORDER BY proname"

Expected output for --type all:

         proname
-------------------------
 json_cleanup_batch
 json_contains
 json_del
 json_get
 json_get_field
 json_get_field_json
 json_set
 json_set_with_result
 json_update_field
 kv_cleanup_batch
 kv_del
 kv_get
 kv_get_text
 kv_set
 kv_set_with_result
(15 rows)

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).

# Run tests against a local instance
bash scripts/test-kv-cache.sh myinst

# Run tests against the default instance
bash scripts/test-kv-cache.sh

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:

-- Remove BYTEA variant
DROP TABLE IF EXISTS kv_cache CASCADE;

-- Remove JSONB variant
DROP TABLE IF EXISTS kv_cache_json CASCADE;

The CASCADE clause removes dependent functions automatically.

18 - Toolbox

Standalone tools that extend pgcli beyond PostgreSQL management

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

Install ZFS and manage pools, datasets and snapshots directly from pgcli

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

pg zfs install

The command is zero-configuration and idempotent:

  1. Verifies privileges (root, or working sudo -n).
  2. Enables the required repository (contrib on Debian, universe on Ubuntu, the OpenZFS release repository on Fedora and Rocky).
  3. Installs ZFS, kernel headers/module and the ZED daemon.
  4. Loads the module and enables the ZFS systemd units for boot.
  5. 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:

pg zfs status
OS:            Debian GNU/Linux 12 (bookworm)
Family:        debian
ZFS:           installed
Userland:      zfs-2.1.11-1+deb12u1
Kernel module: 2.1.11-1+deb12u1 (loaded)
Kernel:        6.1.0-54-cloud-amd64
Privilege:     root
Pools:         none
Secure Boot:   disabled

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

# 1. Install ZFS
pg zfs install

# 2. See which disks are usable
pg zfs disk list

# 3. Two whole disks -> mirror automatically
pg zfs pool create tank /dev/vdb /dev/vdc

# 4. Confirm the pool is online
pg zfs pool list

# 5. A dataset for backups
pg zfs dataset create tank/backups

# 6. Snapshot before an upgrade
pg zfs snapshot create tank/backups@before-upgrade

# 7. Roll back if the upgrade went wrong
pg zfs snapshot rollback tank/backups@before-upgrade

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:

# 1. Import and destroy the old pool if its data is no longer needed
pg zfs pool import fast
pg zfs pool destroy fast --force

# 2. Clear the ZFS labels and the partition table
pg zfs raw zpool labelclear -f /dev/vdd
pg zfs raw zpool labelclear -f /dev/vde
wipefs -a /dev/vdd /dev/vde

# 3. Create the new pool
pg zfs pool create tank /dev/vdd /dev/vde

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
pg zfs pool create tank /dev/vdb /dev/vdc              # mirror
pg zfs pool create data /dev/vdb /dev/vdc /dev/vdd     # raidz1
pg zfs pool create bulk /dev/vdb /dev/vdc /dev/vdd /dev/vde   # raidz2

Override with --type:

pg zfs pool create fast /dev/vdb /dev/vdc /dev/vdd /dev/vde --type mirror
pg zfs pool create safe /dev/vdb /dev/vdc /dev/vdd /dev/vde /dev/vdf --type raidz3

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:

# Two mirror vdevs (striped mirrors)
pg zfs pool create fast --type mirror \
  --data /dev/vdb,/dev/vdc --data /dev/vdd,/dev/vde

# Two raidz2 vdevs
pg zfs pool create bulk --type raidz2 \
  --data /dev/vdb,/dev/vdc,/dev/vdd,/dev/vde \
  --data /dev/vdf,/dev/vdg,/dev/vdh,/dev/vdi

--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

pg zfs pool create tank /dev/vdb /dev/vdc \
  --type mirror \
  --special /dev/vdd,/dev/vde \
  --log /dev/vdf,/dev/vdg \
  --cache /dev/vdh \
  --spare /dev/vdi

--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:

pg zfs pool add tank --class spare /dev/vdi

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):

pg zfs pool create tank /dev/vdb /dev/vdc \
  --prop ashift=13 --fs-prop compression=lz4 --fs-prop atime=on

-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

pg zfs pool list
pg zfs pool status tank
pg zfs pool status --brief          # only pools with problems
pg zfs pool scrub tank
pg zfs pool scrub tank --stop

Growth and repair:

# Add another mirror vdev
pg zfs pool add tank /dev/vdd /dev/vde --type mirror

# Attach /dev/vdd to the mirror holding /dev/vdc (2-way to 3-way)
pg zfs pool attach tank /dev/vdc /dev/vdd

# Shrink it back
pg zfs pool detach tank /dev/vdd

# Replace the failing /dev/vdc with the new /dev/vde
pg zfs pool replace tank /dev/vdc /dev/vde

# Temporarily take a disk out
pg zfs pool offline tank /dev/vdc
pg zfs pool online tank /dev/vdc

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:

pg zfs pool export tank

An exported pool is not imported at boot. Attach it again by listing the importable pools and importing it:

pg zfs pool import                  # list importable pools
pg zfs pool import tank
pg zfs pool import tank --dir /dev/disk/by-id --no-mount

pg zfs pool destroy deletes the pool, every dataset and all data:

pg zfs pool destroy tank

destroy only works on an imported pool. If the pool was exported, import it first:

pg zfs pool import tank
pg zfs pool destroy tank

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 install enables zfs-load-module, zfs-import-cache, zfs-mount and zfs-zed (when the unit exists).
  • zpool create records the pool in /etc/zfs/zpool.cache; on boot zfs-import-cache imports it.
  • ZFS does not use /etc/fstab. Each dataset’s mountpoint is a ZFS property, and zfs-mount mounts 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:

systemctl is-enabled zfs-load-module zfs-import-cache zfs-mount zfs-zed
pg zfs raw zpool get cachefile fast
pg zfs raw zfs get mountpoint fast

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

pg zfs dataset create tank/data
pg zfs dataset create tank/a/b/c --parents
pg zfs dataset create tank/logs --prop quota=50G --prop recordsize=128K

pg zfs dataset list
pg zfs dataset list tank/data --props quota,recordsize,compression

pg zfs dataset set tank/data compression=lz4 quota=100G

pg zfs snapshot create tank/data@snap1
pg zfs snapshot create tank/data@weekly --recursive
pg zfs snapshot list tank/data
pg zfs snapshot rollback tank/data@snap1
pg zfs snapshot destroy tank/data@snap1

pg zfs dataset clone tank/data@snap1 tank/clone --prop compression=off
pg zfs dataset destroy tank/clone
pg zfs dataset destroy tank/a --recursive

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.

# Snapshot before an upgrade
pg zfs snapshot create tank/data@before-upgrade

# Roll back (asks for confirmation)
pg zfs snapshot rollback tank/data@before-upgrade

# Non-interactive automation
pg zfs snapshot rollback tank/data@before-upgrade --force

# Newer snapshots exist: --recursive also destroys them
pg zfs snapshot rollback tank/data@before-upgrade --recursive --force

# Clones block the rollback: -R also destroys dependent clones
pg zfs raw zfs rollback -R tank/data@before-upgrade

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:

# Create a writable copy of a snapshot
pg zfs dataset clone tank/data@snap1 tank/clone

# Set properties on the clone
pg zfs dataset clone tank/data@snap1 tank/clone \
  --prop compression=off --prop mountpoint=/tank/clone

# Detach the clone; the original snapshot can then be destroyed
pg zfs raw zfs promote tank/clone
pg zfs snapshot destroy tank/data@snap1
pg zfs dataset destroy tank/clone --force

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

pg zfs raw zpool status -v
pg zfs raw zpool set comment="backup pool" tank
pg zfs raw zfs create -o compression=off tank/scratch
pg zfs raw zpool create -f -O compression=gzip temp /dev/vdb

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:

pg zfs snapshot create tank/data@send
pg zfs raw zfs send tank/data@send | pg zfs raw zfs receive tank/recv

Permissions

Every command that invokes zpool or zfs first checks privileges:

  • root: runs directly;

  • non-root with working sudo -n: all ZFS commands run through sudo -n;

  • otherwise: fails immediately with

    ZFS management requires root: run "sudo pg zfs ..." or configure passwordless sudo for this user

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:

pg zfs raw zpool labelclear -f /dev/vdb
wipefs -a /dev/vdb

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:

pg zfs raw zpool labelclear -f /dev/vdb1
wipefs -a /dev/vdb

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

Use the pgmq extension to build durable message queues inside PostgreSQL

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:

pg extension install pgmq -i pg01

To use it from another database in the same instance:

pg psql -i pg01 -- -d appdb
CREATE EXTENSION IF NOT EXISTS pgmq;

Check the installed version:

pg exec -i pg01 "SELECT extversion FROM pg_extension WHERE extname = 'pgmq'"

Quick start

-- Create a queue; running it again on an existing queue is a no-op
SELECT pgmq.create('jobs');

-- Send one message; returns its message id
SELECT pgmq.send('jobs', '{"task": "resize", "image_id": 42}');

-- Read up to 10 messages, invisible to other consumers for 60 seconds
SELECT msg_id, read_ct, message
FROM pgmq.read('jobs', 60, 10);

-- Acknowledge one message
SELECT pgmq.delete('jobs', 1);

Run the same statements through pgcli:

pg exec -i pg01 "SELECT pgmq.send('jobs', '{\"task\": \"resize\"}')"
pg psql -i pg01

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

SELECT pgmq.send('jobs', '{"task": "resize"}');

-- Visible only after 30 seconds
SELECT pgmq.send('jobs', '{"task": "cleanup"}', 30);

-- Batch send: one statement, one atomic insert
SELECT pgmq.send_batch('jobs', ARRAY[
  '{"task": "a"}',
  '{"task": "b"}',
  '{"task": "c"}'
]::jsonb[]);

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

SELECT msg_id, read_ct, enqueued_at, vt, message
FROM pgmq.read('jobs', 60, 10);

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:

SELECT pgmq.delete('jobs', 42);                -- one message
SELECT pgmq.delete('jobs', ARRAY[42, 43, 44]); -- batch

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:

SELECT msg_id, message
FROM pgmq.read_with_poll('jobs', 60, 10, 5, 100);

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:

  1. read sets vt = now() + timeout and increments read_ct.
  2. The message stays invisible to other consumers until vt.
  3. delete or archive removes it. If the consumer crashes or does not acknowledge in time, the message becomes visible again after vt and 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:

SELECT pgmq.set_vt('jobs', 42, 300);            -- one message
SELECT pgmq.set_vt('jobs', ARRAY[42, 43], 300); -- batch

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:

SELECT pgmq.archive('jobs', 42);
SELECT pgmq.archive('jobs', ARRAY[42, 43]);
SELECT * FROM pgmq.a_jobs;

A simple dead-letter pattern is to archive messages that failed too many times:

-- Move messages that have been delivered 5 or more times to the archive
SELECT pgmq.archive('jobs', ARRAY(
  SELECT msg_id FROM pgmq.q_jobs WHERE read_ct >= 5
));

Without a rule like this, a poison message is redelivered forever.

Monitoring

SELECT * FROM pgmq.list_queues();
SELECT * FROM pgmq.metrics('jobs');
SELECT * FROM pgmq.metrics_all();

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:

SELECT count(*) FROM pgmq.q_jobs;

SELECT msg_id, read_ct, enqueued_at, vt, message
FROM pgmq.q_jobs
ORDER BY msg_id DESC
LIMIT 5;

Alert when queue_length keeps growing or oldest_msg_age_sec climbs: either consumers are down or they cannot keep up.

Maintenance

SELECT * FROM pgmq.list_queues();

SELECT pgmq.create_unlogged('scratch');                    -- no WAL, lost on crash
-- Partitioned queues require pg_partman: pg extension install pg_partman -i pg01
SELECT pgmq.create_partitioned('events', '10000', '100000');
SELECT pgmq.purge_queue('jobs');                           -- delete all messages, keep queue
SELECT pgmq.drop_queue('jobs');                            -- queue and archive tables
  • create_unlogged trades crash safety for write speed. Use it only for data that can be regenerated.
  • create_partitioned requires the pg_partman extension. It keeps a very high-throughput queue from bloating a single table; old partitions are dropped automatically.
  • purge_queue empties a queue but keeps it; drop_queue removes 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:

GRANT USAGE ON SCHEMA pgmq TO app;
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA pgmq TO app;
GRANT SELECT, INSERT, UPDATE, DELETE ON pgmq.q_jobs TO app;
GRANT SELECT, INSERT ON pgmq.a_jobs TO app;   -- only when archiving

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

Manage host-local SQLite databases and replicate WAL changes to S3-compatible storage with 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.yaml and a per-name state directory under <base>/sqlite/<name>/.
  • pg sqlite create enables journal_mode=WAL, which is persistent in the database header.
  • pg sqlite replication enable renders a dedicated litestream.yml (mode 0600) and starts litestream replicate in 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 in pg.yaml and are never printed by any pg sqlite command.

Install

pg sqlite install

The command is idempotent and installs two host binaries:

  1. sqlite3 from the distribution package manager if it is not already on PATH (apt-get, dnf, yum, pacman, zypper or apk). Only this step may need root; pgcli uses passwordless sudo -n and fails with a clear message instead of prompting for a password.
  2. Litestream 0.5.17, downloaded from GitHub and verified against the published SHA256 checksum. It goes to /usr/local/bin when writable, otherwise to ~/.local/bin.

Useful flags:

pg sqlite install --install-dir /opt/bin      # choose where Litestream goes
pg sqlite install --force-litestream          # re-download a supported version

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

# Default path: <base>/sqlite/appdb/appdb.db
pg sqlite create appdb
pg sqlite create analytics --path /srv/data/analytics.db

pg sqlite list
pg sqlite info appdb
pg sqlite info appdb --integrity full

pg sqlite exec appdb "CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)"
pg sqlite exec appdb "INSERT INTO items (name) VALUES ('first')"

# Read SQL from stdin (no command-line length limit)
pg sqlite exec appdb - <<'SQL'
INSERT INTO items (name) VALUES ('second');
INSERT INTO items (name) VALUES ('third');
SQL

pg sqlite shell appdb
pg sqlite shell appdb -- -header -column

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:

# Install a named MinIO instance; the output prints the S3 API endpoint.
# The default endpoint is http://127.0.0.1:9000 when the port is free.
pg addon install minio --name sqlite-s3

# The root user defaults to "admin"; the password is generated at install.
S3_SECRET=$(pg addon password minio --name sqlite-s3)

# Register the endpoint with the bundled mc wrapper and create the bucket.
pg mc alias set local http://127.0.0.1:9000 admin "$S3_SECRET"
pg mc mb local/sqlite-backups

If port 9000 was already taken, use the endpoint reported by pg addon list instead. Then point replication at the local store:

pg sqlite replication enable appdb \
  --s3-endpoint http://127.0.0.1:9000 \
  --s3-bucket sqlite-backups \
  --s3-access-key admin \
  --s3-secret-key "$S3_SECRET" \
  --s3-uri-style path

Any other MinIO or S3-compatible store uses the same flags with its own endpoint and credentials:

pg sqlite replication enable appdb \
  --s3-endpoint http://127.0.0.1:9000 \
  --s3-bucket sqlite-backups \
  --s3-access-key minioadmin \
  --s3-secret-key minioadmin \
  --s3-uri-style path

AWS S3 with the default credential chain (no keys on the command line):

pg sqlite replication enable appdb \
  --s3-bucket my-org-sqlite-backups \
  --s3-region eu-west-1 \
  --s3-uri-style virtual-host
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

pg sqlite replication status appdb
pg sqlite replication status              # all databases
pg sqlite replication logs appdb -n 100
pg sqlite replication logs appdb -f
pg sqlite replication stop appdb          # final sync, up to 30s
pg sqlite replication start appdb
pg sqlite replication disable appdb       # stop + remove litestream.yml

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:

pg mc ls local/sqlite-backups/sqlite/appdb -- --recursive

A populated prefix looks like this:

0000/0000000000000001-0000000000000001.ltx
0000/0000000000000002-0000000000000002.ltx
0001/0000000000000001-0000000000000002.ltx
0009/0000000000000001-0000000000000002.ltx

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:

pg autostart enable --sqlite --name appdb
pg autostart status

--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 autostart disable --sqlite --name appdb

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:

# Restore a copy to another path; replication keeps running
pg sqlite replication restore appdb --target /tmp/appdb-restored.db

# Point in time by RFC3339 timestamp (UTC with a `Z` suffix)
pg sqlite replication restore appdb --target /tmp/appdb-old.db \
  --timestamp 2026-01-01T12:00:00Z --integrity full

# Point in time by Litestream transaction id (hex; 0x prefix optional)
pg sqlite replication restore appdb --target /tmp/appdb-tx.db --txid 00000000000000ff

# Show what would be restored without writing anything
pg sqlite replication restore appdb --dry-run

--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:

# Current local transaction id (the `local: ... txid=...` line)
pg sqlite replication status appdb

# Or inspect the uploaded LTX ranges in S3
pg mc ls local/sqlite-backups/sqlite/appdb -- --recursive

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:

pg sqlite replication restore appdb \
  --target /tmp/appdb-pitr.db \
  --txid 0000000000000004 \
  --integrity full

sqlite3 -readonly /tmp/appdb-pitr.db \
  "SELECT COUNT(*) FROM items; PRAGMA journal_mode;"

The timestamp form follows the same flow:

pg sqlite replication restore appdb \
  --target /tmp/appdb-pitr-ts.db \
  --timestamp 2026-01-01T12:00:00Z \
  --integrity full

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:

pg sqlite replication restore appdb --force

The sequence is deliberately conservative:

  1. Litestream is stopped (a final sync runs) and other applications must have been stopped manually first.
  2. The current database and its WAL sidecars are moved to <db>.pre-restore-<UTC>/ (confirmation unless --force).
  3. The backup is restored into a temporary directory next to the database, so a failed restore never touches the preserved copy.
  4. The restored database is checked and switched to WAL if necessary, then moved into place with an atomic rename on the same filesystem.
  5. Local Litestream state is reset. If reset fails, the stale state directory is removed.
  6. Replication stays stopped by default. --restart starts 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:

PRAGMA busy_timeout=5000;   -- wait for locks instead of failing immediately
PRAGMA synchronous=NORMAL;  -- recommended with WAL (durability trade-off)
PRAGMA wal_autocheckpoint=1000;

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/disable can take up to 30 seconds.
  • One Litestream process and one WAL writer per database.
  • --clean-data and in-place restore are destructive; they prompt by default, and the preserved .pre-restore-* directories are left for manual cleanup.

18.4 - TimescaleDB

Use TimescaleDB hypertables and time_bucket analytics for time-series data inside PostgreSQL

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_bucket and first/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

pg extension install timescaledb -i pg01 --auto-restart

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:

pg psql -i pg01 -- -d appdb
CREATE EXTENSION IF NOT EXISTS timescaledb;

Check the installed version and license:

pg exec -i pg01 "SELECT extversion FROM pg_extension WHERE extname = 'timescaledb'"
pg exec -i pg01 "SHOW timescaledb.license"

The Pigsty package reports timescale.

Quick start

1. Create an instance

pg create -i tsdb
pg start -i tsdb

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

pg extension install timescaledb -i tsdb --auto-restart

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:

pg psql -i tsdb

Create a table and convert it to a hypertable:

CREATE TABLE sensor (
    time      timestamptz NOT NULL,
    sensor_id text NOT NULL,
    value     double precision
);

SELECT create_hypertable('sensor', by_range('time', INTERVAL '1 day'));
CREATE INDEX ON sensor (sensor_id, time DESC);

Or create it in one step; the default chunk interval applies and can be changed afterwards:

CREATE TABLE sensor (
    time      timestamptz NOT NULL,
    sensor_id text NOT NULL,
    value     double precision
) WITH (tsdb.hypertable);

SELECT set_chunk_time_interval('sensor', INTERVAL '1 day');

Choose the chunk interval to fit the query and retention window. Existing chunks keep their interval when you change it later.

4. Write data

INSERT INTO sensor
SELECT now() - (i * INTERVAL '1 minute'),
       'sensor_' || (i % 3 + 1),
       random() * 100
FROM generate_series(1, 1440) AS i;

SELECT time_bucket('1 hour', time) AS bucket,
       sensor_id,
       round(avg(value)::numeric, 2) AS avg_value
FROM sensor
WHERE time > now() - INTERVAL '24 hours'
GROUP BY bucket, sensor_id
ORDER BY bucket DESC, sensor_id;

5. Daily operations

Append new rows, inspect chunks and jobs, and enable automatic maintenance:

INSERT INTO sensor VALUES (now(), 'sensor_1', 42.0);

SELECT show_chunks('sensor');
SELECT * FROM timescaledb_information.jobs;

ALTER TABLE sensor SET (
    timescaledb.compress,
    timescaledb.compress_segmentby = 'sensor_id',
    timescaledb.compress_orderby = 'time DESC'
);
CALL add_columnstore_policy('sensor', INTERVAL '7 days');
SELECT add_retention_policy('sensor', INTERVAL '365 days');

Operational commands:

pg status -i tsdb               # instance and backup status
pg logs -i tsdb -n 100          # server log tail
pg export -i tsdb -o sensor.dump

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 time as the partition column;
  • CREATE / ALTER / DROP RETENTION POLICY is translated into TimescaleDB’s own add_retention_policy / add_compression_policy jobs — no pg_cron is 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.
pg addon install influx2tsdb --pg-name tsdb

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:

SELECT sensor_id,
       first(value, time) AS first_value,
       last(value, time) AS last_value
FROM sensor
GROUP BY sensor_id;

Latest row per series:

SELECT DISTINCT ON (sensor_id) sensor_id, time, value
FROM sensor
ORDER BY sensor_id, time DESC;

Daily buckets in a specific timezone:

SELECT time_bucket('1 day', time, 'Asia/Shanghai') AS day,
       count(*)
FROM sensor
GROUP BY day
ORDER BY day DESC;

Fill gaps in a bucket sequence:

SELECT time_bucket_gapfill('1 hour', time) AS bucket,
       avg(value)
FROM sensor
WHERE time BETWEEN now() - INTERVAL '6 hours' AND now()
GROUP BY bucket
ORDER BY bucket;

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:

ALTER TABLE sensor SET (
    timescaledb.compress,
    timescaledb.compress_segmentby = 'sensor_id',
    timescaledb.compress_orderby = 'time DESC'
);

-- Compress chunks older than 7 days; runs as a background job
CALL add_columnstore_policy('sensor', INTERVAL '7 days');

Check the compression status and the space used:

SELECT * FROM hypertable_columnstore_stats('sensor');
SELECT * FROM chunk_columnstore_stats('sensor');

Retention policies

Expire chunks automatically once they fall outside the retention window:

SELECT add_retention_policy('sensor', INTERVAL '365 days');

-- Inspect or remove the policy
SELECT * FROM timescaledb_information.jobs
WHERE proc_name = 'policy_retention';

SELECT remove_retention_policy('sensor');

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:

CREATE MATERIALIZED VIEW sensor_hourly
WITH (timescaledb.continuous) AS
SELECT time_bucket('1 hour', time) AS bucket,
       sensor_id,
       avg(value) AS avg_value
FROM sensor
GROUP BY bucket, sensor_id;

SELECT add_continuous_aggregate_policy('sensor_hourly',
    start_offset      => INTERVAL '3 hours',
    end_offset        => INTERVAL '1 hour',
    schedule_interval => INTERVAL '1 hour');

Query the rollup like a normal table:

SELECT * FROM sensor_hourly
ORDER BY bucket DESC
LIMIT 10;

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:

SELECT create_hypertable('sensor', by_range('time', INTERVAL '1 day'));

-- Existing chunks keep their ranges; new chunks use 6 hours
SELECT set_chunk_time_interval('sensor', INTERVAL '6 hours');

SELECT time_interval
FROM timescaledb_information.dimensions
WHERE hypertable_name = 'sensor';

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

SELECT show_chunks('sensor');
SELECT show_chunks('sensor', older_than => INTERVAL '30 days');
SELECT show_chunks('sensor', newer_than => INTERVAL '1 day');

SELECT chunk_name, range_start, range_end
FROM timescaledb_information.chunks
WHERE hypertable_name = 'sensor'
ORDER BY range_start DESC
LIMIT 10;

older_than and newer_than filter by the chunk’s time range, not by its creation time.

Chunk sizes

SELECT chunk_name,
       pg_size_pretty(pg_total_relation_size(
           format('%I.%I', chunk_schema, chunk_name)::regclass)) AS total_size
FROM timescaledb_information.chunks
WHERE hypertable_name = 'sensor'
ORDER BY range_start DESC;

SELECT pg_size_pretty(hypertable_size('sensor'));
SELECT * FROM hypertable_detailed_size('sensor');

Split a chunk

Split one chunk at a timestamp inside its range; take the chunk name from show_chunks('sensor'):

CALL split_chunk('_timescaledb_internal._hyper_1_2_chunk',
                 '2026-10-26 12:00:00+00'::timestamptz);

split_chunk is a procedure; use CALL, not SELECT.

Remove chunks

-- Chunks entirely older than 90 days, or newer than tomorrow
SELECT count(*) FROM drop_chunks('sensor', older_than => INTERVAL '90 days');
SELECT count(*) FROM drop_chunks('sensor', newer_than => now() + INTERVAL '1 day');

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:

pg extension install pg_cron -i pg01 --auto-restart
SELECT cron.schedule('drop-sensor-chunks', '0 3 * * *',
  $$SELECT drop_chunks('sensor', older_than => INTERVAL '90 days')$$);

Manual columnstore conversion

With compression settings configured (see Compression and columnstore), convert a single chunk without waiting for the policy:

CALL convert_to_columnstore('_timescaledb_internal._hyper_1_1_chunk');
SELECT chunk_name, is_compressed
FROM timescaledb_information.chunks
WHERE hypertable_name = 'sensor'
ORDER BY range_start DESC;

CALL convert_to_rowstore('_timescaledb_internal._hyper_1_1_chunk');

Both procedures take a chunk name, not the hypertable name.

Metadata views

SELECT * FROM timescaledb_information.hypertables;
SELECT * FROM timescaledb_information.dimensions;
SELECT * FROM timescaledb_information.chunks;
SELECT * FROM timescaledb_information.jobs;

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:

SELECT timescaledb_pre_restore();   -- before replacing the data files, when possible
-- restore the instance
SELECT timescaledb_post_restore();  -- after the restored instance starts

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:

CREATE TABLE sensor (
    time      timestamptz NOT NULL,
    sensor_id text NOT NULL,
    value     double precision,
    PRIMARY KEY (sensor_id, time)
);

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

SQL commands guide for pgcli

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.

pg exec "SELECT version()"
pg exec -i proj01 "SELECT count(*) FROM users"
pg exec "CREATE TABLE test (id serial PRIMARY KEY, msg text)"

Container command mode (after –)

Arguments after -- are run directly inside the container (as root).

pg exec -- pg_isready
pg exec -- ls -la /var/lib/postgresql/data
pg exec -- bash -c "cat /var/lib/postgresql/data/postgresql.conf"
pg exec -- tail -f /var/log/postgresql/postgresql-*.log

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 exec --dsn postgres://user:pass@host:5432/db "SELECT count(*) FROM users"

pg psql

Interactive session

pg psql                          # default instance
pg psql -i proj01                # specific instance

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)

echo "SELECT version();" | pg psql        # SQL from stdin
pg psql -- -c "SHOW work_mem"             # single command
pg psql -- -d other_db                    # connect to different database
pg psql -- -U other_user                  # connect as different user

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:

pg psql -i pg01 -- -U postgres -d postgres

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:

# Create an extension that requires superuser privileges
pg psql -i pg01 -- -U postgres -d postgres -c "CREATE EXTENSION pg_cron"

# Configure cron.database_name (pg_cron specific parameter)
pg psql -i pg01 -- -U postgres -d postgres -c "ALTER SYSTEM SET cron.database_name = 'pg01_db'"

# View system-level configuration
pg psql -i pg01 -- -U postgres -d postgres -c "SHOW shared_preload_libraries"

Remote database (–dsn)

pg psql --dsn postgres://user:pass@host:5432/db

pg shell

Open an interactive bash shell inside the PostgreSQL container.

pg shell                             # interactive bash
pg shell -i proj01                   # specific instance
pg shell -- -c "ls -la /var/lib/postgresql/data"
pg shell -- -c "cat /etc/postgresql/postgresql.conf"

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

  • --dsn and --instance are mutually exclusive: the connection string determines host, port and database, so -i is rejected to avoid silent misuse.
  • With --dsn, the database is the path part of the URL: postgres://user:pass@host:5432/mydb connects to mydb. 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 an instance and remove its configuration

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

# Destroy instance (keeps data directory)
pg destroy -i proj01

# Destroy without confirmation prompt
pg destroy -i proj01 --force

# Destroy with data cleanup (fresh start)
pg destroy -i proj01 --clean-data

# Skip confirmation and clean all data
pg destroy -i proj01 --clean-data --force

What Happens

When you run pg destroy:

  1. Stop container — PostgreSQL is gracefully shut down
  2. Remove container — The Podman container is deleted
  3. Remove configuration — The instance entry is removed from ~/.pgcli/pg.yaml
  4. Preserve data (by default) — Host data directory at --base-dir/<instance> is kept

With --clean-data:

  1. All of the above, plus:
  2. Remove host data — The data directory is deleted
  3. Remove WAL archives — Any WAL files on the host are removed
  4. 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:

# Destroy and clean all data
pg destroy -i proj01 --clean-data

# Recreate with the same name
pg create -i proj01 --base-dir /data/pg

# Start the new instance
pg start -i proj01

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:

$ pg destroy -i proj01
!  This will destroy instance "proj01":
   - Container: pgcli-pg-default-proj01
   - Data dir: /data/pg/proj01 (preserved)

Continue? [y/N]:

Use --force to skip the prompt:

pg destroy -i proj01 --force

Use Cases

1. Clean Restart After Configuration Change

If you changed the default user, password, or PostgreSQL version in the config, destroy and recreate:

# Update configuration
# Edit ~/.pgcli/pg.yaml to change postgres.user or image_tag

# Destroy with data cleanup
pg destroy -i proj01 --clean-data --force

# Recreate with new settings
pg create -i proj01 --base-dir /data/pg
pg start -i proj01

2. Remove Test Instance

After testing, remove an instance you no longer need:

pg destroy -i test-instance --force

3. Troubleshooting Corrupted State

If an instance is in a bad state (e.g., failed to start, corrupted data), destroy and recreate:

pg destroy -i broken-instance --clean-data --force
pg create -i broken-instance --base-dir /data/pg
pg start -i broken-instance

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:

# Step 1: Destroy the replica
pg destroy -i ro1 --force

# Step 2: On the primary, drop the replication slot
pg replica drop ro1 -i primary-instance

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-data permanently 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
  • pg create — Create a new instance
  • pg start — Start an instance
  • pg stop — Stop an instance (container remains)
  • pg replica drop — Remove replication slot from primary

21 - Logs

View PostgreSQL and addon console output 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

pg logs                              # Last 50 lines (default instance)
pg logs -i proj01                    # Specific instance
pg logs -n 200                       # Last 200 lines

Follow logs in real-time

pg logs -f                           # Follow mode (Ctrl+C to exit)
pg logs -i proj01 -f                 # Follow specific instance
pg logs -n 100 -f                    # Start from last 100 lines, then follow

Show all available logs

pg logs -n 0                         # All logs (no line limit)

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:

pg logs addon pgbouncer -i proj01    # PgBouncer logs for proj01
pg logs addon pgbouncer -i proj01 -f # Follow PgBouncer logs
pg logs addon pgbouncer -i proj01 -n 100  # Last 100 lines

Remote addons

View logs for standalone PgBouncer instances targeting remote databases:

pg logs addon pgbouncer --pg-name my-pool
pg logs addon pgbouncer --pg-name my-pool -f
pg logs addon pgbouncer --pg-name my-pool -n 200

etcd members

etcd is a top-level infra addon — select the member with --name (defaults to etcd):

pg logs addon etcd --name m1           # Member m1 logs
pg logs addon etcd --name m1 -f        # Follow m1
pg logs addon etcd -n 200              # Member "etcd", last 200 lines

PgDog proxies

PgDog is also a top-level infra addon — select the proxy with --name (defaults to pgdog):

pg logs addon pgdog --name pgdog       # Proxy logs
pg logs addon pgdog --name pgdog -f    # Follow

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):

pg logs addon haproxy --name lb        # Instance logs
pg logs addon haproxy --name lb -f     # Follow

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):

pg logs addon minio --name store       # Instance logs
pg logs addon minio --name store -f    # Follow

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 app --member node1     # node1's logs (scope app)
pg logs ha app -m node1 -f        # Follow
pg logs ha app -m node1 -n 200    # Last 200 lines

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

# Check recent errors
pg logs -n 100 | grep ERROR

# Monitor database activity
pg logs -i prod-db -f

# Debug PgBouncer connection issues
pg logs addon pgbouncer -i myapp -f

# View remote pooler logs
pg logs addon pgbouncer --pg-name analytics-pool -n 50

# Watch an etcd member for leader elections / peer issues
pg logs addon etcd --name m1 -f

# Watch PgDog pool activity
pg logs addon pgdog --name pgdog -f

# Watch HAProxy health-check / failover transitions
pg logs addon haproxy --name lb -f

# Watch MinIO request errors
pg logs addon minio --name store -f

# Watch a Patroni member / diagnose a stuck replica
pg logs ha app --member node1 -f

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-name instead of -i to identify the target pooler; etcd members, PgDog proxies, HAProxy instances, and MinIO instances use --name

22 - Administration

Administration guide for pgcli

Shell Completion

Enable tab completion for commands, flags, and instance names.

Bash

# Linux
pg completion bash > /etc/bash_completion.d/pg

# macOS (with Homebrew bash-completion)
pg completion bash > $(brew --prefix)/etc/bash_completion.d/pg

# Or load in current session
source <(pg completion bash)

Zsh

# Enable completion system (once)
echo "autoload -U compinit; compinit" >> ~/.zshrc

# Install completion
pg completion zsh > "${fpath[1]}/_pg"

Fish

pg completion fish > ~/.config/fish/completions/pg.fish

PowerShell

pg completion powershell > pg.ps1
# Source from your PowerShell profile

PostgreSQL Configuration

Modify PostgreSQL runtime parameters via pg exec with ALTER SYSTEM, then reload:

# Change a parameter
pg exec "ALTER SYSTEM SET work_mem = '256MB'"
pg exec "SELECT pg_reload_conf()"

# For a specific instance
pg exec -i proj01 "ALTER SYSTEM SET effective_cache_size = '4GB'"
pg exec -i proj01 "SELECT pg_reload_conf()"

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).

# Show current configuration (YAML)
pg config show

# Show configuration as JSON
pg config show --json

# Validate config file structure
pg config validate

Init generates a default config; --add creates a named instance in the same file, -o writes to a custom path:

pg config init --add default --base-dir /data/pg
pg config init --add proj01 --base-dir /data/pg -o ./my-pg.yaml

# Separate data and backup onto different disks
pg config init --base-dir /data/pg --backup-dir /mnt/backup/pg --add default

--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):

pg config init --namespace t1 --pg-start-port 38000 --pg-ssh-port 43000 --add proj01 -o ~/.pgcli-t1/pg.yaml
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.