pgAdmin 4
pgAdmin 4 is PostgreSQL’s official web
administration UI — the browser console for browsing schemas, running SQL, and
watching servers. pgcli runs it as a standalone, top-level addon with the
same CLI surface as the other web/infra addons (install / start / stop / logs /
autostart / remove), managed entirely through pg.yaml.
It is the fleet’s one UI addon, which shapes its design:
- A web app, not a database front. pgAdmin dials the servers you register
in its own UI; it does not front one specific PostgreSQL instance the way
PostgREST or PgBouncer do. That is why it is
a top-level-only addon (
addons.pgadmin.<name>) with no per-instance sidecar. - Its own login. The web UI needs an email + password to sign in. That is
not a PostgreSQL password — it is pgAdmin’s own account, auto-generated on
first install and stored in
pg.yaml. Retrieve it later withpg addon password pgadmin. - Persistent session store. pgAdmin keeps its config, saved servers, and
browse history in a sqlite DB under a bind-mounted data dir. It survives
pg addon remove(without--clean-data) and is revived on reinstall. - Optional one-time seed.
--dsnor--pg-name <instance>pre-registers one server in the UI’sservers.jsonat install, so the browser opens with that server already listed. It is a convenience, not a runtime coupling — pgAdmin never blocks on it, and you can add/remove servers in the UI after.
Tip: one pgAdmin per database, spin up on demand. A pgcli-managed pgAdmin is a single lightweight container that starts in seconds and consumes very little memory — cheap enough to treat as disposable. Rather than running one shared pgAdmin with every server registered in it, create a dedicated instance for the database you are working on right now, and remove it when done:
Each instance gets its own port (auto-assigned), its own login, and its own pgpass — no cross-contamination between databases, no stale connections lingering in a shared session store. When you need it again, reinstall in seconds.
Out of scope: pgAdmin serves plain HTTP here — no TLS termination is wired in (put a reverse proxy in front if you need HTTPS).
servers.jsonitself cannot carry the target server’s password (pgAdmin refuses to import one), but when the seed DSN does carry one, pgcli pre-configures it through pgAdmin’s passfile support so the seeded server connects without prompting — see Seeding a server.
Parameters
One container from the upstream docker.io/dpage/pgadmin4 image (pull-only —
pgcli never builds it), configured entirely through -e env vars the entrypoint
reads. The table lists every knob and its default:
Flag / pg.yaml key |
Default | Env var | Meaning |
|---|---|---|---|
--image / image_tag |
docker.io/dpage/pgadmin4:9.18 |
— | override the tag verbatim |
--port / host_port |
auto from pgadmin_start_port (base 5050) |
PGADMIN_LISTEN_PORT |
web host port |
--listen / listen |
127.0.0.1 |
PGADMIN_LISTEN_ADDRESS |
bind address (0.0.0.0 to expose — see Listening and exposure) |
--email / email |
admin@pgcli.lan |
PGADMIN_DEFAULT_EMAIL |
web login account (validated — see Troubleshooting) |
--password / password |
generated, 20 chars | PGADMIN_DEFAULT_PASSWORD |
web login password — not a PG password |
--dsn / dsn |
unset | — (renders servers.json + pgpass) |
one server to pre-register, given as a URI — when it carries a password, it is pre-filled too (host is dialed from the container — see Seeded host vs. listen address) |
--pg-name |
unset | — (renders servers.json + pgpass) |
resolve that one server from a locally-managed instance instead |
--data-dir / data_dir |
<base-dir>/addon/pgadmin/<name>/data |
(/var/lib/pgadmin) |
where the session store lives |
| — | autostart: false |
— | start on boot (pg autostart enable --pgadmin) |
How the knobs combine:
email+passwordonly take effect on a fresh data dir. pgAdmin readsPGADMIN_DEFAULT_*while it initialisespgadmin4.db; once that file exists the stored account is authoritative and the env vars are ignored. A reinstall that revives a kept dir therefore prints a password that is inert against it (the install says so) — to actually reset the login, remove with--clean-datafirst, or pin--passwordto the original when reviving.--dsnand--pg-nameare mutually exclusive — both mean “seed this one server”, one by URI and the other by resolving a local instance’s endpoint.--nameis the addon’s own key and has nothing to do with--pg-name. The seed’s host is where pgAdmin connects from, not where your browser does — see Seeded host vs. listen address.listendefaults to loopback — the web UI is a login gate, so it stays on127.0.0.1unless you pass--listen 0.0.0.0to reach it from another host (the summary then warns that the login is the only gate). Full picture in Listening and exposure.emailmust survive the image’s validator — the entrypoint checks it withemail_validatorand rejects reserved TLDs (.local,.invalid,.test) outright, crash-looping on one. The defaultadmin@pgcli.lanis chosen to pass; pick your own on the same basis (.lan,.internal-style names you control are fine — a real, deliverable address is not required, the deliverability check is off).
Install
The install summary prints the URL and the (generated) web login:
The Seeded: / Seed auth: lines appear only when --dsn/--pg-name was
passed. Seed auth: says password pre-configured (pgpass) when the DSN
carries a password, and no password in the DSN — first connect will prompt
when it does not. Re-running install is idempotent: a running container is
left alone, a stopped one is started. To apply a changed
port/listen/email/seed, add --force (the container is recreated from the
config; the data dir is untouched).
Rebuilding with --force
pg addon install pgadmin without --force is a no-op on a running container
— it leaves the live config alone and just makes sure the container is up.
That is exactly the wrong behaviour when you meant to change something
(port, listen, email, image, seed DSN, --pg-name target, data_dir): the
running container is pinned to the config it was created with, so the edit
looks accepted but nothing changes until the next recreate.
--force is the knob that applies such edits. It stops the running container,
removes it, and creates a new one from the current config; the data dir is
untouched, so saved servers and browse history come back in the new
container:
Two caveats worth stating:
email/passwordstill only re-apply to an empty data dir. pgAdmin readsPGADMIN_DEFAULT_*while it initialisespgadmin4.db; once that file exists the stored account is authoritative and the env vars are ignored.--forcealone does not reset the web login — pair it with--clean-data(which removes the store first) to actually reset the account.- A running container is recreated, not left alone. If the UI is open in a
browser,
--forcekills the session; the next page load re-authenticates against the new container.
Using the web UI
Point a browser at the URL from the summary (on the host that runs podman),
sign in with the login email + password, and pgAdmin’s own server tree appears.
Add connections in the UI, or pre-register one at install with
–dsn / –pg-name.
If the host isn’t where your browser is, set --listen 0.0.0.0 (the
login is then the only gate):
Listening and exposure
--listen (config key listen) is the address pgAdmin’s HTTP server binds, and
it defaults to loopback. The three postures, each applied with --force since
the container is recreated to pick up the change:
- On Linux the container shares the host network, so
--listen 0.0.0.0answers on<host-ip>:<port>and--listen 127.0.0.1on127.0.0.1:<port>. Widening to0.0.0.0puts pgAdmin behind nothing but its email/password login — the install summary warns so — so keep loopback unless the network is trusted. - On macOS the picture inverts: the bridge network always binds
0.0.0.0inside the podman machine and pgcli publishes the port, solistenis effectively widened there regardless, and you reach the UI from the Mac at127.0.0.1:<port>via gvproxy. See Platform.
Retrieving the web login
pg addon list shows the URL and login email but never the password unless
you pass --show-password.
Seeding a server
--dsn and --pg-name both render a one-time servers.json that pgAdmin
imports on first boot, so the browser opens with that server already in its
tree. --pg-name resolves the endpoint from a locally-managed instance (its
host/port/database/user and password, read from pg.yaml); --dsn gives
it as a URI:
What the seed does and does not carry:
- The target server’s password IS pre-configured when the DSN carries one.
servers.jsonitself cannot hold a password (pgAdmin refuses to import one), so pgcli writes a libpqpgpassfile beside it and points the seeded entry at it viaConnectionParameters.passfile— an absolute path pgAdmin honours natively (documented in the pgAdmin 9.18 Import/Export Servers example). Connecting from the browser skips the password prompt entirely. A DSN without a password (e.g.postgres://app@host/db) rendersservers.jsononly, and pgAdmin prompts on first connect as before. - The pgpass file holds the password in plaintext on disk. It is mode 0600
under
<base-dir>/addon/pgadmin/<name>/pgpass, readable only by the host user and inside the container by uid 5050 — but it is on disk, unlike aservers.jsonwhich never holds a secret. The tradeoff is the same one every.pgpass-using tool makes: password-free connects cost a file on disk. Removing the pgAdmin instance (with or without--clean-data) deletes the pgpass alongsideservers.json— the plaintext secret does not outlive the instance. - It’s a one-time convenience, not a coupling. After install, the server
lives in pgAdmin’s own
pgadmin4.db; add, rename, or delete it in the UI. The addon keeps running regardless of whether the seed target is reachable. PGADMIN_REPLACE_SERVERS_ON_STARTUP=Trueis set whenever a seed exists, so a--forcereinstall that changes--dsn/--pg-namere-applies the seed declaratively instead of leaving a stale entry.
To drop the seed and stop the declarative re-load, reinstall with --force and
neither --dsn nor --pg-name.
Seeded host vs. listen address
These two knobs point at different machines and are often conflated. --listen
controls who may reach the pgAdmin web UI; the seed’s host controls where
pgAdmin dials the database from. The dial is made server-side — by the
pgAdmin container, not by your browser — so a locally-managed instance seeded
with --pg-name correctly records 127.0.0.1, because pgAdmin and that
PostgreSQL share the podman host’s loopback. Exposing the UI to the network
does not require the seeded host to be routable from the browser:
The converse pitfall: seed a remote host with --dsn and point a browser at
that same pgAdmin from elsewhere — the connection still originates inside the
pgAdmin container, so that host must be reachable from the container’s
network, not from the laptop.
Ports
Each instance takes one port from pgAdmin’s own pool, pgadmin_start_port
(default 5050) — a separate cursor from Redis’s, Predixy’s, and the object
stores’ pools, so a pgadmin, a redis, and a minio instance never collide:
--port fixes an instance to a specific port; auto-assignment skips anything
already explicit or live on the host.
Configuration
Instances live under the top-level addons.pgadmin map in pg.yaml:
Edits to listen, host_port, email, password, image_tag, dsn/
server_name, or data_dir take effect after the next
pg addon install pgadmin --name console --force (or via the matching flags).
Remember: email/password only re-apply to an empty data dir, so
changing them against an existing store needs --clean-data first (or accept
that the running account is the original one).
List
pg addon list never prints the password. Reveal it deliberately with
pg addon list --show-password (appends a Login password: line) or bare with
pg addon password pgadmin --name console — and prefer --file so the secret
stays out of shell history and scrollback.
Start and stop
start only starts an existing container (and self-heals an improper state by
recreating it from the config). The data dir is preserved across stop/start, so
pgAdmin’s saved servers and browse history come back with it.
Auto-start on Boot
Containers carry a --restart unless-stopped policy (crashes, not reboots). To
bring instances up after a host reboot:
pgAdmin autostart is start-only (install the instance first). On boot the
entrypoint re-chowns /var/lib/pgadmin and replays pgadmin4.db from the data
dir, so the saved servers survive the reboot. pgAdmin is independent of the
PostgreSQL stack — it is a browser UI that dials its servers itself — so there
is no ordering constraint and boot-time pg start --autostart starts it
alongside the other non-database addons.
Remove
Without --clean-data the host data dir (and its pgadmin4.db) stays in place
— reinstalling the same name revives the saved servers and session store (the
revived login is the original one, not a freshly generated password).
--clean-data deletes it, falling back through podman unshare rm when
rootless podman’s mapped uid makes pgAdmin’s files undeletable by the host
user. An empty per-instance parent directory is pruned; a custom data_dir
parent is never touched. servers.json (if seeded) is host-owned and removed
with the instance directory either way.
Logs
Troubleshooting
- Signed in with the printed password and it’s rejected — you reinstalled
over a kept data dir. The login that works is the one pgAdmin stored in
pgadmin4.dbon the first init; the newly generated one is inert (install warns “reviving an existing pgAdmin data dir”). Use--clean-datato start fresh, orpg addon password pgadminto confirm what’s stored, or reinstall with--passwordpinned to the original. - “pgAdmin could not start” / container exits immediately (or crash-loops)
— the entrypoint needs both
PGADMIN_DEFAULT_EMAILandPGADMIN_DEFAULT_PASSWORDon a fresh dir; a hand-editedpg.yamlwith an empty password and a wiped data dir has neither. Let install generate one, or pass--password. It also validates the email and rejects reserved TLDs (.local,.invalid,.test, …) with “does not appear to be a valid email address” — the container then exits and, under the restart policy, spins. That is exactly why the default isadmin@pgcli.lanand not.local. - Can’t reach the UI from another machine —
listendefaults to127.0.0.1. Reinstall--listen 0.0.0.0 --forceand accept that the web login is then the only gate. - The seeded server shows but won’t connect — if the seed DSN carried no
password, pgAdmin prompts for it on first connect (Seeding a
server). If it did carry one and the prompt still
appears, the pgpass file was invalidated — most often because the container
was started without the entrypoint’s
chownrunning (e.g. someone passed--user 5050directly instead of--user 0, so the file is owned by the host user and libpq silently drops it). Confirm the host/port are reachable from where pgAdmin runs. - Image not found at install —
docker.io/dpage/pgadmin4is pulled on demand; on an air-gapped host,podman loadthe tar first (catalog and export steps indocs/images.mdin the repo) and the pull is skipped. - First boot is slow to answer — pgAdmin runs its sqlite migrations before
gunicorn binds; the port answers 302 (to
/login) only once that finishes. On a loaded host this can take tens of seconds.
Known limitations
- No TLS — pgAdmin serves plain HTTP here. For HTTPS put a reverse proxy
(with
PGADMIN_URL_SCHEME/PGADMIN_DISABLE_STATIC_FILE_SERVERtuning) in front; pgcli does not wire it. The loopback default keeps the common case on127.0.0.1. - Not suitable for large data import/export — pgAdmin streams data through
HTTP (query → container → browser for export; upload → container → database
for import), which times out or exhausts memory on large tables. Use
pg export/pg importfor pgcli instances, orpg exec --dsn "..." -- pg_dumpfor arbitrary databases. See Data Import/Export for details. - Seed password lives on disk as plaintext — the pgpass file beside
servers.jsonis mode 0600 but not encrypted; removing the instance cleans it up. There is no way to pre-configure the password without a file on disk (pgAdmin has no KMS/keyring integration). - Login only applies to a fresh store — pgAdmin reads
PGADMIN_DEFAULT_*while it initialisespgadmin4.db; once that file exists the stored account is authoritative. There is no in-place password reset, only--clean-data+ reinstall. - macOS is code-complete, untested — the bridge path (published ports,
proxyBindHostwidening loopback to0.0.0.0on the bridge) mirrors redis; it has not yet been exercised on a Mac.