pgAdmin
A database browser at http://localhost:5050, run by docker compose up alongside the rest of
the stack. Development only.
There is no login screen: it runs in desktop mode, because a login wall in front of a database browser you already had to be on this host to reach buys nothing.
The two pre-registered connections
infra/pgadmin/servers.json registers the same database twice, and the difference between them is
the whole point.
| Connection | Role | What it sees |
|---|---|---|
| MotorPH Payroll (owner) | motorph | Everything. Owners bypass row-level security, so every query returns every tenant's rows. |
| MotorPH Payroll (app_runtime — RLS applies) | app_runtime | Nothing, at first. This is the role the application connects as. RLS applies, and a fresh session has no tenant selected. |
Passwords are POSTGRES_PASSWORD and APP_DB_PASSWORD from your .env (defaults motorph and
app_runtime). pgAdmin asks once and offers to save them.
Use the owner connection for ordinary work — inspecting data, checking a migration, fixing a row. Use app_runtime when you want to see what the application sees.
Seeing what a tenant sees
Connected as app_runtime, most tables read empty:
SELECT count(*) FROM employee; -- 0
That is not an empty database. It is row-level security
doing its job: the policy compares tenant_id against an app.tenant_id session variable that the
application sets on every connection checkout, and which a hand-opened session has never set.
Set it yourself, then query:
SELECT set_config('app.tenant_id', '1', false);
SELECT count(*) FROM employee; -- 101, for the default tenant
Switch tenants by re-running set_config with a different id (SELECT tenant_id, slug FROM tenant
as the owner to list them). This is the fastest way to check tenant isolation by hand — if a query
returns another tenant's rows here, isolation is broken.
Note the session variable resets when pgAdmin reconnects, so an unexpected zero usually just means the connection dropped.
Why the owner sees everything
RLS is only enforced for roles that do not bypass it. The application therefore connects as
app_runtime (APP_DB_USER), and Flyway keeps the owning role because a migration has to be able
to see every tenant's rows. RowLevelSecurityCheck logs at startup which side of that line the
backend is on — if it warns, isolation is not being enforced and the analytics views will serve one
tenant another's numbers.
Things that will trip you up
-
servers.jsonis imported on first start only, when the config database is created. Editing it later changes nothing. To re-import, drop pgAdmin's volume — and only pgAdmin's;docker compose down -vwould take the database with it:docker compose rm -sf motorph_payroll_pgadmindocker volume rm "$(docker volume ls -q --filter name=motorph_payroll_pgadmin_data)"docker compose up -d motorph_payroll_pgadminThe volume's real name carries a project prefix derived from your checkout's directory name, which is why the command looks it up rather than spelling it out.
-
The host is
motorph_payroll_db, notlocalhost. Inside the compose network a container's localhost is itself. (Connecting from a tool on your host instead, it islocalhost:5434.) -
PGADMIN_EMAILcannot be any address. pgAdmin validates it for real-world deliverability and rejects reserved TLDs, which is a restart loop rather than an error you would notice. The compose file setsPGADMIN_CONFIG_ALLOW_SPECIAL_EMAIL_DOMAINS: "['local']"so the stack's usual.localaddresses work; a different fake TLD needs adding there too. -
servers.jsonhardcodes the default database and usernames. If you have customisedPOSTGRES_DBorPOSTGRES_USER, edit the connection in the UI — pgAdmin does no environment substitution in that file.
Not for production
This is a full admin console on the database. It is in docker-compose.yml (the development
stack) and deliberately not in docker-compose.client.template.yml, where no host ports are
published at all. Reach a production database through an SSH tunnel instead — see
../deployment/vps-guide.md.