-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdocker-compose.dev.yml
More file actions
203 lines (197 loc) · 8.24 KB
/
Copy pathdocker-compose.dev.yml
File metadata and controls
203 lines (197 loc) · 8.24 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
# ---------------------------------------------------------------------------
# Pull.fm - LOCAL DEVELOPMENT STACK ONLY. Never used for staging or production
# (those are provisioned by Terraform + Nomad under infra/).
#
# The point of this file is that "the stack runs" is provable on a laptop with
# one command, so every gate below Phase 4 can be tested without cloud spend.
#
# pnpm stack:up start
# pnpm stack:reset wipe volumes and restart (destroys local data)
# ---------------------------------------------------------------------------
name: pullfm-dev
services:
postgres:
# Pinned to the same major the cloud runs. The Neon project reports
# pg_version 18 (infra/neon, verified against the live API), and a skew
# between here and there is how "migrations passed locally" turns into a
# production incident.
image: postgres:18-alpine
container_name: pullfm-postgres
restart: unless-stopped
environment:
POSTGRES_USER: pullfm
POSTGRES_DB: pullfm
# Local-only throwaway credential. Documented as non-secret so the value
# is obvious to a reader and to gitleaks; production credentials come from
# the vault and never appear in a file.
POSTGRES_PASSWORD: pullfm_local_dev_not_a_secret
POSTGRES_INITDB_ARGS: "--data-checksums"
ports:
- "127.0.0.1:5432:5432"
volumes:
# PG18+ images store data under a major-version-specific subdirectory
# (matching pg_ctlcluster), so the mount point moved up one level. Keeping
# the old /data path makes the container refuse to start.
- pgdata:/var/lib/postgresql
- ./infra/local/postgres-init:/docker-entrypoint-initdb.d:ro
command:
# WAL settings mirror the production replication + PITR configuration so
# the Phase 5 backup/restore drill exercises a realistic setup locally.
- postgres
- -c
- wal_level=replica
- -c
- max_wal_senders=4
- -c
- archive_mode=on
- -c
- archive_command=/bin/true
- -c
- log_min_duration_statement=200
healthcheck:
test: ["CMD-SHELL", "pg_isready -U pullfm -d pullfm"]
interval: 5s
timeout: 5s
retries: 12
# PgBouncer in transaction mode.
#
# ---------------------------------------------------------------------------
# THIS IS DELIBERATELY LOCAL-ONLY. THERE IS NO PGBOUNCER IN STAGING OR PROD,
# AND ITS ABSENCE THERE IS NOT AN OVERSIGHT.
# ---------------------------------------------------------------------------
#
# The cloud environments run on Neon (infra/neon), and Neon's pooled endpoint
# IS PgBouncer in transaction mode, operated by Neon inside their proxy. The
# `-pooler` host and the plain host are two front doors onto the same compute.
# Deploying our own PgBouncer in front of that would add a second pooler, a
# second failure mode and a second hop for exactly zero capability, which is
# why the Hetzner data node lost it (and lost Postgres with it).
#
# It stays here because the alternative is a laptop that behaves differently
# from production in precisely the way that is expensive to discover late.
# Transaction pooling is not transparent: a session-level advisory lock does
# not survive COMMIT, `SET` outside a transaction does not persist, LISTEN and
# NOTIFY do not work, and prepared statements outside a transaction are not
# available. Every one of those fails SILENTLY rather than loudly, so the only
# way to catch the mistake is to develop against the same semantics.
#
# Which is why the two ports are both published and are not interchangeable:
#
# 6432 this pooler -> DATABASE_URL (the application)
# 5432 Postgres directly -> DATABASE_URL_DIRECT (the migration runner)
#
# That is the same split the Neon outputs expose, under the same two variable
# names, so nothing about the application's configuration changes between a
# laptop and production. See docs/runbooks/neon-migration.md.
pgbouncer:
image: edoburu/pgbouncer:latest
container_name: pullfm-pgbouncer
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
DB_HOST: postgres
DB_PORT: "5432"
DB_USER: pullfm
DB_PASSWORD: pullfm_local_dev_not_a_secret
DB_NAME: pullfm
POOL_MODE: transaction
# Deliberately lopsided: many client connections multiplexed onto few
# server connections is the entire point. Gate 1 asserts this ratio.
MAX_CLIENT_CONN: "500"
DEFAULT_POOL_SIZE: "25"
AUTH_TYPE: scram-sha-256
ADMIN_USERS: pullfm
# WITHOUT THIS THE BFF CANNOT CONNECT AT ALL.
#
# node-postgres puts the `statement_timeout` and
# `idle_in_transaction_session_timeout` options from `apps/bff/src/lib/db.ts`
# into the libpq StartupMessage, and PgBouncer rejects any startup
# parameter outside this list:
#
# ERR unsupported startup parameter: statement_timeout
#
# So DATABASE_URL had to point at 5432 to get a working stack, which meant
# the pooler this file exists to mirror was never on the path and Gate 1's
# transaction-pooling assertion could not be measured locally.
#
# READ THIS BEFORE TRUSTING THE SETTING: `ignore_startup_parameters` means
# IGNORE. PgBouncer accepts the connection and DISCARDS the value; the
# backend then runs with `statement_timeout = 0`, unbounded. Measured, not
# assumed - connecting through 6432 with `statement_timeout: 3000` and
# asking the server gives `current_setting('statement_timeout') = '0'`, and
# `SELECT pg_sleep(6)` completes. `track_extra_parameters` does not rescue
# it either: it stops the rejection but the value is still dropped, because
# Postgres does not GUC_REPORT these two so PgBouncer never learns them.
#
# That is why infra/local/postgres-init/01-role-timeouts.sql sets the same
# ceiling as a ROLE DEFAULT, which every backend picks up at start and
# which no pooler can swallow. The two have to move together. This is not a
# local quirk: Neon's pooled endpoint is PgBouncer too, so an application
# relying on the startup parameter has no statement timeout in production
# either, which is THREAT-MODEL T10 with the mitigation missing.
IGNORE_STARTUP_PARAMETERS: extra_float_digits,statement_timeout,idle_in_transaction_session_timeout
ports:
- "127.0.0.1:6432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -p 5432 -U pullfm || exit 1"]
interval: 10s
timeout: 5s
retries: 6
# Cache Redis. Evictable by design.
redis:
image: redis:7-alpine
container_name: pullfm-redis
restart: unless-stopped
ports:
- "127.0.0.1:6379:6379"
command:
- redis-server
- --maxmemory
- 256mb
- --maxmemory-policy
- allkeys-lru
- --appendonly
- "no"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 12
# Quota Redis. SEPARATE INSTANCE, and this separation is a security control.
#
# Rate-limit counters must never share an eviction policy with the cache. On
# a single `allkeys-lru` instance, a cache-fill event evicts quota keys and
# every rate limit then fails OPEN: no error, no alert, no signal at all,
# while the abuse protections are simply gone. That is the highest-likelihood
# defect in the threat model (T11).
#
# `noeviction` makes the failure mode loud instead: writes error rather than
# silently discarding a counter, so the limiter can fail CLOSED. Eviction
# policy is per-instance in Redis, not per-database, so a second instance is
# the only way to express this.
redis-quota:
image: redis:7-alpine
container_name: pullfm-redis-quota
restart: unless-stopped
ports:
- "127.0.0.1:6380:6379"
command:
- redis-server
- --maxmemory
- 64mb
- --maxmemory-policy
- noeviction
# Counters are short-lived and reconstructible, so persistence would cost
# fsync latency on the hot path for no benefit.
- --appendonly
- "no"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 12
volumes:
pgdata:
driver: local