Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

79 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

pgpeek

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.

What it looks like

┌─ 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_CAP rows; an enormous result set is never fully buffered into memory. The UI tells you when output was capped.
  • Statement timeoutstatement_timeout is 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.

Architecture

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/v5 for 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 via go:embed.

LLM-friendly project notes live in llms.txt; the published docs site serves the same file at /llms.txt.

Read-only enforcement (defense in depth)

  1. The real boundary: the DB role (descoperead) has no write privileges. That's what actually keeps the data safe.
  2. Session-level: pgpeek sets default_transaction_read_only=on on every pooled connection.
  3. App-layer guardrail (internal/guard): rejects anything that isn't a single SELECT/WITH/VALUES/TABLE/EXPLAIN statement — 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.

MCP

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.

Descope OAuth with Dynamic Client Registration

pgpeek can act as an OAuth resource server while Descope handles Dynamic Client Registration (DCR), login, consent, and token issuance:

  1. 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.
  2. Enable Dynamic Client Registration and define the scope or scopes that should grant access to pgpeek's read-only tools.
  3. 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.

Configuration (env vars)

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).

Multiple databases / clusters

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=prod

Numbered 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=Analytics

Mounted 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-urls

Docker 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:ro

RDS / Aurora IAM authentication

Set 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.

Run locally

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

Keyboard: Ctrl/Cmd + Enter runs the query.

Build

make build                 # static binary (CGO disabled)
make image                 # snapshot distroless image via goreleaser + ko
docker build -t pgpeek .   # alternative: hand-written multi-stage Dockerfile

Builds 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.

Testing & quality

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 above

A 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)

Releases

  • release-please watches main for Conventional Commits and maintains a release PR (version bump + CHANGELOG.md). Merging it tags vX.Y.Z and cuts a GitHub Release.
  • The tag triggers goreleaser (.goreleaser.yaml), which builds the binaries and uses ko to publish multi-arch distroless images to ghcr.io/descope-sample-apps/pgpeek:{version,major.minor,latest} with SBOMs. Release builds pin Go FIPS mode with GOFIPS140=v1.0.0 and GODEBUG=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.

Deploy to k8s

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.

Kubernetes security and secret mounting

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:

  1. Single DB, env from Secret — the shipped k8s/deployment.yaml reads DATABASE_URL from the pgpeek-db Secret. Create that Secret out-of-band with kubectl create secret, Sealed Secrets, External Secrets Operator, or your platform's secret manager sync.
  2. 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.
  3. Multiple DBs, env from Secret — set PGPEEK_DATABASE_URLS from a Secret when your secret manager already publishes the whole DSN list as one value. Keep PGPEEK_DATABASE_IDS and PGPEEK_DATABASE_NAMES in a ConfigMap when they are non-secret.
  4. 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.
  5. 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 by urlFile.
  6. No static DB password — for RDS/Aurora, set PGPEEK_DB_IAM_AUTH=true, PGPEEK_AWS_REGION, use a passwordless DSN, and annotate k8s/serviceaccount.yaml for IRSA with rds-db:connect on 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_URL identical to the Server URL registered in Descope.
  • If using Cloudflare Access, set PGPEEK_REQUIRE_CLOUDFLARE_ACCESS=true only when the origin cannot be reached except through Cloudflare.
  • Keep public infrastructure endpoints open: /healthz, /readyz, and /.well-known/oauth-protected-resource remain 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.

A note on scaling

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.

Auth

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.

Managing preset queries

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.go and 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.

Endpoints

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.

About

Minimal, read-only, team-shared Postgres browser — a single static Go binary. Preact UI, CSV export, saved queries, read-only by design.

Resources

Stars

5 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages