PG Insight
An open multi-tenant monitoring service: anyone signs up, adds their own PostgreSQL and sees only the databases they added
What this project proves
- Tenant isolation: a global guard checks the owner on every :targetId route and returns 404, not 403, for someone else's target; adding an unguarded mutating route fails a test
- SSRF protection: every IP in the DNS answer is checked and the connection is pinned to the verified IP (keeping TLS SNI); production rejected platform-db, 169.254.169.254 and 127.0.0.1
- Limits on arbitrary SQL: EXPLAIN goes through six layers of checks and runs READ ONLY with a 5 s statement_timeout; pg_sleep was cut off with 400 in 0.05 s and generate_series(1, 10000000000) in 5.4 s
- I measured the load on monitored databases: 0.06% of one core over 10 minutes, +0.3% container CPU
- Operations: SHA-tagged deploys, recorded only after a health check, and a two-way rollback drill
- Role
- Solo: design, backend, frontend, security, tests, deployment and operations (81 commits)
- Period
- 2026.08 – 2026.10
- Stack
- NestJS
- TypeScript
- Prisma
- PostgreSQL
- TimescaleDB
- Socket.IO
- React
- Vite
- Tailwind CSS
- Recharts
- Jest
- Vitest
- Docker
- Caddy
Key numbers
| Item | Value | Type | Evidence |
|---|---|---|---|
| Queries sent to the target in 10 minutes | 2,430 | Measured | View evidence |
| Load on the target (share of one CPU core) | 0.06 % | Measured | View evidence |
| Average query execution time | 0.15 ms | Measured | View evidence |
| Collected data lag | 1.4 s | Measured | View evidence |
| Time to block pg_sleep inside EXPLAIN | 0.05 s | Measured | View evidence |
| Automated tests (188 backend + 49 frontend) | 237 | Measured | View evidence |
The problem
Slow queries, lock waits, bloat and replication lag in a production PostgreSQL have to be checked separately across tools and hand-written SQL. I brought them into one service, and because it is a public service that connects to other people's databases, tenant isolation, SSRF protection and limits on arbitrary SQL had to be there from the start.
Architecture
Collectors gather metrics from each database every 3 seconds to 5 minutes and write them to TimescaleDB (10 hypertables), while live pages query the target directly. Users, targets, alerts and audit logs live in a platform DB (PostgreSQL 16) managed with Prisma 7. Global NestJS 11 guards (Throttler → JWT → TargetAccess) check ownership on every :targetId route, and target queries run as raw SQL on a per-target pool (max 5). There are two roles, admin (the first account) and user (up to 3 own targets, public addresses only), and the UI is in Korean, English and Uzbek.
Data model
Key constraints and indexes
- users.role DEFAULT 'user', bcrypt 12
- refresh_tokens.tokenHash UNIQUE: SHA-256 해시만 저장, 사용할 때마다 교체
- targets.passwordEncrypted: AES-256-GCM, createdByUserId = 테넌트 소유자, sslMode DEFAULT 'require'
- hypertable 10개, 1일 chunk; connection_metrics, query_metrics는 7일 뒤 압축, retention 7–30일
- audit_logs: 로그인, 가입, 대상 변경, 모든 EXPLAIN 기록
Key design decisions
An open multi-tenant service, not a demo
- Context
- Anyone should be able to monitor their own database.
- Decision
- Open sign-up, with each user seeing only the targets they registered.
- Trade-off
- Tenant isolation, SSRF protection, quotas and rate limits became mandatory.
Ownership checks in a global guard, not in controllers
- Context
- A new route must not be able to skip the check.
- Decision
- Global guards in the order Throttler → JWT → TargetAccess check every :targetId route.
- Trade-off
- Routes keyed by other IDs need the @TargetAccess decorator.
404, not 403, for someone else's target
- Context
- Even the existence of a target should not be revealed.
- Decision
- Without permission the response is the same 404 as for a target that does not exist.
- Trade-off
- Existence has to be checked separately for admins too.
Resolve the host and pin to the IP
- Context
- If DNS answers differently at check time and connect time (DNS rebinding), an attacker can reach internal hosts.
- Decision
- Every IP in the DNS answer is checked with net.BlockList, and the connection goes straight to the verified IP.
- Trade-off
- TLS SNI has to be passed by hand, and self-hosting needs the TARGET_ALLOW_PRIVATE_HOSTS option.
Keep EXPLAIN, but READ ONLY + 5 s + a function denylist
- Context
- Owners should see plans for their own databases, but EXPLAIN ANALYZE actually executes the query.
- Decision
- After six layers of checks it runs in a READ ONLY transaction with statement_timeout and lock_timeout.
- Trade-off
- Time and function limits can reject some legitimate queries.
New targets default to sslmode=require
- Context
- The pg driver has no libpq-style prefer (TLS, falling back to plain), so the default prefer was in practice an unencrypted connection.
- Decision
- Switched new targets and the database default to require (47287b4).
- Trade-off
- For a database without TLS the user has to choose disable explicitly.
Migrations are never rolled back
- Context
- A rollback that only swaps the image is fast and safe.
- Decision
- Every migration is additive (add first, clean up later).
- Trade-off
- Every migration has to stay compatible with the previous code.
Before and after
- What changed
- The collection orchestrator started in onModuleInit before the pools connected; it now starts in onApplicationBootstrap, with a lifecycle test that checks the restart order (6246ce2). In the same round I fixed the lock query RENAME error, metric timestamps, paused state, the TLS default and /health returning 500 instead of 503.
Incident write-up
- Symptom
- After an API restart no target was monitored; the overhead measurement showed 0 queries from PG Insight.
- Root cause
- The log showed Starting collection for 0 active target(s): the orchestrator started in onModuleInit before the pools had connected, and data from 14:23 to 17:25 UTC was lost.
- Fix
- Moved startup to onApplicationBootstrap (6246ce2); both targets are collected right after a restart.
- Prevention
- Added a lifecycle test that reproduces the bug and also checks that paused targets are skipped; the new tests were confirmed to fail on the old code.
Tests and quality
- Tests
- 237
- Coverage
- —
- CI
- GitHub Actions (워크플로 준비, billing 문제로 현재 중단)
- Test tools
- Jest, Supertest, Vitest, 실제 PostgreSQL 16
Deployment and operations
- Deployment
- iwinv VPS on Docker Compose: api (NestJS, non-root), web (Caddy with the React static files, proxying /api/* and /socket.io/*), platform-db (PostgreSQL 16) and metrics-db (TimescaleDB). Database ports are not exposed. deploy.sh: conflict-marker check → git merge --ff-only → build api and web with SHA tags → wait for health 45 × 2 s → record APP_TAG and .deploy-history only on success. The API container runs prisma migrate deploy on startup.
- Backups
- The server-wide cron backup (daily at 03:00, kept 7 days) includes platform-db and metrics-db. pg_dump backups of user databases can only be created by an admin.
- Rollback
- rollback.sh recreates api and web from existing images without a build, checking first that both images exist. Tested in production in both directions (71dceaf ↔ d587ce6), with health 200 both times.
- Monitoring
- In real use: the LedgerCore and FlashDrop production databases are registered and monitored (internal Docker network on the same server). /api/v1/health (503 when the database is down), audit logs (login, sign-up, target changes, every EXPLAIN), sign-up limited to 5 per hour per IP and login to 10 per minute. GitHub private vulnerability reporting (SECURITY.md)
Screenshots
Limits and retrospective
Current limits
- No account management yet: email verification, password reset and account deletion are missing
- Diagnostics, security check results, recommendations and error messages come from the backend in English only (the UI itself is in three languages)
- The connection_by_app hypertable has no retention and grows without bound, and the metrics DB has no migration mechanism (init-platform.sql only runs when the volume is first created)
- The real-time socket is protected by JWT and ownership, but the UI does not call subscribeToTarget yet and refreshes by REST polling
- Monitoring queries are not optimized yet: pg_settings is re-read every 10 s and each target has a pool of 5; caching and a 2–3 connection pool are next
- query_metrics storage (about 4 MB per target per hour) is an estimate from a 22-minute window; the 24-hour measurement is still pending
- The two existing targets (LedgerCore, FlashDrop) use prefer, meaning unencrypted connections, on the internal Docker network (the traffic never leaves the server)
- The CI workflow is ready but stopped by a GitHub Actions billing issue, so tests run locally. The key rotation script (rotate-encryption-key.ts) predates the Prisma 7 adapter and is untested in production, and there are no Playwright E2E tests
Key code
pg-insight-back/src/auth/target-access.guard.tsGlobal ownership check; someone else's target is a 404
pg-insight-back/src/auth/admin-routes.spec.tsA test that finds unguarded mutating routes through metadata
pg-insight-back/src/targets/target-host.policy.tsSSRF: BlockList, every IP in DNS, IP pinning + SNI
pg-insight-back/src/live/live-query.service.tsEXPLAIN with six layers of defence, READ ONLY, statement_timeout / lock_timeout
pg-insight-back/src/targets/target-pool.manager.tsTarget pools, reconnecting with the decrypted password, keeping the paused state
pg-insight-back/src/collector/collector.orchestrator.tsonApplicationBootstrap, reconnect on error, skipping paused targets
pg-insight-back/src/real-time/real-time.gateway.tsHandshake JWT and ownership check on room subscription
pg-insight-back/docker/init-platform.sqlHypertables, compression and retention policies
scripts/measure-overhead.shScript that measures load on the target
scripts/deploy.sh, scripts/rollback.shConflict guard, SHA tags, recording only after a health check
Current status and next steps
Collection load on a monitored database was 2,430 queries and 353.4 ms of total execution in 10 minutes, about 0.06% of one CPU core. I found 5 bugs on real production databases (LedgerCore, FlashDrop) and 3 more in code review and tests, and reproduced each one with a test before fixing it. Other cases: a real-time broadcast error that stopped the collectors (b9825e4), a RENAME syntax error in the lock query (b9825e4), connection metrics stamped with the session start time (d587ce6, 891 wrong rows deleted), paused targets turning themselves back on after a restart (41569c7), SSL settings lost on a password change and prefer not actually encrypting (41569c7, 47287b4), and a first rollback drill where the script stopped safely because the old image was missing.