Repository navigation
Expand file tree
/
Copy pathdocker-compose.yml
More file actions
339 lines (319 loc) · 16.4 KB
/
Copy pathdocker-compose.yml
File metadata and controls
339 lines (319 loc) · 16.4 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
336
337
338
339
# Production stack: the Forma server and its job worker, the one-shot migration, PostgreSQL 18, Valkey and SpiceDB, plus optional S3-compatible storage (profile `storage`) and a local Qwen model (profile `qwen`). deploy/docker/forma.sh installs and updates it on a single host; keep local changes in docker-compose.override.yml so this file can be replaced on update.
#
# Put the settings in deploy/docker/.env next to this file (mode 0600); every service below reads it. The repository's .env.example documents every variable the server reads. The minimum:
#
# WEBAPP_URL=https://forma.example.com
# POSTGRES_PASSWORD=<openssl rand -hex 32>
# ENCRYPTION_KEY=<openssl rand -hex 32>
# AUTHZED_TOKEN=<openssl rand -hex 32>
# AUTHZED_DATABASE_PASSWORD=<openssl rand -hex 32>
#
# then `docker compose up -d`. Every image runs the same binary: `forma serve` answers on :3000, `forma worker` runs the background jobs, `forma migrate` applies the schema before either starts, and `forma authz schema apply` writes the authorization schema into SpiceDB. Booleans are 1 or 0; AUTHZED_ENABLED and AUTHZED_INSECURE also take true and false.
# Pin a release (FORMA_IMAGE_REF=ghcr.io/yldm-tech/forma-server:<version>) for reproducible upgrades.
x-forma-image: &forma-image
image: ${FORMA_IMAGE_REF:-ghcr.io/yldm-tech/forma-server:latest}
# The password goes into a URL: set POSTGRES_PASSWORD_URL_ENCODED as well when it contains characters that need escaping. A `?schema=public` suffix, a Prisma option, is not needed: the server drops it from DATABASE_URL and MIGRATE_DATABASE_URL.
x-forma-database: &forma-database
DATABASE_URL: "postgresql://postgres:${POSTGRES_PASSWORD_URL_ENCODED:-${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}}@postgres:5432/forma?sslmode=disable"
x-forma-authzed: &forma-authzed
AUTHZED_ENABLED: ${AUTHZED_ENABLED:-1}
AUTHZED_ENDPOINT: ${AUTHZED_ENDPOINT:-spicedb:50051}
AUTHZED_TOKEN: ${AUTHZED_TOKEN:?AUTHZED_TOKEN is required}
# The bundled SpiceDB is reached over the internal network without TLS.
AUTHZED_INSECURE: ${AUTHZED_INSECURE:-1}
AUTHZED_CONSISTENCY: ${AUTHZED_CONSISTENCY:-fully_consistent}
x-forma-app: &forma-app
<<: *forma-image
restart: always
env_file:
- path: .env
required: false
environment: &app-environment
<<: [*forma-database, *forma-authzed]
# Valkey for rate limits and short-lived state. To use an external Redis/Valkey, remove the redis service and set REDIS_URL in .env.
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
# The server sits behind one reverse proxy (Traefik, Caddy, nginx) that appends to X-Forwarded-For. Set 0 when clients reach port 3000 directly, or the client address can be spoofed.
TRUSTED_PROXY_HOP_COUNT: ${TRUSTED_PROXY_HOP_COUNT:-1}
# Audit events are written to this container's log stream (JSON lines with "level":"audit"), not to the database, and no page in the app shows them: collect them with your Docker logging driver or log pipeline for as long as you need them.
#
# Everything else comes from .env. The groups:
#
# Required: WEBAPP_URL (the public URL of the admin, e.g. https://forma.example.com) and ENCRYPTION_KEY (openssl rand -hex 32; an existing instance keeps its value).
# Optional URLs: PUBLIC_URL (a separate origin for surveys and the client API; custom head scripts need it on a host other than WEBAPP_URL's), AUTH_URL (only when single sign-on redirect URIs were registered under another origin).
# Link survey token secrets, kept so links already sent keep working: LINK_SURVEY_TOKEN_SECRET, LINK_SURVEY_PIN_TOKEN_SECRET.
# Renamed: NEXTAUTH_SECRET is now LINK_SURVEY_TOKEN_SECRET, BETTER_AUTH_SECRET LINK_SURVEY_PIN_TOKEN_SECRET, BETTER_AUTH_URL and NEXTAUTH_URL AUTH_URL, SAML_DATABASE_URL SAML_ENABLED=1 and BULLMQ_WORKER_ENABLED WORKER_ENABLED. The old names are still read, with a deprecation warning in the log, until a later release; forma.sh update renames them in .env.
# Email: SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_AUTHENTICATED, SMTP_SECURE_ENABLED, SMTP_REJECT_UNAUTHORIZED_TLS, MAIL_FROM, MAIL_FROM_NAME.
# Sign-up and sign-in: EMAIL_VERIFICATION_DISABLED, PASSWORD_RESET_DISABLED, EMAIL_AUTH_DISABLED, INVITE_DISABLED, INVITE_RATE_LIMIT_PER_24_HOURS, SESSION_MAX_AGE, TURNSTILE_SITE_KEY, TURNSTILE_SECRET_KEY, USER_MANAGEMENT_MINIMUM_ROLE.
# Single sign-on: GITHUB_ID, GITHUB_SECRET, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, AZUREAD_CLIENT_ID, AZUREAD_CLIENT_SECRET, AZUREAD_TENANT_ID, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_ISSUER, OIDC_DISPLAY_NAME, AUTH_SSO_DEFAULT_ORGANIZATION_ID, AUTH_SSO_DEFAULT_ORGANIZATION_ROLE, SAML_ENABLED.
# Storage: S3_ACCESS_KEY, S3_SECRET_KEY, S3_REGION, S3_BUCKET_NAME, S3_ENDPOINT_URL, S3_FORCE_PATH_STYLE, and STORAGE_UPLOAD_METHOD (post by default, put for a store without POST uploads such as Cloudflare R2; see .env.example). Browsers upload straight to the bucket, so S3_ENDPOINT_URL must be reachable from both the browser and this container (see the `storage` profile below).
# Spam protection: Cloudflare Turnstile when TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY are set (they also guard sign-up), else RECAPTCHA_SITE_KEY, RECAPTCHA_SECRET_KEY.
# Integrations: NOTION_OAUTH_CLIENT_ID, NOTION_OAUTH_CLIENT_SECRET, GOOGLE_SHEETS_CLIENT_ID, GOOGLE_SHEETS_CLIENT_SECRET, GOOGLE_SHEETS_REDIRECT_URL, AIRTABLE_CLIENT_ID, SLACK_CLIENT_ID, SLACK_CLIENT_SECRET.
# AI: AI_PROVIDER, AI_MODEL and the provider's AI_* settings (see the `qwen` profile below for a local model).
# MCP: MCP_OAUTH_JWKS_URL, only to verify tokens against another instance's keys.
# Legal: PRIVACY_URL, TERMS_URL, IMPRINT_URL, IMPRINT_ADDRESS.
# Server: LOG_LEVEL, LOG_FORMAT, CLIENT_IP_HEADER with TRUSTED_PROXY_CIDRS (only when the proxy passes the client address in a single-address header such as CF-Connecting-IP rather than X-Forwarded-For), RATE_LIMITING_DISABLED, GATEWAY_RATE_LIMITING (only with a gateway in front), AUDIT_LOG_ENABLED, AUDIT_LOG_GET_USER_IP (set 1 to record client addresses in audit events), RETENTION_SWEEP_DRY_RUN (1, the default, only counts what the organizations' retention windows would delete; 0 deletes), DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS, WORKER_CONCURRENCY, SURVEY_SCHEDULING_TIME_ZONE (the default zone of scheduled surveys; an organization can choose its own), SURVEY_SCHEDULING_LOCAL_HOUR, SURVEY_SCHEDULING_LOCAL_MINUTE.
depends_on: &forma-app-dependencies
postgres:
condition: service_healthy
redis:
condition: service_healthy
# Neither process migrates on its own: waiting for the one-shot `forma-migrate` keeps them from running against an old schema, and a failed migration shows as `dependency failed to start`. No SpiceDB condition on purpose: startup and /health do not depend on it.
forma-migrate:
condition: service_completed_successfully
services:
postgres:
restart: always
# PostgreSQL 18 (the schema needs uuidv7()).
image: pgvector/pgvector:pg18
volumes:
- postgres:/var/lib/postgresql
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
POSTGRES_DB: forma
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres || exit 1"]
interval: 5s
timeout: 3s
retries: 30
start_period: 30s
# SpiceDB keeps its relationships in a database and role of its own on the same server; this creates or updates them. Idempotent.
authzed-db-bootstrap:
image: pgvector/pgvector:pg18
restart: "no"
entrypoint: ["/bin/sh", "/usr/local/bin/authzed-postgres-bootstrap.sh"]
environment:
POSTGRES_ADMIN_URL: postgresql://postgres:${POSTGRES_PASSWORD_URL_ENCODED:-${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}}@postgres:5432/postgres?sslmode=disable
AUTHZED_DATABASE_NAME: spicedb
AUTHZED_DATABASE_USERNAME: spicedb
AUTHZED_DATABASE_PASSWORD: ${AUTHZED_DATABASE_PASSWORD:?AUTHZED_DATABASE_PASSWORD is required}
volumes:
- ./authzed-postgres-bootstrap.sh:/usr/local/bin/authzed-postgres-bootstrap.sh:ro
depends_on:
postgres:
condition: service_healthy
spicedb-migrate:
image: ${SPICEDB_IMAGE_REF:-authzed/spicedb:v1.52.0}
restart: "no"
command: datastore migrate head
environment:
SPICEDB_DATASTORE_ENGINE: postgres
SPICEDB_DATASTORE_CONN_URI: postgresql://spicedb:${AUTHZED_DATABASE_PASSWORD:?AUTHZED_DATABASE_PASSWORD is required}@postgres:5432/spicedb?sslmode=disable
depends_on:
authzed-db-bootstrap:
condition: service_completed_successfully
# Not published to the host: only the server, the worker and the authz commands talk to it.
spicedb:
image: ${SPICEDB_IMAGE_REF:-authzed/spicedb:v1.52.0}
restart: always
command: serve
mem_limit: 512m
environment:
SPICEDB_DATASTORE_ENGINE: postgres
SPICEDB_DATASTORE_CONN_URI: postgresql://spicedb:${AUTHZED_DATABASE_PASSWORD:?AUTHZED_DATABASE_PASSWORD is required}@postgres:5432/spicedb?sslmode=disable
SPICEDB_GRPC_PRESHARED_KEY: ${AUTHZED_TOKEN:?AUTHZED_TOKEN is required}
SPICEDB_LOG_FORMAT: json
SPICEDB_LOG_LEVEL: info
SPICEDB_TELEMETRY_ENDPOINT: ""
depends_on:
spicedb-migrate:
condition: service_completed_successfully
healthcheck:
test: ["CMD", "/usr/local/bin/grpc_health_probe", "-addr=localhost:50051"]
interval: 5s
timeout: 5s
retries: 12
start_period: 10s
# No host port: the rate-limit counters and session state are reached only over the compose network, and publishing 6379 would bypass host firewalls such as ufw.
redis:
restart: always
image: valkey/valkey@sha256:e0eb7c480958d32bdc4357a74bdd70653ae15f2f9b4c93c4a5a9fad1dc471c84
command: valkey-server --appendonly yes --maxmemory-policy noeviction
volumes:
- redis:/data
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 5s
timeout: 3s
retries: 30
start_period: 10s
# Applies the database schema (goose, then the job queue's tables), then exits. MIGRATE_DATABASE_URL in .env, when set, is used instead of DATABASE_URL.
forma-migrate:
<<: *forma-image
restart: "no"
command: ["migrate"]
env_file:
- path: .env
required: false
environment:
<<: *forma-database
healthcheck:
disable: true
depends_on:
postgres:
condition: service_healthy
# Writes apps/server/authzed/schema.zed into SpiceDB; a no-op when it is already there. SpiceDB reports healthy a moment before it accepts a schema write, hence the restarts. A SpiceDB holding a different schema is left alone: apply it with the digest `authz schema check` prints (see authzed-ops below).
authzed-initialize:
<<: *forma-image
restart: on-failure:10
command: ["authz", "schema", "apply"]
environment:
<<: [*forma-database, *forma-authzed]
LOG_LEVEL: warn
healthcheck:
disable: true
depends_on:
forma-migrate:
condition: service_completed_successfully
spicedb:
condition: service_healthy
forma:
<<: *forma-app
command: ["serve"]
ports:
- 3000:3000
# SAML SSO (SAML_ENABLED=1): put the identity provider's metadata XML in deploy/docker/saml-connection/.
volumes:
- ./saml-connection:/home/nextjs/apps/web/saml-connection
# Background jobs: the response pipeline, webhooks, emails, integrations, workflows, the AI summaries of open-text answers (so it needs the AI_* settings too) and the periodic tasks. Scale it with `docker compose up -d --scale forma-worker=2`.
forma-worker:
<<: *forma-app
command: ["worker"]
healthcheck:
disable: true
# Operator commands against SpiceDB and its projection, never started by `docker compose up`. Examples:
# docker compose --profile authzed-ops run --rm authzed-ops schema check
# docker compose --profile authzed-ops run --rm authzed-ops schema apply --expected-current-digest=sha256:<digest from check>
# docker compose --profile authzed-ops run --rm authzed-ops backfill
# docker compose --profile authzed-ops run --rm authzed-ops outbox status
# Every command prints one JSON line; exit 0 is clean, 2 drift, 1 failed (forma authz help).
authzed-ops:
<<: *forma-image
profiles: ["authzed-ops"]
entrypoint: ["/usr/local/bin/forma", "authz"]
command: ["schema", "check"]
environment:
<<: [*forma-database, *forma-authzed]
LOG_LEVEL: warn
healthcheck:
disable: true
depends_on:
postgres:
condition: service_healthy
spicedb:
condition: service_healthy
# Optional S3-compatible storage (COMPOSE_PROFILES=storage). Browsers upload straight to the bucket, so publish it under an address both they and the forma containers resolve (a domain routed to port 9000 by your proxy), and set in .env:
# RUSTFS_ADMIN_USER, RUSTFS_ADMIN_PASSWORD the RustFS administrator (openssl rand -hex 16 each)
# S3_ACCESS_KEY, S3_SECRET_KEY the service account rustfs-init creates for the app
# S3_BUCKET_NAME=forma, S3_REGION=us-east-1, S3_FORCE_PATH_STYLE=1
# S3_ENDPOINT_URL=https://files.example.com
# then run `docker compose run --rm rustfs-init` once.
rustfs-perms:
profiles: ["storage"]
image: busybox:1.36.1
restart: "no"
user: "0:0"
# RustFS runs as uid 10001.
command: ["sh", "-c", "mkdir -p /data && chown -R 10001:10001 /data"]
volumes:
- rustfs-data:/data
rustfs:
profiles: ["storage"]
restart: always
image: rustfs/rustfs:1.0.0-rc.2@sha256:7d6d361c49c08d427250fb59aae5d78df83d644c3405d9ccf4b21cda0b0692d0
command: /data
environment:
RUSTFS_ACCESS_KEY: ${RUSTFS_ADMIN_USER:-}
RUSTFS_SECRET_KEY: ${RUSTFS_ADMIN_PASSWORD:-}
RUSTFS_ADDRESS: ":9000"
ports:
- ${RUSTFS_PORT:-9000}:9000
volumes:
- rustfs-data:/data
depends_on:
rustfs-perms:
condition: service_completed_successfully
# Creates the bucket, its CORS rule for WEBAPP_URL (and PUBLIC_URL) and the app's service account. Idempotent.
rustfs-init:
profiles: ["storage"]
image: rustfs/rc:v0.1.36@sha256:ab024bfebee49a750ce886b4c70963ccd9ddaa03f491704a90710641d7a26699
restart: "no"
entrypoint: ["/bin/sh", "/usr/local/bin/rustfs-init.sh"]
environment:
RUSTFS_ADMIN_USER: ${RUSTFS_ADMIN_USER:-}
RUSTFS_ADMIN_PASSWORD: ${RUSTFS_ADMIN_PASSWORD:-}
RUSTFS_BUCKET_NAME: ${S3_BUCKET_NAME:-forma}
RUSTFS_POLICY_NAME: forma-policy
RUSTFS_SERVICE_USER: ${S3_ACCESS_KEY:-}
RUSTFS_SERVICE_PASSWORD: ${S3_SECRET_KEY:-}
RUSTFS_CORS_ALLOWED_ORIGINS: ${WEBAPP_URL:-},${PUBLIC_URL:-}
volumes:
- ./rustfs-init.sh:/usr/local/bin/rustfs-init.sh:ro
depends_on:
- rustfs
# A local Qwen model behind vLLM's OpenAI-compatible API (COMPOSE_PROFILES=qwen, needs an NVIDIA GPU). Point the app at it in .env:
# AI_PROVIDER=openai-compatible
# AI_MODEL=qwen3-14b-awq
# AI_OPENAI_COMPATIBLE_BASE_URL=http://vllm:8000/v1
# AI_OPENAI_COMPATIBLE_PROVIDER_NAME=vllm
# AI_OPENAI_COMPATIBLE_SUPPORTS_STRUCTURED_OUTPUTS=1
vllm:
restart: always
profiles: ["qwen"]
image: ${QWEN_VLLM_IMAGE:-vllm/vllm-openai:v0.14.0}
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
command:
- vllm
- serve
- ${QWEN_MODEL_ID:-Qwen/Qwen3-14B-AWQ}
- --host
- 0.0.0.0
- --port
- "8000"
- --no-enable-prefix-caching
- --max-model-len
- ${QWEN_MAX_MODEL_LEN:-8192}
- --dtype
- ${QWEN_DTYPE:-float16}
- --tensor-parallel-size
- ${QWEN_TENSOR_PARALLEL_SIZE:-1}
- --max-num-seqs
- ${QWEN_MAX_NUM_SEQS:-8}
- --gpu_memory_utilization
- ${QWEN_GPU_MEMORY_UTILIZATION:-0.9}
- --served-model-name
- ${QWEN_SERVED_MODEL_NAME:-qwen3-14b-awq}
- --default-chat-template-kwargs
- '{"enable_thinking": false}'
environment:
HF_HOME: /data
volumes:
- qwen-model-cache:/data
ports:
- "${QWEN_VLLM_HOST:-127.0.0.1}:${QWEN_VLLM_PORT:-8000}:8000"
healthcheck:
test:
[
"CMD",
"python3",
"-c",
"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=5).read()",
]
interval: 30s
timeout: 10s
retries: 40
start_period: 10m
volumes:
postgres:
driver: local
redis:
driver: local
rustfs-data:
driver: local
qwen-model-cache:
driver: local