read-only by design single static binary

The Postgres browser your support team can't break.

pgpeek is a minimal, read-only, team-shared Postgres browser — a sidebar of tables, paged row browsing, a SQL scratchpad with saved queries, and CSV export everywhere. Built to replace Adminer, shipped as one ~25 MB distroless binary.

Press Ctrl/ + Enter to run a query.

Agent access: concise context structured index full context

localhost:8080
pgpeek browsing the public.users table: a schema sidebar, paged rows, and clickable foreign-key links.

Adminer kept falling over. pgpeek won't.

Every failure mode that made the old tool wedge a pod was designed out on purpose.

connection per request → pooling

Connection pooling

A single pgx/pgxpool fronts Postgres instead of opening a fresh connection on every click — no connection storms.

OOM on huge results → row cap

Hard row cap

Results stop at PGPEEK_ROW_CAP; an enormous table is never fully buffered into memory, and the UI tells you when output was capped.

runaway query → statement timeout

Statement timeout

statement_timeout is set on every pooled session, so a slow query can't wedge the pod for everyone else.

per-pod state → stateless

Stateless pods

Saved queries live in a tiny SQLite file on a PVC. The query path holds no per-user state, so pods are disposable.

Everything you need to read a database. Nothing you don't.

No row editing, no schema management, no migrations — browsing and querying, done well.

Data
pgpeek Data tab with a global search box, per-column filter row, and a sortable column header showing an active sort arrow.
01

Browse & filter any table

  • Click a table → paged rows (Prev / Next), server-capped.
  • Global search across any column.
  • Per-column filters: =, , <, >, , , ILIKE, LIKE, IS NULL, NOT NULL.
  • Click-to-sort headers; CSV export respects active search, filters, and sort.
wide_support_events
pgpeek browsing a very wide support-events table with sticky headers, clipped cell previews, and horizontal scrolling.
02

Wide tables stay readable

  • Sticky headers and filter rows keep context while scrolling.
  • Long cells render compact previews, with full values available on demand.
  • Desktop, tablet, and mobile overflow are contained instead of stretching the page.
public.companies
After clicking a foreign-key cell, pgpeek navigates to the referenced companies row, pre-filtered by id.
03

Foreign keys you can click through

  • FK cells render as links straight to the referenced row.
  • Jumping in applies the matching = value filter automatically.
  • Follow relationships across schemas without writing a single join.
?s=CYIwvADg5g...
pgpeek with the database selector set to Analytics while browsing public.users, with exact view state compressed in the URL.
04

Switch databases without losing the link

  • Pick from named databases or clusters in the header.
  • The selected database and exact view state are compressed into one bookmarkable s parameter.
  • Shared SQL editor text restores without running the query.
Structure
pgpeek Structure tab listing each column's name, type, nullability, and default.
05

Structure at a glance

  • Every column: name, type, nullable, default.
  • One click from the data you're looking at to the shape behind it.
  • Views and materialized views included, clearly marked.
SQL
pgpeek SQL tab: a CodeMirror editor with a syntax-highlighted query, a results table, and a saved-queries dropdown.
06

A SQL scratchpad with memory

  • CodeMirror editor (pgsql mode) with table/field autocomplete; gracefully degrades to a textarea.
  • Single SELECT / WITH only — enforced.
  • Saved & preset queries, grouped; CSV export; capped-result warning.
  • Query errors appear inline under the editor, without replacing the last good result.
  • Ctrl/ + Enter runs.

Pick a palette that fits your eyes.

Twenty built-in color themes, switchable from the header dropdown — pgpeek remembers your choice in the browser. No rebuild, no config.

Default
pgpeek in the Default theme
Dark+
pgpeek in the Dark+ theme
Light+
pgpeek in the Light+ theme
Monokai
pgpeek in the Monokai theme
Dracula
pgpeek in the Dracula theme
One Dark Pro
pgpeek in the One Dark Pro theme
Nord
pgpeek in the Nord theme
Solarized Dark
pgpeek in the Solarized Dark theme
Solarized Light
pgpeek in the Solarized Light theme
GitHub Dark
pgpeek in the GitHub Dark theme
GitHub Light
pgpeek in the GitHub Light theme
Catppuccin Mocha
pgpeek in the Catppuccin Mocha theme
Catppuccin Latte
pgpeek in the Catppuccin Latte theme
Tokyo Night
pgpeek in the Tokyo Night theme
Ayu Dark
pgpeek in the Ayu Dark theme
Ayu Mirage
pgpeek in the Ayu Mirage theme
Night Owl
pgpeek in the Night Owl theme
Houston
pgpeek in the Houston theme
Matcha
pgpeek in the Matcha theme
Dainty
pgpeek in the Dainty theme

Three layers of defense in depth

Read-only isn't a setting you can forget to flip — it's enforced at the role, the session, and the app.

1

The DB role — the real boundary

pgpeek connects with a role that has no write privileges. This is what actually keeps your data safe — everything else is belt-and-suspenders.

2

Session level — every connection

pgpeek sets default_transaction_read_only = on on every pooled connection, so even an accidental write is rejected by Postgres.

3

App guardrail internal/guard

Rejects anything that isn't a single SELECT / WITH / VALUES / TABLE / EXPLAIN — blocking multiple statements and DML/DDL, and aware of keywords hiding inside comments and string literals.

The guard is a guardrail, not the security boundary. It catches fat-fingering — don't rely on it as your only line of defense. The read-only DB role is what protects the data.
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; sort is ASC/DESC only. No user input is ever concatenated into SQL.
Cloudflare Access aware. pgpeek shows the detected Cf-Access-Authenticated-User-Email in the UI. Set PGPEEK_REQUIRE_CLOUDFLARE_ACCESS=true to reject requests without Cloudflare Access headers. This is header enforcement only, not JWT validation; keep the origin reachable only through Cloudflare Access/Tunnel.
OAuth for public MCP access. Optionally configure Descope to validate bearer-token issuer, exact /mcp audience, expiry, and required scopes. Descope owns Dynamic Client Registration, login, consent, and token issuance; pgpeek remains the resource server.

One binary, two stores, zero surprises

The browsed database stays read-only; a tiny independent SQLite file holds saved queries.

Browser Preact + htm UI
pgpeek Go static binary
data sources
Postgresvia pgx pool · read-only role · Aurora-friendly
SQLitesaved & preset queries · on a PVC
BackendGo · jackc/pgx/v5 · modernc.org/sqlite (pure-Go, no cgo)
FrontendPreact + htm · no build step · vendored & embedded via go:embed · CSP-safe
Ships asone ~25 MB distroless image · Go FIPS mode · multi-arch · reproducible · with SBOMs

Running in under a minute

Run pgpeek locally with Docker Compose.

Create a pgpeek-only compose file and start it.

cat > compose.yml <<'YAML'
services:
  pgpeek:
    image: ghcr.io/descope-sample-apps/pgpeek:latest
    environment:
      DATABASE_URL: ${DATABASE_URL}
    ports: ["8080:8080"]
YAML

export DATABASE_URL='postgres://descoperead:PASSWORD@host:5432/db?sslmode=require'
docker compose up

Open http://localhost:8080. Stop with docker compose down.

Point pgpeek at any reachable Postgres with a read-only role.

export DATABASE_URL='postgres://descoperead:PASSWORD@host:5432/db?sslmode=require'
export PGPEEK_STORE_PATH=./pgpeek.db
go run .

Produce a static binary or a distroless image.

make build                 # static binary (CGO disabled)
make image                 # distroless image via goreleaser + ko
docker build -t pgpeek .   # or the hand-written Dockerfile

Everything is an environment variable

Single-database installs keep using DATABASE_URL. Multi-database installs can use URL lists, numbered env vars, or a mounted JSON config file.

VariableDefaultNotes
DATABASE_URLsingle-DB requiredPostgres DSN for single-database installs. Use the read-only role. Never logged. (DATABASE_URL_FILE reads it from a mounted secret.)
PGPEEK_DATABASE_URLSComma/semicolon-separated DSNs for multiple databases. Pair with PGPEEK_DATABASE_IDS and PGPEEK_DATABASE_NAMES.
PGPEEK_DATABASE_URL_1Numbered multi-DB form; continue with _2, _3, … and use _FILE for mounted secrets.
PGPEEK_DATABASES_FILEMounted JSON config file with {default, databases:[{id,name,urlFile}]}.
PGPEEK_DEFAULT_DATABASEfirst DBDefault database ID when the URL has no db=.
PGPEEK_LISTEN:8080Listen address.
PGPEEK_ROW_CAP1000Max rows returned/exported per query.
PGPEEK_STATEMENT_TIMEOUT30sPer-query DB statement timeout.
PGPEEK_IDLE_TX_TIMEOUT30sidle_in_transaction_session_timeout.
PGPEEK_MAX_CONNS8Max pool size (caps DB connection usage).
PGPEEK_STORE_PATH/data/pgpeek.dbSQLite file for saved queries.
PGPEEK_READ_HEADER_TIMEOUT10sHTTP read-header timeout.
PGPEEK_WRITE_TIMEOUTstmt+30sHTTP write timeout (must exceed statement timeout for big exports).
PGPEEK_IDLE_TIMEOUT120sHTTP keep-alive idle timeout.
PGPEEK_SHUTDOWN_TIMEOUT15sGraceful-shutdown grace period.
PGPEEK_TLS_CERT_FILEEnable HTTPS (set with the key file). Otherwise terminate TLS at the ingress.
PGPEEK_TLS_KEY_FILETLS private key path.
PGPEEK_REQUIRE_CLOUDFLARE_ACCESSfalseReturn 403 unless Cloudflare Access headers are present; probes and OAuth protected-resource metadata stay open.
DESCOPE_MCP_SERVER_WELL_KNOWN_URLEnable Descope OAuth for /mcp with the MCP Server OpenID discovery URL. DESCOPE_CONFIG_URL is an accepted alias; if both are set, they must match.
PGPEEK_MCP_SERVER_URLExact public MCP URL ending in /mcp; used as the protected resource and required JWT audience.
PGPEEK_MCP_REQUIRED_SCOPESComma- or whitespace-separated Descope scopes required on every MCP request.
PGPEEK_DB_IAM_AUTHfalseUse RDS/Aurora IAM auth instead of a password.
PGPEEK_AWS_REGION$AWS_REGIONAWS region for IAM token signing (required when IAM auth is on).
RDS / Aurora IAM auth. Set PGPEEK_DB_IAM_AUTH=true and a region, drop the password from the DSN, and pgpeek mints a short-lived IAM token from the default AWS credential chain (env / web-identity / IRSA / instance role) before every new connection — no static DB password stored anywhere.
Multiple clusters are bookmarkable. The UI writes the selected database into the URL as ?db=prod, alongside table, tab, filter, sort, and pagination params. For Kubernetes, mount a ConfigMap at /config/pgpeek/databases.json and Secret files at /secrets/*; for Compose, mount local directories with ./pgpeek-config:/config/pgpeek:ro and ./pgpeek-secrets:/secrets:ro. These are examples only — no extra deploy files are required.
Cloudflare Access gate. pgpeek reads Cf-Access-Authenticated-User-Email for the current user banner and can require Access headers with PGPEEK_REQUIRE_CLOUDFLARE_ACCESS=true. Use it only when Cloudflare Access/Tunnel is the only path to the origin.

Read-only Postgres tools over MCP

Connect an MCP client to the exact public URL https://your-pgpeek.example/mcp. The stateless Streamable HTTP endpoint uses JSON responses and can be protected by Descope OAuth with Dynamic Client Registration.

ToolPurpose
list_databasesList configured database IDs and display names without exposing credentials.
list_tablesList user-facing tables and views in the selected database.
describe_tableReturn columns and single-column foreign keys for a table or view.
queryRun one guarded, row-capped, read-only SQL statement.
Authentication is optional. If all three Descope settings are absent, /mcp remains unauthenticated. Keep that deployment on a trusted network or behind an existing access proxy.
export DESCOPE_MCP_SERVER_WELL_KNOWN_URL='https://api.descope.com/.../.well-known/openid-configuration'
export PGPEEK_MCP_SERVER_URL='https://pgpeek.example.com/mcp'
export PGPEEK_MCP_REQUIRED_SCOPES='mcp:pgpeek.read'
Descope owns the authorization flow. Register the same full /mcp Server URL in Descope, enable DCR, and define the required scopes. At startup pgpeek requires an advertised registration_endpoint, matching scopes, a secure jwks_uri, and supported asymmetric signing algorithms. No Descope management key is required.
Standards-based discovery stays public. RFC 9728 metadata is served at /.well-known/oauth-protected-resource, and unauthenticated /mcp responses link to it in WWW-Authenticate. When Cloudflare Access is also required, metadata remains public while /mcp must pass both gates.

A small, predictable surface

The UI is just a client of these endpoints — script against them directly if you like.

Method & pathPurpose
GET /api/databasesList configured databases → {defaultId, databases:[{id,name}]}.
GET /api/userCurrent detected user: anonymous or a 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.
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/queriesList saved/preset queries.
POST /api/queriesCreate a saved query.
PUT /api/queries/{id}Update a saved query.
DEL /api/queries/{id}Delete a saved query.
POST /mcpSend stateless Streamable HTTP MCP messages; GET and DELETE retain protocol transport behavior.
GET /.well-known/oauth-protected-resourcePublic OAuth protected-resource metadata when Descope MCP auth is enabled.
GET /healthz · /readyzLiveness · readiness (pings the DB).
GET /The UI.

Kubernetes-ready, hardened by default

Manifests live in k8s/ — Deployment, Service, PVC, optional Ingress, and a ServiceAccount.

Runtime

Locked down

Runs nonroot with a read-only root filesystem (only /data writable), drops all capabilities, and ships liveness /healthz + readiness /readyz probes.

Scaling

Stateless query path

The saved-query store is a SQLite file on a ReadWriteOnce PVC, so the Deployment ships replicas: 1 + Recreate. Move it to a shared backend to scale out.

Releases

FIPS, signed & reproducible

release-please + goreleaser + ko publish multi-arch distroless images with SBOMs to ghcr.io/descope-sample-apps/pgpeek. Builds and runtime pin GOFIPS140=v1.0.0 and GODEBUG=fips140=on.

Secrets

Mount credentials, don't commit them

Use Secret-backed env for single DB or PGPEEK_DATABASE_URLS, Secret-mounted files for DATABASE_URL_FILE or numbered _FILE DSNs, and ConfigMap JSON plus Secret urlFile entries for multi-cluster routing.

Security-aware Kubernetes patterns. Keep DSNs and passwords in Kubernetes Secrets or an External Secrets / Sealed Secrets flow, never in Git or ConfigMaps. For RDS/Aurora, prefer IRSA with PGPEEK_DB_IAM_AUTH=true, PGPEEK_AWS_REGION, and a passwordless DSN. If pgpeek terminates TLS itself, mount cert/key files from a Secret and set PGPEEK_TLS_CERT_FILE + PGPEEK_TLS_KEY_FILE; otherwise terminate TLS at the Ingress or mesh.
Deploy behind an access layer. The UI and JSON API are auth-thin by design. Use oauth2-proxy, Cloudflare Access/Tunnel, VPN, private ingress, or service-mesh auth; do not expose them directly. Public MCP access can instead use Descope DCR auth. If PGPEEK_REQUIRE_CLOUDFLARE_ACCESS=true, keep the origin reachable only through Cloudflare. Kubernetes probes and OAuth protected-resource metadata stay open.

Held to a high bar

100%backend coverage (internal/)
100%frontend coverage (vitest)
~25 MBdistroless image
FIPSGo crypto mode pinned

Tested under race

Unit + integration tests run with the race detector against a real Postgres service in CI.

Linted & scanned

golangci-lint (errcheck, gosec, revive, …) and govulncheck gate every change.

Reproducible FIPS releases

Tagged via release-please; built by goreleaser + ko into signed, multi-arch images with SBOMs and Go FIPS mode enabled.