start-vibing-stacks 2.7.3 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/setup.js +2 -2
- package/package.json +1 -1
- package/stacks/_shared/skills/hook-development/SKILL.md +2 -2
- package/stacks/_shared/skills/openapi-design/SKILL.md +409 -0
- package/stacks/_shared/skills/postgres-patterns/SKILL.md +521 -0
- package/stacks/nodejs/skills/fastify-api/SKILL.md +569 -0
- package/stacks/nodejs/stack.json +5 -4
- package/stacks/php/stack.json +3 -2
- package/stacks/python/stack.json +4 -4
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: postgres-patterns
|
|
3
|
+
version: 1.0.0
|
|
4
|
+
description: PostgreSQL design, security, and tuning at the SQL/operations layer — role separation, Row-Level Security (RLS), connection security, JSONB, partitioning, indexing strategy (BTREE/GIN/BRIN/partial/expression), EXPLAIN ANALYZE, isolation levels, locking, advisory locks, connection pool sizing. Stack-agnostic. Invoke when designing Postgres schemas, hardening database access, or debugging Postgres query performance. Complements per-stack ORM skills (Drizzle, SQLAlchemy, Eloquent, Mongoose) and the database-migrations skill.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# PostgreSQL — Design, Security, and Tuning
|
|
8
|
+
|
|
9
|
+
**Invoke when designing a Postgres schema, granting database access, picking an index, debugging a slow query, or hardening prod connection settings.**
|
|
10
|
+
|
|
11
|
+
> Postgres is the same on every stack. The application code differs. This skill covers the database side — what to ask of it, how to lock it down, and how to read its performance signals. For schema **deployment** (concurrent indexes, lock timeouts, backfills, parallel change), see `database-migrations`.
|
|
12
|
+
|
|
13
|
+
This skill assumes Postgres ≥ 15 (14 still supported but missing some features called out below). PG 16/17 add more — flagged where relevant.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## 1. Connection Security (do this on day 1)
|
|
18
|
+
|
|
19
|
+
### TLS — non-negotiable in production
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
# Connection string
|
|
23
|
+
postgresql://app_user:****@db.example.com:5432/app_db?sslmode=verify-full&sslrootcert=/etc/ssl/certs/postgres-ca.pem
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
| `sslmode` | Use |
|
|
27
|
+
|---|---|
|
|
28
|
+
| `disable` | Local dev only |
|
|
29
|
+
| `require` | Encryption only — does NOT verify cert (vulnerable to MITM) |
|
|
30
|
+
| `verify-ca` | Verifies cert is signed by trusted CA |
|
|
31
|
+
| `verify-full` | + verifies hostname matches cert. **Production minimum.** |
|
|
32
|
+
|
|
33
|
+
Server: set `ssl = on` and require TLS in `pg_hba.conf` with `hostssl` lines (not `host`). Reject plaintext.
|
|
34
|
+
|
|
35
|
+
### Credentials in env, never in code
|
|
36
|
+
|
|
37
|
+
Connection strings live in environment variables (`DATABASE_URL`), never in source. See `secrets-management`.
|
|
38
|
+
|
|
39
|
+
Connection strings should not appear in:
|
|
40
|
+
- application logs (mask before logging)
|
|
41
|
+
- error messages returned to clients
|
|
42
|
+
- crash dumps / Sentry breadcrumbs
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## 2. Role Separation (least privilege)
|
|
47
|
+
|
|
48
|
+
**Never** connect the application as a `SUPERUSER` or as the database owner. Three roles minimum:
|
|
49
|
+
|
|
50
|
+
```sql
|
|
51
|
+
-- Owner: created tables. Used only by migrations.
|
|
52
|
+
CREATE ROLE app_owner LOGIN PASSWORD '...';
|
|
53
|
+
|
|
54
|
+
-- Application: runtime. SELECT/INSERT/UPDATE/DELETE on data tables, no DDL.
|
|
55
|
+
CREATE ROLE app_runtime LOGIN PASSWORD '...';
|
|
56
|
+
|
|
57
|
+
-- Read-only: analytics, BI, on-call investigation.
|
|
58
|
+
CREATE ROLE app_readonly LOGIN PASSWORD '...';
|
|
59
|
+
|
|
60
|
+
-- Schema, owned by the migration role
|
|
61
|
+
CREATE SCHEMA app AUTHORIZATION app_owner;
|
|
62
|
+
ALTER ROLE app_owner SET search_path = app, public;
|
|
63
|
+
|
|
64
|
+
-- Grants
|
|
65
|
+
GRANT USAGE ON SCHEMA app TO app_runtime, app_readonly;
|
|
66
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA app TO app_runtime;
|
|
67
|
+
GRANT SELECT ON ALL TABLES IN SCHEMA app TO app_readonly;
|
|
68
|
+
|
|
69
|
+
-- Apply to future tables (created by migrations)
|
|
70
|
+
ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA app
|
|
71
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_runtime;
|
|
72
|
+
ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA app
|
|
73
|
+
GRANT SELECT ON TABLES TO app_readonly;
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Outcomes:
|
|
77
|
+
- An RCE in the app cannot `DROP TABLE` (no DDL grant)
|
|
78
|
+
- A leaked read-only credential cannot mutate
|
|
79
|
+
- Migrations get their own audit trail
|
|
80
|
+
|
|
81
|
+
**Lock down `public`:** remove the default `CREATE` privilege on the `public` schema (PG 15 already revokes it for non-owners — verify with `\dn+`). Untrusted users with table-creation rights in `public` enable function-search-path attacks.
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## 3. Row-Level Security (RLS) — multi-tenant default
|
|
86
|
+
|
|
87
|
+
For any multi-tenant or per-user data, enable RLS so even a buggy `WHERE` clause can't leak across tenants.
|
|
88
|
+
|
|
89
|
+
```sql
|
|
90
|
+
-- Schema
|
|
91
|
+
CREATE TABLE app.documents (
|
|
92
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
93
|
+
tenant_id uuid NOT NULL,
|
|
94
|
+
owner_id uuid NOT NULL,
|
|
95
|
+
title text NOT NULL,
|
|
96
|
+
body text,
|
|
97
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
98
|
+
);
|
|
99
|
+
CREATE INDEX ON app.documents (tenant_id, created_at DESC);
|
|
100
|
+
|
|
101
|
+
-- Enable RLS
|
|
102
|
+
ALTER TABLE app.documents ENABLE ROW LEVEL SECURITY;
|
|
103
|
+
ALTER TABLE app.documents FORCE ROW LEVEL SECURITY; -- applies to table owner too
|
|
104
|
+
|
|
105
|
+
-- App-level policy: tenant set per session
|
|
106
|
+
CREATE POLICY tenant_isolation ON app.documents
|
|
107
|
+
USING (tenant_id = current_setting('app.tenant_id', true)::uuid);
|
|
108
|
+
|
|
109
|
+
-- App connects, then SETs the tenant from session-trusted context (JWT, cookie):
|
|
110
|
+
SET LOCAL app.tenant_id = '01HZ...';
|
|
111
|
+
SELECT * FROM app.documents; -- automatically filtered
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
**Critical:** the `SET LOCAL` value comes from your **server-side authn**, never from a request header you didn't validate. RLS is enforced by the database; the trust boundary is whoever owns the connection setting.
|
|
115
|
+
|
|
116
|
+
With PgBouncer transaction pooling, use `SET LOCAL` (transaction-scoped), never `SET` (session-scoped — leaks to next pooled user).
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## 4. Schema Design
|
|
121
|
+
|
|
122
|
+
### IDs
|
|
123
|
+
|
|
124
|
+
| Choice | When |
|
|
125
|
+
|---|---|
|
|
126
|
+
| `bigserial` / `bigint generated always as identity` | Internal, single-region; fastest |
|
|
127
|
+
| `uuid` (v4 random) | Distributed, public IDs; trades index locality for non-guessability |
|
|
128
|
+
| **`uuid` v7** (RFC 9562) | **2025 best practice** — time-ordered UUIDs; preserve insert locality + non-guessability |
|
|
129
|
+
|
|
130
|
+
Postgres 18 ships native `uuidv7()`. On older versions, use the `pg_uuidv7` extension or generate in app code (Drizzle's `uuid().defaultRandom()`, Python's `uuid_utils`).
|
|
131
|
+
|
|
132
|
+
Avoid `serial`/`integer` IDs for anything in URLs (4-byte counter exhausts at 2.1B; no privacy).
|
|
133
|
+
|
|
134
|
+
### Timestamps
|
|
135
|
+
|
|
136
|
+
```sql
|
|
137
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
138
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`timestamptz` (= `timestamp with time zone`) — always. It stores UTC; the "with time zone" is misleading. `timestamp` (without) is a foot-gun: no offset info, time-zone-conversion bugs at every boundary.
|
|
142
|
+
|
|
143
|
+
### Constraints — push correctness into the DB
|
|
144
|
+
|
|
145
|
+
```sql
|
|
146
|
+
CREATE TABLE app.orders (
|
|
147
|
+
id uuid PRIMARY KEY,
|
|
148
|
+
user_id uuid NOT NULL REFERENCES app.users(id) ON DELETE RESTRICT,
|
|
149
|
+
status text NOT NULL,
|
|
150
|
+
total_cents bigint NOT NULL,
|
|
151
|
+
currency text NOT NULL DEFAULT 'USD',
|
|
152
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
153
|
+
|
|
154
|
+
CONSTRAINT chk_status CHECK (status IN ('pending','paid','shipped','cancelled')),
|
|
155
|
+
CONSTRAINT chk_total_positive CHECK (total_cents > 0),
|
|
156
|
+
CONSTRAINT chk_currency CHECK (currency ~ '^[A-Z]{3}$')
|
|
157
|
+
);
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
CHECK constraints catch bad data the application failed to. They cost almost nothing on insert.
|
|
161
|
+
|
|
162
|
+
### Money
|
|
163
|
+
|
|
164
|
+
**Always** integer cents (`bigint`) or `numeric(precision, scale)`. **Never** `float`/`double` for money — IEEE 754 rounding errors compound.
|
|
165
|
+
|
|
166
|
+
### Enums vs lookup tables
|
|
167
|
+
|
|
168
|
+
- **Postgres `ENUM`**: cheaper, rigid (adding values is `ALTER TYPE`, ordering matters)
|
|
169
|
+
- **Lookup table** (`status_codes (code text PK, label text)` + FK): flexible, joins required
|
|
170
|
+
|
|
171
|
+
Lookup tables age better for any value users might reorder/rename/translate.
|
|
172
|
+
|
|
173
|
+
### JSONB — when to use it
|
|
174
|
+
|
|
175
|
+
Use `jsonb` (not `json`) when:
|
|
176
|
+
- Truly heterogeneous attributes per row (settings blobs, audit details, integration payloads)
|
|
177
|
+
- Sparse fields where >80% of rows wouldn't have them
|
|
178
|
+
- External payloads stored as-is (Stripe events, webhook bodies)
|
|
179
|
+
|
|
180
|
+
**Don't** use JSONB for:
|
|
181
|
+
- Fields you'll filter or order on regularly (worse than indexed columns)
|
|
182
|
+
- Anything with a stable shape (use real columns + CHECK)
|
|
183
|
+
- Multi-row data (use a child table)
|
|
184
|
+
|
|
185
|
+
```sql
|
|
186
|
+
-- Indexing JSONB
|
|
187
|
+
CREATE INDEX ON app.events USING GIN (payload jsonb_path_ops); -- good for @>, ?
|
|
188
|
+
CREATE INDEX ON app.events ((payload->>'user_id')); -- expression index for one path
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### Generated columns
|
|
192
|
+
|
|
193
|
+
```sql
|
|
194
|
+
CREATE TABLE app.users (
|
|
195
|
+
id uuid PRIMARY KEY,
|
|
196
|
+
email citext NOT NULL UNIQUE,
|
|
197
|
+
search_tsv tsvector GENERATED ALWAYS AS (to_tsvector('english', email)) STORED
|
|
198
|
+
);
|
|
199
|
+
CREATE INDEX ON app.users USING GIN (search_tsv);
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
The DB keeps it consistent. Saves an UPDATE trigger or app-side maintenance.
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## 5. Indexing Strategy
|
|
207
|
+
|
|
208
|
+
### Index types — choose by workload
|
|
209
|
+
|
|
210
|
+
| Type | Use For |
|
|
211
|
+
|---|---|
|
|
212
|
+
| **BTREE** (default) | Equality, range, ORDER BY |
|
|
213
|
+
| **GIN** | `@>`, `?`, `?|`, `?&` on JSONB; full-text search; arrays |
|
|
214
|
+
| **GIST** | Geometry (PostGIS), exclusion constraints, range types |
|
|
215
|
+
| **BRIN** | Huge append-only tables (events, logs) ordered by insertion time. Tiny index, range-scan focus |
|
|
216
|
+
| **HASH** | Equality only on large keys; rare — BTREE usually wins |
|
|
217
|
+
|
|
218
|
+
### Partial indexes — index only what you query
|
|
219
|
+
|
|
220
|
+
```sql
|
|
221
|
+
-- 90% of rows are status='inactive' — don't index them
|
|
222
|
+
CREATE INDEX idx_active_users_email ON app.users (email) WHERE status = 'active';
|
|
223
|
+
|
|
224
|
+
-- Soft-delete pattern
|
|
225
|
+
CREATE INDEX idx_orders_user ON app.orders (user_id) WHERE deleted_at IS NULL;
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Partial indexes are smaller, faster to maintain, and the planner uses them when the query's `WHERE` matches.
|
|
229
|
+
|
|
230
|
+
### Expression indexes — index computed values
|
|
231
|
+
|
|
232
|
+
```sql
|
|
233
|
+
-- Case-insensitive email lookup without citext
|
|
234
|
+
CREATE UNIQUE INDEX idx_users_email_lower ON app.users (lower(email));
|
|
235
|
+
SELECT * FROM app.users WHERE lower(email) = lower($1);
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### Composite ordering matters
|
|
239
|
+
|
|
240
|
+
```sql
|
|
241
|
+
-- Used by: WHERE tenant_id=? ORDER BY created_at DESC
|
|
242
|
+
CREATE INDEX ON app.documents (tenant_id, created_at DESC);
|
|
243
|
+
|
|
244
|
+
-- DOES NOT serve: WHERE created_at >= ? (leftmost prefix rule)
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Leftmost prefix rule: a composite index `(a, b, c)` serves queries on `(a)`, `(a, b)`, `(a, b, c)` — never on `(b)` alone.
|
|
248
|
+
|
|
249
|
+
### Unique constraints
|
|
250
|
+
|
|
251
|
+
```sql
|
|
252
|
+
-- One active subscription per user (partial UNIQUE)
|
|
253
|
+
CREATE UNIQUE INDEX one_active_sub_per_user
|
|
254
|
+
ON app.subscriptions (user_id) WHERE status = 'active';
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
---
|
|
258
|
+
|
|
259
|
+
## 6. Reading Performance — `EXPLAIN ANALYZE`
|
|
260
|
+
|
|
261
|
+
```sql
|
|
262
|
+
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, FORMAT TEXT)
|
|
263
|
+
SELECT u.id, count(o.id)
|
|
264
|
+
FROM app.users u
|
|
265
|
+
LEFT JOIN app.orders o ON o.user_id = u.id
|
|
266
|
+
WHERE u.created_at > now() - interval '7 days'
|
|
267
|
+
GROUP BY u.id;
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
What to look for:
|
|
271
|
+
|
|
272
|
+
| Signal | Meaning |
|
|
273
|
+
|---|---|
|
|
274
|
+
| `Seq Scan` on a big table | Missing or unusable index; or planner thinks scan is cheaper |
|
|
275
|
+
| `rows=10000 vs actual rows=10` | Planner stats are stale → `ANALYZE table_name` |
|
|
276
|
+
| `Buffers: shared read=...` huge | Cold cache or genuinely large scan |
|
|
277
|
+
| `Sort Method: external merge Disk: ...kB` | `work_mem` too small; bumps to disk |
|
|
278
|
+
| `Rows Removed by Filter: 99000` | Index is wrong shape; fetching too much |
|
|
279
|
+
| `Heap Fetches: 100000` | Index-only scan turned into heap fetch — visibility map stale, run `VACUUM` |
|
|
280
|
+
|
|
281
|
+
Always test with `ANALYZE` (real execution); plain `EXPLAIN` shows estimated plan, not actuals.
|
|
282
|
+
|
|
283
|
+
`pg_stat_statements` extension: enable in production. Sort by `total_exec_time DESC` to find the queries actually consuming the database.
|
|
284
|
+
|
|
285
|
+
---
|
|
286
|
+
|
|
287
|
+
## 7. Transactions and Locking
|
|
288
|
+
|
|
289
|
+
### Isolation levels
|
|
290
|
+
|
|
291
|
+
| Level | Postgres default | Phenomena prevented |
|
|
292
|
+
|---|---|---|
|
|
293
|
+
| `READ COMMITTED` | yes (default) | Dirty reads |
|
|
294
|
+
| `REPEATABLE READ` | | + non-repeatable reads, phantom reads |
|
|
295
|
+
| `SERIALIZABLE` | | + write skew (Serializable Snapshot Isolation) |
|
|
296
|
+
|
|
297
|
+
`SERIALIZABLE` is the safest and well-implemented in Postgres (SSI). The cost is occasional `40001` serialization-failure errors — your application **must** retry them. Worth it for financial/balance logic.
|
|
298
|
+
|
|
299
|
+
### Locking selects
|
|
300
|
+
|
|
301
|
+
```sql
|
|
302
|
+
-- Block other transactions from updating this row
|
|
303
|
+
SELECT * FROM app.invoices WHERE id = $1 FOR UPDATE;
|
|
304
|
+
|
|
305
|
+
-- Wait at most 5 seconds for the lock
|
|
306
|
+
SET LOCAL lock_timeout = '5s';
|
|
307
|
+
SELECT * FROM app.invoices WHERE id = $1 FOR UPDATE;
|
|
308
|
+
|
|
309
|
+
-- Job queue pattern: pick + lock + skip already-locked
|
|
310
|
+
SELECT * FROM app.jobs
|
|
311
|
+
WHERE status = 'pending'
|
|
312
|
+
ORDER BY priority, created_at
|
|
313
|
+
LIMIT 1
|
|
314
|
+
FOR UPDATE SKIP LOCKED;
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
`FOR UPDATE SKIP LOCKED` is the classic correct way to build a worker queue on top of Postgres. No external broker needed for many use cases.
|
|
318
|
+
|
|
319
|
+
### Advisory locks — coordinate without rows
|
|
320
|
+
|
|
321
|
+
```sql
|
|
322
|
+
-- App-level mutex, e.g. "only one cron at a time"
|
|
323
|
+
SELECT pg_try_advisory_lock(42); -- returns true if got it
|
|
324
|
+
-- ... do work ...
|
|
325
|
+
SELECT pg_advisory_unlock(42);
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
Survives across rows; cheap. Useful for cross-instance leadership without Redis.
|
|
329
|
+
|
|
330
|
+
### Idempotent upsert
|
|
331
|
+
|
|
332
|
+
```sql
|
|
333
|
+
INSERT INTO app.users (id, email, name)
|
|
334
|
+
VALUES ($1, $2, $3)
|
|
335
|
+
ON CONFLICT (email) DO UPDATE
|
|
336
|
+
SET name = EXCLUDED.name,
|
|
337
|
+
updated_at = now()
|
|
338
|
+
RETURNING *;
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
Atomic. Use this everywhere you'd otherwise SELECT-then-INSERT.
|
|
342
|
+
|
|
343
|
+
---
|
|
344
|
+
|
|
345
|
+
## 8. Connection Pooling
|
|
346
|
+
|
|
347
|
+
Postgres connections are heavyweight (each fork = ~10MB + planner state). Pool, but understand the modes.
|
|
348
|
+
|
|
349
|
+
| Tool | Modes |
|
|
350
|
+
|---|---|
|
|
351
|
+
| **PgBouncer** | session, transaction, statement |
|
|
352
|
+
| **Pgpool-II** | similar; also load-balancing |
|
|
353
|
+
| In-app pool (drizzle/pg-pool, SQLAlchemy, etc.) | session |
|
|
354
|
+
|
|
355
|
+
### Sizing
|
|
356
|
+
|
|
357
|
+
```
|
|
358
|
+
total_pool_size ≈ (cores * 2) + spindle_count
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
Real-world starting point per Postgres instance: 50–200. **Never** size your app pool * replica count > server `max_connections` — you'll get `too many clients already` outages.
|
|
362
|
+
|
|
363
|
+
Run multiple app instances? Use PgBouncer. Each app's local pool = ~5–10 connections to PgBouncer; PgBouncer multiplexes onto fewer real connections.
|
|
364
|
+
|
|
365
|
+
### PgBouncer transaction-mode caveats
|
|
366
|
+
|
|
367
|
+
In transaction-pooling mode, a single backend connection serves many clients across transactions. Things that break:
|
|
368
|
+
|
|
369
|
+
| Feature | Why it breaks | Workaround |
|
|
370
|
+
|---|---|---|
|
|
371
|
+
| `SET` (session-scope) | Leaks to next user | Use `SET LOCAL` inside a tx |
|
|
372
|
+
| Prepared statements (server-side) | Cached on the wrong backend | `pgbouncer.ini`: `max_prepared_statements`; PG ≥ 16 + PgBouncer ≥ 1.21 supports protocol-level prepared in transaction mode |
|
|
373
|
+
| `LISTEN`/`NOTIFY` | Notification listeners survive across pooling | Use a dedicated connection outside PgBouncer |
|
|
374
|
+
| Temporary tables | Live for the connection, not your transaction | Don't, or use `ON COMMIT DROP` |
|
|
375
|
+
| Advisory **session** locks | Leak | Use `pg_advisory_xact_lock` (transaction-scoped) |
|
|
376
|
+
|
|
377
|
+
If your stack uses a lot of session state, run PgBouncer in **session** mode and accept fewer connection-multiplexing wins, or rely on the in-app pool only.
|
|
378
|
+
|
|
379
|
+
---
|
|
380
|
+
|
|
381
|
+
## 9. Timeouts (set everywhere)
|
|
382
|
+
|
|
383
|
+
In every connection or session:
|
|
384
|
+
|
|
385
|
+
```sql
|
|
386
|
+
SET statement_timeout = '15s'; -- kill any single statement past 15s
|
|
387
|
+
SET lock_timeout = '5s'; -- kill any wait-on-lock past 5s
|
|
388
|
+
SET idle_in_transaction_session_timeout = '60s'; -- kill stuck transactions
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
Without these, one slow query holding a lock cascades into a database-wide stall. Set per-role defaults so nobody forgets:
|
|
392
|
+
|
|
393
|
+
```sql
|
|
394
|
+
ALTER ROLE app_runtime SET statement_timeout = '15s';
|
|
395
|
+
ALTER ROLE app_runtime SET lock_timeout = '5s';
|
|
396
|
+
ALTER ROLE app_runtime SET idle_in_transaction_session_timeout = '60s';
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
Migrations need different timeouts (longer; less concurrent traffic). Set them in the migration role only.
|
|
400
|
+
|
|
401
|
+
---
|
|
402
|
+
|
|
403
|
+
## 10. Maintenance
|
|
404
|
+
|
|
405
|
+
### Autovacuum — leave it on, tune for hot tables
|
|
406
|
+
|
|
407
|
+
Autovacuum is enabled by default and good. But hot-write tables (`>10k upserts/min`) often need:
|
|
408
|
+
|
|
409
|
+
```sql
|
|
410
|
+
ALTER TABLE app.events SET (
|
|
411
|
+
autovacuum_vacuum_scale_factor = 0.05, -- vacuum at 5% dead tuples (default 20%)
|
|
412
|
+
autovacuum_analyze_scale_factor = 0.02,
|
|
413
|
+
autovacuum_vacuum_cost_limit = 1000 -- vacuum more aggressively
|
|
414
|
+
);
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
Without this, dead tuples accumulate, indexes bloat, planner stats stale, queries slow.
|
|
418
|
+
|
|
419
|
+
### `VACUUM ANALYZE` after large data change
|
|
420
|
+
|
|
421
|
+
After a big migration, backfill, or import, run `VACUUM ANALYZE table_name;` so the planner re-stats the table.
|
|
422
|
+
|
|
423
|
+
### Bloat monitoring
|
|
424
|
+
|
|
425
|
+
`pgstattuple` extension or `pg_repack` to repack tables online without taking write locks. See `database-migrations` §8.
|
|
426
|
+
|
|
427
|
+
---
|
|
428
|
+
|
|
429
|
+
## 11. Encryption at Rest (column-level)
|
|
430
|
+
|
|
431
|
+
For specific PII fields, use `pgcrypto`:
|
|
432
|
+
|
|
433
|
+
```sql
|
|
434
|
+
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
|
435
|
+
|
|
436
|
+
-- Symmetric: stores ciphertext in column; key stays in app
|
|
437
|
+
INSERT INTO app.kyc_documents (user_id, ssn_enc)
|
|
438
|
+
VALUES ($1, pgp_sym_encrypt($2, current_setting('app.encryption_key')));
|
|
439
|
+
|
|
440
|
+
SELECT pgp_sym_decrypt(ssn_enc, current_setting('app.encryption_key'))
|
|
441
|
+
FROM app.kyc_documents WHERE user_id = $1;
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
Disk-level encryption (LUKS, EBS, RDS-default) protects against backup theft and disposal. Column encryption protects against DB-admin access and SQL injection. They are **not** substitutes — use both for sensitive PII.
|
|
445
|
+
|
|
446
|
+
For audit trails, prefer storage in a separate, write-only-by-app schema with `app_runtime` having only `INSERT` (not `UPDATE`/`DELETE`).
|
|
447
|
+
|
|
448
|
+
---
|
|
449
|
+
|
|
450
|
+
## 12. SQL Injection — Postgres specifics
|
|
451
|
+
|
|
452
|
+
Generic SQL injection defense lives in `security-baseline` §A03. Postgres-specific gotchas:
|
|
453
|
+
|
|
454
|
+
| Gotcha | Defense |
|
|
455
|
+
|---|---|
|
|
456
|
+
| String concat in dynamic SQL (`EXECUTE 'SELECT ' || user_input`) | Use `EXECUTE ... USING ($1, $2)` with bindings — even in `plpgsql` |
|
|
457
|
+
| `quote_ident()` / `quote_literal()` | Use **only** when truly building dynamic identifiers; never for user data |
|
|
458
|
+
| `search_path` hijacking — function calls resolve via search_path | Schema-qualify everywhere in functions: `SELECT app.users.id`; set `SET search_path = pg_catalog, app` at function definition |
|
|
459
|
+
| `LIKE` patterns | Escape `%` and `_` in user input: `replace(replace(input, '%', '\\%'), '_', '\\_')` then bind |
|
|
460
|
+
| ORM "raw" escape hatches | Audit every call site; treat as guilty until proven safe |
|
|
461
|
+
|
|
462
|
+
---
|
|
463
|
+
|
|
464
|
+
## 13. Backups
|
|
465
|
+
|
|
466
|
+
| Tool | Use |
|
|
467
|
+
|---|---|
|
|
468
|
+
| `pg_basebackup` + WAL archiving | Point-in-time recovery (PITR), production standard |
|
|
469
|
+
| `pg_dump --format=custom` | Logical, portable across versions; use for schema migration / small DBs |
|
|
470
|
+
| Cloud provider (RDS, Cloud SQL, Neon, Supabase) snapshots | Convenient, verify retention/region |
|
|
471
|
+
|
|
472
|
+
Three rules:
|
|
473
|
+
1. **Test restores monthly.** A backup nobody has restored is not a backup.
|
|
474
|
+
2. **Store off-region.** A region failure that takes the DB also takes co-located backups.
|
|
475
|
+
3. **Encrypt and rotate** the backup encryption keys; treat them as production secrets.
|
|
476
|
+
|
|
477
|
+
---
|
|
478
|
+
|
|
479
|
+
## 14. Pre-Deploy Checklist
|
|
480
|
+
|
|
481
|
+
- [ ] App connects as a non-superuser role with only the privileges it needs
|
|
482
|
+
- [ ] `sslmode=verify-full` against a known CA
|
|
483
|
+
- [ ] `statement_timeout`, `lock_timeout`, `idle_in_transaction_session_timeout` set per role
|
|
484
|
+
- [ ] RLS enabled on every multi-tenant table; `FORCE ROW LEVEL SECURITY` on
|
|
485
|
+
- [ ] Multi-tenant policies tested with a non-bypass role
|
|
486
|
+
- [ ] PgBouncer mode matches what the ORM/driver expects (transaction vs session)
|
|
487
|
+
- [ ] Autovacuum tuned for known hot tables
|
|
488
|
+
- [ ] `pg_stat_statements` enabled with retention configured
|
|
489
|
+
- [ ] Backup + restore drill within last 30 days
|
|
490
|
+
- [ ] Slow-query log threshold set (`log_min_duration_statement = '500ms'`)
|
|
491
|
+
|
|
492
|
+
---
|
|
493
|
+
|
|
494
|
+
## FORBIDDEN
|
|
495
|
+
|
|
496
|
+
| Pattern | Reason |
|
|
497
|
+
|---|---|
|
|
498
|
+
| App connects as `postgres` superuser | RCE = `DROP DATABASE`; no audit boundary |
|
|
499
|
+
| `sslmode=require` (no `verify-full`) | MITM-vulnerable |
|
|
500
|
+
| Multi-tenant table without RLS | One bad WHERE → cross-tenant leak |
|
|
501
|
+
| Tenant ID derived from request header app didn't authenticate | RLS becomes attacker-controlled |
|
|
502
|
+
| `float`/`double precision` for money | Rounding errors |
|
|
503
|
+
| `timestamp` (no time zone) for events | DST/UTC bugs |
|
|
504
|
+
| Sequential `serial` IDs in public URLs | Enumeration; existence leaks |
|
|
505
|
+
| Connection pool sized > server `max_connections` | Outage on traffic spike |
|
|
506
|
+
| `SET` (session) on PgBouncer transaction-mode | Leaks to next pooled user |
|
|
507
|
+
| No `statement_timeout` | One runaway query can stall the cluster |
|
|
508
|
+
| String concat for dynamic SQL in `plpgsql` functions | SQL injection inside the database |
|
|
509
|
+
| Column encryption keys in the same DB / same secret | Defeats the purpose |
|
|
510
|
+
| Untested backups | Not backups |
|
|
511
|
+
|
|
512
|
+
---
|
|
513
|
+
|
|
514
|
+
## See Also
|
|
515
|
+
|
|
516
|
+
- `database-migrations` — safe DDL: concurrent indexes, parallel-change pattern, lock-aware migrations
|
|
517
|
+
- `security-baseline` — A01 (authz), A03 (SQL injection generic), A09 (audit logging)
|
|
518
|
+
- `secrets-management` — DATABASE_URL hygiene, key rotation
|
|
519
|
+
- `observability` — slow-query log shipping, `pg_stat_statements` to APM
|
|
520
|
+
- `performance-patterns` — generic perf: caching, N+1, response compression
|
|
521
|
+
- per-stack ORM skill — Drizzle (Node), SQLAlchemy/asyncpg (Python), Eloquent (PHP), Mongoose (only if you accidentally added Postgres to a Mongo project — don't)
|