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.

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.
public.companies
After clicking a foreign-key cell, pgpeek navigates to the referenced companies row, pre-filtered by id.
02

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.
Structure
pgpeek Structure tab listing each column's name, type, nullability, and default.
03

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

A SQL scratchpad with memory

  • CodeMirror editor (pgsql mode), gracefully degrades to a textarea.
  • Single SELECT / WITH only — enforced.
  • Saved & preset queries, grouped; CSV export; capped-result warning.
  • 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.

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 · 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

Any value can also come from a mounted file via <VAR>_FILE — Docker / Kubernetes secrets friendly.

VariableDefaultNotes
DATABASE_URLrequiredPostgres DSN. Use the read-only role. Never logged. (DATABASE_URL_FILE reads it from a mounted secret.)
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_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.

A small, predictable surface

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

Method & pathPurpose
POST /api/queryRun a query → JSON {columns, rows, …}.
POST /api/exportRun a query → CSV download.
GET /api/metaServer limits the UI needs ({rowCap}).
GET /api/tablesList browsable tables/views (+ row estimate).
GET /api/tables/{schema}/{table}/columnsColumn structure.
GET /api/tables/{schema}/{table}/fksSingle-column foreign keys (for click-through).
GET /api/tables/{schema}/{table}/dataPaged 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.
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

Signed & reproducible

release-please + goreleaser + ko publish multi-arch distroless images with SBOMs to ghcr.io/descope-sample-apps/pgpeek.

pgpeek is intentionally auth-thin — put it behind your own SSO. The example Ingress assumes oauth2-proxy (Entra / Google SAML). Do not expose pgpeek without an auth layer in front of it.

Held to a high bar

100%backend coverage (internal/)
100%frontend coverage (vitest)
~25 MBdistroless image
0cgo dependencies

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 releases

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