A minimal, read-only, team-shared Postgres browser. Built to replace Adminer
for the support team. pgweb-style browsing — a sidebar of tables/views you
click to page through rows, a Structure tab showing each table's columns, and
a SQL tab for free-form SELECTs with saved/preset queries — plus CSV export
everywhere. Read-only by design: no row editing, schema management, or migrations.
┌─ tables ──────┬─ Data │ Structure │ SQL ─────────────┐
│ 🔍 filter… │ id email created_at │
│ public │ 1 a@x.com 2026-01-02… │
│ • users ◀ │ 2 b@y.com 2026-01-03… │
│ • companies │ … │
│ auth │ ◀ Prev 1–100 Next ▶ [Export] │
│ • sessions │ │
└───────────────┴───────────────────────────────────────┘
- Data tab — click a table → paged rows (Prev/Next). A global search box
(matches any column), per-column filters with operators (
=,≠,<,>,ILIKE,IS NULL, …), and click-to-sort headers. Foreign-key cells are click-through links that jump to the referenced row. CSV export respects the active search/filters/sort. Wide tables stay usable with sticky headers, clipped cell previews, full values on demand, and mobile overflow containment. - Structure tab — column name, type, nullable, default.
- SQL tab — CodeMirror editor with table/field autocomplete, saved/preset queries, CSV export.
Filtering is safe by construction: column names are validated against the
relation's real columns and emitted via pgx.Identifier, operators come from a
fixed allowlist, values are bound as query parameters, and sort is ASC/DESC
only — no user input is ever concatenated into SQL.
It exists because Adminer kept falling over. pgpeek avoids those failure modes on purpose:
- Connection pooling (
pgx/pgxpool) — not a new connection per request. - Row cap — results stop at
PGPEEK_ROW_CAProws; an enormous result set is never fully buffered into memory. The UI tells you when output was capped. - Statement timeout —
statement_timeoutis set on every pooled session, so a runaway query can't wedge a pod. - Stateless pods — saved queries live in a small SQLite file on a PVC, not in pod memory. The app process holds no per-user state.
browser ── HTTP ──> pgpeek (Go, single static binary)
│ pgx pool ──> Postgres (Aurora, read-only role)
└ SQLite file ──> saved/preset queries (on a PVC)
- Backend: Go,
jackc/pgx/v5for Postgres,modernc.org/sqlite(pure Go, no cgo) for the saved-query store → static binary, ~25 MB distroless image. - Frontend: one
web/index.html— CodeMirror editor (CDN, degrades to a textarea), results table, saved-query dropdown, CSV button. Embedded into the binary viago:embed.
LLM-friendly project notes live in llms.txt; the published docs
site serves the same file at /llms.txt.
- The real boundary: the DB role (
descoperead) has no write privileges. That's what actually keeps the data safe. - Session-level: pgpeek sets
default_transaction_read_only=onon every pooled connection. - App-layer guardrail (
internal/guard): rejects anything that isn't a singleSELECT/WITH/VALUES/TABLE/EXPLAINstatement — blocks multiple statements and DML/DDL keywords, ignoring keywords that appear inside comments or string literals. This is a guardrail against fat-fingering, not the security boundary. Don't rely on it as one.
pgpeek exposes a stateless Streamable HTTP
MCP endpoint at http(s)://<pgpeek-host>/mcp, implemented with the official Go
SDK. MCP responses use JSON rather than long-lived SSE sessions, which keeps the
endpoint simple to run behind an ingress or load balancer. Request bodies are
capped at 32 KiB before protocol parsing. Structured tool output is capped at
448 KiB; query and discovery responses set truncated=true when rows or catalog
entries are omitted to stay within that budget.
The server advertises four structured, read-only tools:
| Tool | Purpose |
|---|---|
list_databases |
List safe database IDs and display names. |
list_tables |
List user-facing tables and views. |
describe_table |
Return columns and single-column foreign keys. |
query |
Run one guarded, row-capped read-only SQL statement. |
For MCP clients that accept a URL-based server entry:
{
"mcpServers": {
"pgpeek": {
"url": "http://localhost:8080/mcp"
}
}
}MCP authentication is optional. With no Descope settings, /mcp remains
unauthenticated and should stay on a trusted network or behind an existing
access proxy. Cross-origin browser requests are rejected, and
PGPEEK_REQUIRE_CLOUDFLARE_ACCESS=true remains an additional gate when enabled.
pgpeek can act as an OAuth resource server while Descope handles Dynamic Client Registration (DCR), login, consent, and token issuance:
- In Descope Agentic Identity Hub, create an MCP Server whose Server URL is
the full public pgpeek endpoint, for example
https://pgpeek.example.com/mcp. - Enable Dynamic Client Registration and define the scope or scopes that should grant access to pgpeek's read-only tools.
- Copy the MCP Server's OpenID well-known URL and configure all three values:
export DESCOPE_MCP_SERVER_WELL_KNOWN_URL='https://api.descope.com/v1/apps/agentic/<project>/<server>/.well-known/openid-configuration'
export PGPEEK_MCP_SERVER_URL='https://pgpeek.example.com/mcp'
export PGPEEK_MCP_REQUIRED_SCOPES='mcp:pgpeek.read'DESCOPE_CONFIG_URL is also accepted as an alias for the well-known URL used
by Descope's B2B MCP example. If both names are set, they must match.
At startup pgpeek fetches the discovery document, verifies that it advertises a
DCR registration_endpoint and every configured scope, then prepares local
verification against its jwks_uri using the supported asymmetric signing
algorithms advertised by Descope. Each /mcp request must present a Bearer
token with the discovered issuer, an aud matching
PGPEEK_MCP_SERVER_URL, a valid expiry, and every configured scope. pgpeek does
not need a Descope management key or project secret.
OAuth clients discover Descope through public RFC 9728 metadata at
/.well-known/oauth-protected-resource; unauthenticated MCP responses point to
that URL in WWW-Authenticate. The metadata endpoint remains public when
Cloudflare Access enforcement is enabled, while /mcp must pass both gates.
Everything is configured via the environment. Single-database deployments can
keep using DATABASE_URL; multi-database deployments can use a URL list,
numbered env vars, or a mounted JSON config file. Secret-bearing URLs can be
supplied from mounted files so they do not live in manifests.
| Variable | Default | Notes |
|---|---|---|
DATABASE_URL |
single-DB required | Postgres DSN for single-database installs. Use the read-only role. Never logged. Aurora: include ?sslmode=require. |
DATABASE_URL_FILE |
— | Path to a file holding the DSN (mounted-secret alternative). |
PGPEEK_DATABASE_URLS |
— | Comma- or semicolon-separated DSNs for multiple databases. Quoted CSV values are supported. |
PGPEEK_DATABASE_IDS |
db1, db2, … |
Optional comma/semicolon IDs matching PGPEEK_DATABASE_URLS; URL-safe (A-Z, a-z, 0-9, _, -, .). |
PGPEEK_DATABASE_NAMES |
Database N |
Optional display names matching PGPEEK_DATABASE_URLS. |
PGPEEK_DATABASE_URL_1 |
— | Numbered DSN form. Continue with _2, _3, …; each also supports _FILE. |
PGPEEK_DATABASE_ID_1 |
db1 |
Optional ID for numbered database 1. |
PGPEEK_DATABASE_NAME_1 |
Database 1 |
Optional display name for numbered database 1. |
PGPEEK_DATABASES_FILE |
— | Path to a mounted JSON config file with database entries. |
PGPEEK_DEFAULT_DATABASE |
first configured DB | Default database ID when the URL has no db= parameter. |
PGPEEK_LISTEN |
:8080 |
Listen address. |
PGPEEK_ROW_CAP |
1000 |
Max rows returned/exported per query. |
PGPEEK_STATEMENT_TIMEOUT |
30s |
Per-query DB statement timeout. |
PGPEEK_IDLE_TX_TIMEOUT |
30s |
idle_in_transaction_session_timeout. |
PGPEEK_MAX_CONNS |
8 |
Max pool size (caps DB connection usage). |
PGPEEK_STORE_PATH |
/data/pgpeek.db |
SQLite file for saved queries. |
PGPEEK_READ_HEADER_TIMEOUT |
10s |
HTTP read-header timeout. |
PGPEEK_WRITE_TIMEOUT |
statementTimeout+30s |
HTTP write timeout (must exceed statement timeout for big exports). |
PGPEEK_IDLE_TIMEOUT |
120s |
HTTP keep-alive idle timeout. |
PGPEEK_SHUTDOWN_TIMEOUT |
15s |
Graceful-shutdown grace period. |
PGPEEK_TLS_CERT_FILE |
— | Enable HTTPS (set together with the key). Otherwise serve plain HTTP behind a TLS-terminating ingress. |
PGPEEK_TLS_KEY_FILE |
— | TLS private key path. |
PGPEEK_REQUIRE_CLOUDFLARE_ACCESS |
false |
Return 403 unless Cloudflare Access headers are present; probes and OAuth protected-resource metadata stay open. |
DESCOPE_MCP_SERVER_WELL_KNOWN_URL |
— | Enable Descope OAuth for /mcp with this MCP Server OpenID discovery URL. DESCOPE_CONFIG_URL is an accepted alias. |
PGPEEK_MCP_SERVER_URL |
— | Full public MCP URL ending in /mcp; used as protected resource and required JWT audience. |
PGPEEK_MCP_REQUIRED_SCOPES |
— | Comma- or whitespace-separated Descope scopes required on every MCP request. |
PGPEEK_DB_IAM_AUTH |
false |
Use RDS/Aurora IAM auth instead of a password (see below). |
PGPEEK_AWS_REGION |
$AWS_REGION |
AWS region for IAM token signing (required when IAM auth is on). |
The UI compresses the selected database, table, tab, filters, sort, pagination,
and SQL editor text into one ?s=... parameter, so links are bookmarkable and
shareable. Older readable URL parameters still open and canonicalize to s.
Opening a shared SQL link restores the editor without running the query.
Packed state is capped at 8 KiB; larger SQL remains editor-only.
Same-env list form:
export PGPEEK_DATABASE_URLS='postgres://reader:PASSWORD@prod:5432/app?sslmode=require;postgres://reader:PASSWORD@analytics:5432/warehouse?sslmode=require'
export PGPEEK_DATABASE_IDS='prod;analytics'
export PGPEEK_DATABASE_NAMES='Production;Analytics'
export PGPEEK_DEFAULT_DATABASE=prodNumbered env var form:
export PGPEEK_DATABASE_URL_1_FILE=/run/secrets/prod-url
export PGPEEK_DATABASE_ID_1=prod
export PGPEEK_DATABASE_NAME_1=Production
export PGPEEK_DATABASE_URL_2_FILE=/run/secrets/analytics-url
export PGPEEK_DATABASE_ID_2=analytics
export PGPEEK_DATABASE_NAME_2=AnalyticsMounted config file form (PGPEEK_DATABASES_FILE=/config/pgpeek/databases.json):
{
"default": "prod",
"databases": [
{ "id": "prod", "name": "Production", "urlFile": "/secrets/prod-url" },
{ "id": "analytics", "name": "Analytics", "urlFile": "/secrets/analytics-url" }
]
}Kubernetes example (ConfigMap-mounted config + Secret-mounted DSNs; illustrative only, not an extra manifest to commit):
env:
- name: PGPEEK_DATABASES_FILE
value: /config/pgpeek/databases.json
volumeMounts:
- name: pgpeek-db-config
mountPath: /config/pgpeek
readOnly: true
- name: pgpeek-db-urls
mountPath: /secrets
readOnly: true
volumes:
- name: pgpeek-db-config
configMap:
name: pgpeek-db-config
- name: pgpeek-db-urls
secret:
secretName: pgpeek-db-urlsDocker Compose example (volume-mounted JSON + secret files; illustrative only):
services:
pgpeek:
environment:
PGPEEK_DATABASES_FILE: /config/pgpeek/databases.json
volumes:
- ./pgpeek-config:/config/pgpeek:ro
- ./pgpeek-secrets:/secrets:roSet PGPEEK_DB_IAM_AUTH=true and PGPEEK_AWS_REGION. The DATABASE_URL then
needs only host/port/user/dbname (no password) and sslmode=require. pgpeek
mints a short-lived IAM auth token from the default AWS credential chain
(env / web-identity / IRSA / instance role) before every new connection,
so tokens never go stale and no static DB password is stored anywhere.
export PGPEEK_DB_IAM_AUTH=true
export PGPEEK_AWS_REGION=us-east-1
export DATABASE_URL='postgres://descoperead@your-cluster.cluster-xxxx.us-east-1.rds.amazonaws.com:5432/yourdb?sslmode=require'In k8s, attach an IRSA-annotated ServiceAccount (see k8s/serviceaccount.yaml)
whose role has rds-db:connect on the descoperead DB user.
export DATABASE_URL='postgres://descoperead:PASSWORD@host:5432/db?sslmode=require'
export PGPEEK_STORE_PATH=./pgpeek.db
go run .
# open http://localhost:8080Keyboard: Ctrl/Cmd + Enter runs the query.
make build # static binary (CGO disabled)
make image # snapshot distroless image via goreleaser + ko
docker build -t pgpeek . # alternative: hand-written multi-stage DockerfileBuilds run with Go FIPS 140-3 mode enabled (GOFIPS140=v1.0.0,
GODEBUG=fips140=on). The hand-written Dockerfile uses Verity's Go FIPS builder
image and keeps the runtime distroless/nonroot.
Release images are built with ko inside goreleaser (distroless, multi-arch, reproducible, with SBOMs) — see Releases.
The backend is at 100% statement coverage on every package under
internal/ (guard, db, store, server, config, awsauth); the front-end
(web/app.js) is at 100% lines/branches/functions via vitest. package main
is thin bootstrap, exercised by integration tests.
make test # unit tests, race detector
make test-integration # + db/main integration tests (needs Postgres)
make cover-check # full coverage profile, fail if internal/ < 100%
make lint # golangci-lint (errcheck, gosec, revive, …)
make vulncheck # govulncheck
make web-test # vitest --coverage (100% thresholds)
make ci # everything aboveA throwaway Postgres for integration/coverage:
docker run -d --name pg -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=testdb -p 55432:5432 postgres:16
make cover-check # uses PGPEEK_TEST_DATABASE_URL (default points at :55432)- release-please watches
mainfor Conventional Commits and maintains a release PR (version bump +CHANGELOG.md). Merging it tagsvX.Y.Zand cuts a GitHub Release. - The tag triggers goreleaser (
.goreleaser.yaml), which builds the binaries and uses ko to publish multi-arch distroless images toghcr.io/descope-sample-apps/pgpeek:{version,major.minor,latest}with SBOMs. Release builds pin Go FIPS mode withGOFIPS140=v1.0.0andGODEBUG=fips140=on.
CI (.github/workflows/ci.yml) runs lint, vet, race tests with a Postgres
service, the 100% coverage gate, govulncheck, the vitest suite, and a snapshot
image build on every PR.
Manifests live in k8s/: Deployment, Service, PersistentVolumeClaim,
an optional Ingress, and a secret.example.yaml.
# 1. Create the DB secret out-of-band (do NOT commit it):
kubectl create secret generic pgpeek-db \
--from-literal=DATABASE_URL='postgres://descoperead:PASSWORD@your-aurora-host:5432/yourdb?sslmode=require'
# 2. Apply the rest:
kubectl apply -k k8s/The pod runs as non-root with a read-only root filesystem (only /data is
writable), drops all capabilities, and has liveness (/healthz) and readiness
(/readyz) probes.
The example Deployment also sets GOFIPS140=v1.0.0 and GODEBUG=fips140=on so
runtime crypto follows the same FIPS profile as release builds.
Production rule: keep DSNs and DB passwords out of Git and out of ConfigMaps.
Use a read-only Postgres role, require TLS to the database (sslmode=require),
and put pgpeek behind SSO before it is reachable by users.
Supported secret patterns, from simplest to most flexible:
- Single DB, env from Secret — the shipped
k8s/deployment.yamlreadsDATABASE_URLfrom thepgpeek-dbSecret. Create that Secret out-of-band withkubectl create secret, Sealed Secrets, External Secrets Operator, or your platform's secret manager sync. - Single DB, mounted Secret file — mount a Secret key as a file and set
DATABASE_URL_FILE=/secrets/database-url. This keeps the secret out of pod env dumps while still using one database. - Multiple DBs, env from Secret — set
PGPEEK_DATABASE_URLSfrom a Secret when your secret manager already publishes the whole DSN list as one value. KeepPGPEEK_DATABASE_IDSandPGPEEK_DATABASE_NAMESin a ConfigMap when they are non-secret. - Multiple DBs, numbered Secret files — mount one Secret file per DSN and
set
PGPEEK_DATABASE_URL_1_FILE=/secrets/prod-url,PGPEEK_DATABASE_URL_2_FILE=/secrets/analytics-url, plus matching IDs and display names. - Multiple DBs, ConfigMap + Secret files — mount non-secret routing config
as
PGPEEK_DATABASES_FILE=/config/pgpeek/databases.json, and keep each database DSN in a Secret-mounted file referenced byurlFile. - No static DB password — for RDS/Aurora, set
PGPEEK_DB_IAM_AUTH=true,PGPEEK_AWS_REGION, use a passwordless DSN, and annotatek8s/serviceaccount.yamlfor IRSA withrds-db:connecton the read-only DB user.
Keep app config and secrets separated: ConfigMaps may hold database IDs, names,
row caps, and timeouts; Secrets should hold DSNs, TLS private keys, and any auth
material. If pgpeek terminates TLS itself, mount certificate/key files from a
Secret and set PGPEEK_TLS_CERT_FILE + PGPEEK_TLS_KEY_FILE; otherwise
terminate TLS at the Ingress or mesh.
Network/auth checklist:
- Do not publish the UI or HTTP API directly to the internet. Use oauth2-proxy, Cloudflare Access/Tunnel, VPN, private ingress, or a service mesh auth layer.
- For public MCP access, configure Descope DCR auth and keep
PGPEEK_MCP_SERVER_URLidentical to the Server URL registered in Descope. - If using Cloudflare Access, set
PGPEEK_REQUIRE_CLOUDFLARE_ACCESS=trueonly when the origin cannot be reached except through Cloudflare. - Keep public infrastructure endpoints open:
/healthz,/readyz, and/.well-known/oauth-protected-resourceremain reachable without Cloudflare Access headers. - Restrict egress to the configured Postgres endpoints and any AWS STS/RDS IAM endpoints required for IAM auth.
- Rotate DB credentials in the backing Secret; restart pods if your secret sync mechanism does not refresh mounted files/env automatically.
The saved-query store is a SQLite file on a ReadWriteOnce PVC, so the
Deployment ships with replicas: 1 and a Recreate strategy. The query path
itself is stateless. To scale horizontally, move the saved-query store to a
shared backend (a dedicated schema in Postgres, or an RWX volume) and bump
replicas — see comments in k8s/pvc.yaml.
The UI and JSON API remain intentionally auth-thin — put them behind your
existing SSO. The example Ingress assumes oauth2-proxy (Entra/Google SAML).
Do not expose them without an auth layer in front of them.
The MCP endpoint can additionally validate Descope-issued OAuth tokens when the three MCP auth variables above are set. This does not authenticate the UI or JSON API.
Cloudflare Access is detected from Cf-Access-Authenticated-User-Email and
shown in the UI. Set PGPEEK_REQUIRE_CLOUDFLARE_ACCESS=true to reject requests
without Cloudflare Access headers. This is not JWT validation; only use it when
the origin is reachable only through Cloudflare Access/Tunnel.
Two ways:
- From the UI: write a query, click Save. Saved queries appear in the dropdown (grouped "Presets" vs "Saved") and persist in the SQLite store.
- Seeded on first boot: edit
internal/store/presets.goand rebuild. These seed only when the store is empty, so they never clobber the team's edits. The shipped presets (custom-domains-per-company, recent signups, table sizes) are illustrative — adjust table/column names to your actual schema.
| Method & path | Purpose |
|---|---|
GET /api/databases |
List configured databases with safe runtime details (version, uptime, size, workload/cache/temp/deadlock/session counters, extensions, and connection limits). Core PostgreSQL does not expose portable host CPU, RAM, or free-disk metrics. |
GET /api/user |
Current detected user (anonymous or Cloudflare Access email). |
POST /api/query?db=<id> |
Run a query → JSON {columns, rows, …}. |
POST /api/export?db=<id> |
Run a query → CSV download. |
GET /api/meta?db=<id> |
Server limits the UI needs ({rowCap}). |
GET /api/tables?db=<id> |
List browsable tables/views (+ row estimate). |
GET /api/tables/{schema}/{table}/columns?db=<id> |
Column structure (name, type, nullable, default). |
GET /api/tables/{schema}/{table}/fks?db=<id> |
Single-column foreign keys (for click-through). |
GET /api/tables/{schema}/{table}/data?db=<id> |
Paged rows; &limit=&offset=&search=&sort=&dir=&f=col:op:val (&format=csv). |
GET /api/queries |
List saved/preset queries. |
POST /api/queries |
Create a saved query. |
PUT /api/queries/{id} |
Update a saved query. |
DELETE /api/queries/{id} |
Delete a saved query. |
GET /mcp |
Protocol-defined Streamable HTTP transport behavior. |
POST /mcp |
Send stateless Streamable HTTP MCP messages. |
DELETE /mcp |
Protocol-defined Streamable HTTP session termination behavior. |
OPTIONS /mcp |
CORS preflight when Descope OAuth protects the MCP endpoint. |
GET /.well-known/oauth-protected-resource |
Public OAuth protected-resource metadata when Descope MCP auth is enabled. |
OPTIONS /.well-known/oauth-protected-resource |
CORS preflight for public OAuth metadata. |
GET /healthz |
Liveness plus {status,version,commit,buildDate} metadata. |
GET /readyz |
Readiness (pings the DB). |
GET / |
The UI. |