-
Notifications
You must be signed in to change notification settings - Fork 72
Expand file tree
/
Copy pathdocker-compose.portainer.yml
More file actions
174 lines (170 loc) · 9.61 KB
/
Copy pathdocker-compose.portainer.yml
File metadata and controls
174 lines (170 loc) · 9.61 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
# Self-contained production stack for fazer.ai agents on Portainer (Tier B deploy) — and any host where
# you want TLS terminated INSIDE the stack instead of by an external proxy.
#
# Difference vs docker-compose.prod.yml: prod.yml only publishes the app's HTTP port and expects you to
# bring your own reverse proxy (Traefik/Caddy/NPM). THIS file bundles a Caddy service that terminates TLS
# and obtains a REAL certificate automatically (Let's Encrypt / ZeroSSL via the HTTP-01 challenge) for
# CADDY_DOMAIN — no Traefik labels, no Docker-socket polling. That is the Tier B "agent brings up Caddy"
# path in the onboarding plan.
#
# WHY SELF-CONTAINED (not a `-f` override of prod.yml): Portainer's "deploy from string" API
# (POST /api/stacks/create/standalone/string) accepts ONE compose document, so this file repeats prod.yml's
# agents + postgres verbatim and adds caddy. Keep those two blocks in sync with docker-compose.prod.yml.
#
# Fill the env with:
# bun scripts/gen-onboarding-env.ts --public-url https://agents.example.com [--acme-email you@example.com]
# It emits CADDY_DOMAIN + the two-role DB URLs + secrets. On Portainer, paste this file as a Stack and set
# the same variables in the Stack env editor (or pass them as the API's Env[] array). To also serve the
# Portainer UI itself on a clean HTTPS domain, set PORTAINER_DOMAIN (e.g. portainer.example.com) — Caddy
# adds a second site reverse-proxying to the host's Portainer (9443, self-signed upstream).
#
# TWO database roles by design (docs/deploy.md): MIGRATION_DATABASE_URL = superuser (migrate + bootstrap);
# DATABASE_URL / LANGGRAPH_DATABASE_URL = NON-superuser runtime role (RLS holds; the boot guard enforces it).
# SINGLE replica by construction (scheduler / debounce / webhook / alert workers + realtime pub/sub are
# single-process). Do NOT scale agents to >1 here.
services:
caddy:
image: 'caddy:2'
restart: always
depends_on:
- agents
ports:
- '80:80'
- '443:443'
extra_hosts:
# Lets the optional Portainer-panel site (PORTAINER_DOMAIN) reach the host's Portainer UI on 9443.
- 'host.docker.internal:host-gateway'
environment:
- CADDY_DOMAIN=${CADDY_DOMAIN}
- ACME_EMAIL=${ACME_EMAIL:-}
- PORTAINER_DOMAIN=${PORTAINER_DOMAIN:-}
- PORTAINER_UPSTREAM=${PORTAINER_UPSTREAM:-https://host.docker.internal:9443}
# Build a Caddyfile from env at start (a string-only Portainer deploy ships no files), then run it.
# `$$VAR` is escaped from compose interpolation so the SHELL expands it at runtime from `environment:`;
# `${VAR}` in `environment:` above IS interpolated by compose from the stack env / .env.
entrypoint:
- sh
- -ceu
- |
cf=/etc/caddy/Caddyfile
: > "$$cf"
if [ -n "$${ACME_EMAIL}" ]; then
printf '{\n\temail %s\n}\n' "$${ACME_EMAIL}" >> "$$cf"
fi
printf '%s {\n\treverse_proxy agents:3000\n}\n' "$${CADDY_DOMAIN}" >> "$$cf"
if [ -n "$${PORTAINER_DOMAIN}" ]; then
printf '%s {\n\treverse_proxy %s {\n\t\ttransport http {\n\t\t\ttls_insecure_skip_verify\n\t\t}\n\t}\n}\n' "$${PORTAINER_DOMAIN}" "$${PORTAINER_UPSTREAM}" >> "$$cf"
fi
echo '----- generated Caddyfile -----'
cat "$$cf"
echo '-------------------------------'
exec caddy run --config "$$cf" --adapter caddyfile
volumes:
- 'caddy_data:/data'
- 'caddy_config:/config'
agents:
# Edition-driven: Free (default, public image) vs Pro (set AGENTS_IMAGE to the private
# Harbor image, project `agents`, after `docker login` — hub: create_registry_credential, per-user).
# PLACEHOLDER: the public Free image is not published yet — swap `agents` once it exists.
image: ${AGENTS_IMAGE:-ghcr.io/fazer-ai/agents:latest}
pull_policy: always
restart: always
# NOTE: no `command` here on purpose — the image entrypoint runs, in order: db-bootstrap.ts (idempotent
# runtime-role provisioning) → prisma migrate deploy → serve. Overriding risks drifting from that contract.
environment:
- NODE_ENV=production
- PORT=3000
- PUBLIC_URL=${PUBLIC_URL}
- CDN_URL=${PUBLIC_URL}
# NOTE: default to the public URL, NOT `*` — the WS realtime origin check does an exact
# match, so `*` matches no real Origin and 403s every WebSocket upgrade.
- CORS_ORIGIN=${CORS_ORIGIN:-${PUBLIC_URL}}
# Rate-limit keying. Safe to trust here: this service publishes no port and is reachable only
# through the caddy service above, which writes X-Forwarded-For itself. Without this the whole
# deployment shares one bucket. Hops stays 1 even behind a CDN: measured, a Caddy with no
# `trusted_proxies` DISCARDS the incoming chain and emits only its own peer, so raising it would
# find a chain shorter than the count and fall back to the Caddy socket — one bucket again. To
# key on the real client behind a CDN, give the caddy service `trusted_proxies` for the CDN's
# ranges first, then raise this to 2.
# NOTE: the outgoing runtime role during a DATABASE_URL rotation, declared for the length of
# the overlap. Bootstrap reconciles fleet-role membership down to this database's runtime role
# and the administrator, so without this the outgoing role loses its cross-tenant path mid-
# drain and every asSuperAdmin call in the container still serving on it — API-key
# verification included — starts reading zero rows. Not inferred from an open session: a
# stale installation whose database was recreated under the same name presents exactly the
# same session (measured). Comma-separated; clear it once the old container is gone.
- FLEET_ROLE_RETAIN_MEMBER=${FLEET_ROLE_RETAIN_MEMBER:-}
- TRUST_PROXY=true
- TRUSTED_PROXY_HOPS=${TRUSTED_PROXY_HOPS:-1}
# NOTE: rate-limit ceilings, per client address. The first two are runaway guards, generous on
# purpose. The credential pair is the tight one: it covers login, signup, the one-time /setup
# token and accept-invite, and its window is minutes rather than one minute because a
# per-minute ceiling resets 60 times an hour. Raise RATE_LIMIT_CREDENTIAL_MAX if a shared
# office address trips it; the bucket is keyed by IP, so everyone behind one NAT shares it.
# Boot refuses a credential budget that collides with another limiter's budget+window, or that
# is not tighter than RATE_LIMIT_USER_PER_MIN. See .env.example.
- RATE_LIMIT_USER_PER_MIN=${RATE_LIMIT_USER_PER_MIN:-600}
- RATE_LIMIT_MCP_PER_MIN=${RATE_LIMIT_MCP_PER_MIN:-1200}
- RATE_LIMIT_CREDENTIAL_MAX=${RATE_LIMIT_CREDENTIAL_MAX:-20}
- RATE_LIMIT_CREDENTIAL_WINDOW_MINUTES=${RATE_LIMIT_CREDENTIAL_WINDOW_MINUTES:-5}
# First-run: disable the /setup token in this trusted, TLS-fronted onboarding window (the
# advisory lock still caps /setup at one admin). Set to `true` to require it.
- SETUP_TOKEN_REQUIRED=${SETUP_TOKEN_REQUIRED:-false}
# MCP Dynamic Client Registration, so the operator's AI agent (Claude Code / Codex) can log in
# to THIS instance's MCP. Also the app default — pinned here so the stack works on an older
# image too, since without it both clients abort before any login screen (docs/mcp.md).
- MCP_DCR_ENABLED=${MCP_DCR_ENABLED:-true}
- LOG_LEVEL=${LOG_LEVEL:-info}
- JWT_SECRET=${JWT_SECRET}
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
# Superuser/owner connection — migrations + bootstrap only. Matches the postgres container creds below.
- MIGRATION_DATABASE_URL=${MIGRATION_DATABASE_URL}
# Runtime role — NON-superuser, NON-bypassrls (db-bootstrap creates it from this URL). RLS holds.
- DATABASE_URL=${DATABASE_URL}
# Checkpointer pool — MUST be the runtime (non-superuser) role, never the migration URL.
- LANGGRAPH_DATABASE_URL=${LANGGRAPH_DATABASE_URL}
# Persist uploaded branding + issued document PDFs on the `storage` volume (defaults live on the
# ephemeral container FS and vanish on every redeploy).
- BRANDING_STORAGE_DIR=/app/storage/branding
- DOCUMENTS_STORAGE_DIR=/app/storage/documents
# NOTE: kept for installations created before the rename. Platforms that FREEZE a compose
# value at install time (Coolify) never deliver the new name to an existing install, so
# dropping this line would send its PDFs to the container filesystem, where a redeploy
# erases them without a word. config.ts reads DOCUMENTS_STORAGE_DIR first, then this.
- QUOTES_STORAGE_DIR=/app/storage/quotes
# No host port publish: Caddy is the only ingress and reaches the app over the compose network.
expose:
- '3000'
volumes:
- 'storage:/app/storage'
healthcheck:
test: ['CMD-SHELL', 'wget -qO- http://localhost:3000/api/health || exit 1']
interval: 20s
timeout: 10s
retries: 5
depends_on:
postgres:
condition: service_healthy
postgres:
# pgvector image (NOT plain postgres): the schema needs CREATE EXTENSION vector. pg17 matches the
# validated isolation spike. Not published to the host — only the app reaches it over the network.
image: 'pgvector/pgvector:pg17'
restart: always
environment:
- POSTGRES_DB=${DATABASE_NAME:-fazerai_agents_db}
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
volumes:
- 'postgres:/var/lib/postgresql/data'
healthcheck:
test:
- CMD-SHELL
- 'pg_isready -h localhost -p 5432 -U $${POSTGRES_USER} -d $${POSTGRES_DB}'
interval: 20s
timeout: 20s
retries: 10
volumes:
storage:
postgres:
caddy_data:
caddy_config: