Repository navigation
Expand file tree
/
Copy pathdocker-compose.yml
More file actions
335 lines (316 loc) · 12 KB
/
Copy pathdocker-compose.yml
File metadata and controls
335 lines (316 loc) · 12 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
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
# CollabHub local development stack (Conventions §11).
#
# Copy .env.example to .env first — every value below comes from there.
# Image versions are pinned from docs/platform/versions.md, which wins over the
# design docs.
#
# The three Redis instances are separate containers, not three databases on one
# instance, because they are not interchangeable (Conventions §6, §7): R1 is the
# cache and token denylist, R2 the Socket.IO backplane, R3 the job streams.
# Running them apart locally is what stops them being conflated in code.
name: collabhub
x-service-common: &service-common
restart: unless-stopped
env_file: .env
environment: &service-env
APP_ENV: ${APP_ENV}
LOG_LEVEL: ${LOG_LEVEL}
AUTH_ISSUER: ${AUTH_ISSUER}
AUTH_AUDIENCE: ${AUTH_AUDIENCE}
AUTH_JWKS_URL: ${AUTH_JWKS_URL}
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT}
x-http-healthcheck: &http-healthcheck
healthcheck:
test:
[
"CMD",
"python",
"-c",
"import urllib.request;urllib.request.urlopen('http://localhost:8000/health/live')",
]
interval: 10s
timeout: 3s
retries: 5
start_period: 10s
services:
# ---------------------------------------------------------------------------
# Infrastructure
# ---------------------------------------------------------------------------
postgres:
image: postgres:18
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_USER}
volumes:
# Postgres 18 stores data in a major-version subdirectory, so the mount
# goes at /var/lib/postgresql — not /var/lib/postgresql/data as it did
# before 18. Mounting the old path makes the image refuse to start.
- postgres-data:/var/lib/postgresql
- ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/10-databases.sql:ro
ports:
- "${POSTGRES_PORT}:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
interval: 5s
timeout: 3s
retries: 10
redis-cache: # R1 — cache and token denylist
image: redis:8
restart: unless-stopped
volumes:
- redis-cache-data:/data
healthcheck: &redis-healthcheck
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
redis-rt: # R2 — Socket.IO backplane. Ephemeral by nature; no volume.
image: redis:8
restart: unless-stopped
healthcheck: *redis-healthcheck
redis-streams: # R3 — async job streams. Persisted: unacked jobs are work.
image: redis:8
restart: unless-stopped
volumes:
- redis-streams-data:/data
healthcheck: *redis-healthcheck
garage:
image: dxflrs/garage:v2.3.0
restart: unless-stopped
volumes:
- ./docker/garage/garage.toml:/etc/garage.toml:ro
- garage-meta:/var/lib/garage/meta
- garage-data:/var/lib/garage/data
ports:
- "3900:3900" # S3 API
- "3903:3903" # admin API
garage-bootstrap:
# One-shot: gives a fresh Garage its layout, the bucket, the app's key and the
# bucket's CORS rules, then exits. Garage serves no S3 traffic until a layout
# is assigned, and the image has no shell to run its own CLI from, so the same
# stdlib script the test stack, k3d and testkit run does it over the admin API.
# Every step checks before it acts, so it is safe on every `up`. `asset` waits
# for it to complete.
image: python:3.12-slim
restart: "no"
command: ["python", "/bootstrap/garage-bootstrap.py"]
volumes:
- ./charts/collabhub-deps/files/garage-bootstrap.py:/bootstrap/garage-bootstrap.py:ro
environment:
GARAGE_ADMIN_URL: http://garage:3903
GARAGE_ADMIN_TOKEN: ${GARAGE_ADMIN_TOKEN}
GARAGE_BUCKET: ${OBJECT_STORE_BUCKET}
GARAGE_ACCESS_KEY_ID: ${OBJECT_STORE_ACCESS_KEY}
GARAGE_SECRET_ACCESS_KEY: ${OBJECT_STORE_SECRET_KEY}
GARAGE_CAPACITY_BYTES: "1073741824"
GARAGE_CORS_ORIGINS: ${GARAGE_CORS_ORIGINS}
depends_on:
garage: {condition: service_started}
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:9.4.3
restart: unless-stopped
environment:
discovery.type: single-node
xpack.security.enabled: "false"
ES_JAVA_OPTS: -Xms512m -Xmx512m
volumes:
- elasticsearch-data:/usr/share/elasticsearch/data
ports:
- "9200:9200"
healthcheck:
test: ["CMD-SHELL", "curl -fs http://localhost:9200/_cluster/health || exit 1"]
interval: 10s
timeout: 5s
retries: 20
start_period: 30s
dex:
# The upstream identity provider (register D5). CollabHub's Auth service
# federates to this; it is never an OIDC provider itself.
image: ghcr.io/dexidp/dex:v2.45.1
restart: unless-stopped
command: ["dex", "serve", "/etc/dex/config.yaml"]
environment:
# Referenced by `hashFromEnv` in the config. Dex expands environment
# variables only in its storage, signer and connector blocks, and only
# behind a feature flag, so this indirection is how a password hash gets
# in — and it keeps a string starting `$2b$` out of a file that might one
# day be expanded.
DEX_PASSWORD_HASH: ${DEX_PASSWORD_HASH}
volumes:
- ./docker/dex/config.yaml:/etc/dex/config.yaml:ro
ports:
# The host port must match the port inside the issuer URL. An OIDC issuer
# is an identity, not an address: it is what Dex puts in `iss` and what
# Auth checks the id_token against, so the browser and the token have to
# agree on it. Changing DEX_PORT means changing `issuer` in
# docker/dex/config.yaml too.
- "${DEX_PORT}:5556"
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:5558/healthz"]
interval: 5s
timeout: 3s
retries: 10
start_period: 5s
otel-collector:
image: otel/opentelemetry-collector-contrib:0.156.0
restart: unless-stopped
command: ["--config=/etc/otel/config.yaml"]
volumes:
- ./docker/otel-collector/config.yaml:/etc/otel/config.yaml:ro
ports:
- "4317:4317" # OTLP gRPC
- "4318:4318" # OTLP HTTP
# ---------------------------------------------------------------------------
# Services
# ---------------------------------------------------------------------------
auth:
<<: [*service-common, *http-healthcheck]
build:
context: .
dockerfile: docker/auth/Dockerfile
environment:
<<: *service-env
# .env namespaces the DSNs per service; each container sees only its own,
# under the name Conventions §8 specifies.
POSTGRES_DSN: ${AUTH_POSTGRES_DSN}
REDIS_CACHE_URL: ${REDIS_CACHE_URL}
OIDC_PROVIDERS: ${OIDC_PROVIDERS}
SPA_REDIRECT_URI: ${SPA_REDIRECT_URI}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
ports:
- "${AUTH_PORT}:8000"
depends_on:
postgres: {condition: service_healthy}
redis-cache: {condition: service_healthy}
dex: {condition: service_healthy}
messaging:
<<: [*service-common, *http-healthcheck]
build:
context: .
dockerfile: docker/messaging/Dockerfile
environment:
<<: *service-env
POSTGRES_DSN: ${MESSAGING_POSTGRES_DSN}
REDIS_CACHE_URL: ${REDIS_CACHE_URL}
REDIS_REALTIME_URL: ${REDIS_REALTIME_URL}
REDIS_STREAMS_URL: ${REDIS_STREAMS_URL}
# Read-only, for GET /search/messages. Deliberately not in depends_on: an
# Elasticsearch outage degrades search, it does not stop chat starting.
ELASTICSEARCH_URL: ${ELASTICSEARCH_URL}
# The SPA is a separate origin locally, so the browser will not call this
# service at all without it (Conventions §5.6).
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
# Claiming a send's attachments: Asset's internal API, called with this
# service's own token from Auth (Conventions §5.5).
SERVICE_TOKEN_URL: ${SERVICE_TOKEN_URL}
ASSET_INTERNAL_URL: ${ASSET_INTERNAL_URL}
MESSAGING_SERVICE_CLIENT_ID: ${MESSAGING_SERVICE_CLIENT_ID}
MESSAGING_SERVICE_CLIENT_SECRET: ${MESSAGING_SERVICE_CLIENT_SECRET}
ports:
- "${MESSAGING_PORT}:8000"
depends_on:
postgres: {condition: service_healthy}
redis-cache: {condition: service_healthy}
redis-rt: {condition: service_healthy}
redis-streams: {condition: service_healthy}
# Not a readiness dependency (a send with no files never calls Asset), but
# it should be there when the first attachment is sent.
asset: {condition: service_started}
canvas:
<<: [*service-common, *http-healthcheck]
build:
context: .
dockerfile: docker/canvas/Dockerfile
environment:
<<: *service-env
POSTGRES_DSN: ${CANVAS_POSTGRES_DSN}
REDIS_CACHE_URL: ${REDIS_CACHE_URL}
REDIS_REALTIME_URL: ${REDIS_REALTIME_URL}
ports:
- "${CANVAS_PORT}:8000"
depends_on:
postgres: {condition: service_healthy}
redis-cache: {condition: service_healthy}
redis-rt: {condition: service_healthy}
asset:
<<: [*service-common, *http-healthcheck]
build:
context: .
dockerfile: docker/asset/Dockerfile
environment:
<<: *service-env
POSTGRES_DSN: ${ASSET_POSTGRES_DSN}
# R1 for the token denylist (`require_user`), R3 for thumbnail jobs.
REDIS_CACHE_URL: ${REDIS_CACHE_URL}
REDIS_STREAMS_URL: ${REDIS_STREAMS_URL}
# The SPA calls `upload-url` and `confirm` directly, from another origin.
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
OBJECT_STORE_ENDPOINT: ${OBJECT_STORE_ENDPOINT}
OBJECT_STORE_PUBLIC_ENDPOINT: ${OBJECT_STORE_PUBLIC_ENDPOINT}
OBJECT_STORE_ACCESS_KEY: ${OBJECT_STORE_ACCESS_KEY}
OBJECT_STORE_SECRET_KEY: ${OBJECT_STORE_SECRET_KEY}
OBJECT_STORE_BUCKET: ${OBJECT_STORE_BUCKET}
OBJECT_STORE_PRESIGN_TTL_SECONDS: ${OBJECT_STORE_PRESIGN_TTL_SECONDS}
ASSET_MAX_UPLOAD_BYTES: ${ASSET_MAX_UPLOAD_BYTES}
ports:
- "${ASSET_PORT}:8000"
depends_on:
postgres: {condition: service_healthy}
redis-cache: {condition: service_healthy}
redis-streams: {condition: service_healthy}
# Garage serves nothing until the bootstrap has run.
garage-bootstrap: {condition: service_completed_successfully}
worker:
<<: [*service-common, *http-healthcheck]
build:
context: .
dockerfile: docker/worker/Dockerfile
environment:
<<: *service-env
REDIS_STREAMS_URL: ${REDIS_STREAMS_URL}
ELASTICSEARCH_URL: ${ELASTICSEARCH_URL}
OBJECT_STORE_ENDPOINT: ${OBJECT_STORE_ENDPOINT}
OBJECT_STORE_ACCESS_KEY: ${OBJECT_STORE_ACCESS_KEY}
OBJECT_STORE_SECRET_KEY: ${OBJECT_STORE_SECRET_KEY}
OBJECT_STORE_BUCKET: ${OBJECT_STORE_BUCKET}
SERVICE_TOKEN_URL: ${SERVICE_TOKEN_URL}
ASSET_INTERNAL_URL: ${ASSET_INTERNAL_URL}
ports:
- "${WORKER_PORT}:8000" # health only; the Worker exposes no API
depends_on:
redis-streams: {condition: service_healthy}
elasticsearch: {condition: service_healthy}
frontend:
restart: unless-stopped
build:
context: .
dockerfile: docker/frontend/Dockerfile
# Only VITE_-prefixed vars reach the bundle, and they are baked at build
# time — never put a secret here.
args:
VITE_AUTH_URL: ${VITE_AUTH_URL}
VITE_MESSAGING_URL: ${VITE_MESSAGING_URL}
VITE_CANVAS_URL: ${VITE_CANVAS_URL}
VITE_ASSET_URL: ${VITE_ASSET_URL}
ports:
- "${FRONTEND_PORT}:8080"
healthcheck:
# Nginx serving static files has no /health endpoint, so this probes `/` —
# the same path the chart's liveness and readiness probes use. Without it
# `up --wait` treats "running" as ready, and `scripts/test.sh e2e` would
# race the browser against a server still starting. 127.0.0.1, not
# localhost: Nginx listens on IPv4 only, and busybox wget may try ::1.
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:8080/"]
interval: 5s
timeout: 3s
retries: 10
start_period: 5s
volumes:
postgres-data:
redis-cache-data:
redis-streams-data:
garage-meta:
garage-data:
elasticsearch-data: