Skip to content

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.