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
# The one compose file. Works with plain compose and with Docker Swarm:
#
# make up # = docker compose --project-directory . -f Docker/docker-compose.yml up -d
# make up-dev # same, but builds the bus from this checkout (--build)
# make deploy # = docker stack deploy -c Docker/docker-compose.yml crew, after a preflight
#
# Every variable has a default so a bare `make up` boots on a laptop; override
# from the environment (or ./.env at the repo root under plain compose — Swarm
# does not read .env, export them or use `env $(cat .env) docker stack deploy`).
# Production safety is NOT enforced here: `make deploy-check` refuses the
# example password, a moving `latest` tag and a missing dashboard secret
# before the cluster is contacted.
#
# The compose project name comes from COMPOSE_PROJECT_NAME in ./.env (a
# top-level `name:` here would break `docker stack deploy`, which rejects
# the property; Swarm names the stack from the CLI argument instead).
services:
db:
image: ${POSTGRES_IMAGE:-postgres:18.4-alpine3.24}
environment:
POSTGRES_USER: bus
# Change it for anything reachable from outside your machine.
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me}
POSTGRES_DB: bus
volumes:
# postgres:18+ expects the mount at /var/lib/postgresql (data lives in a
# versioned subdir), which keeps future pg_upgrade --link possible.
- bus-data:/var/lib/postgresql
healthcheck:
test:
interval: 5s
timeout: 3s
retries: 10
deploy:
replicas: 1
restart_policy:
condition: on-failure
delay: 3s
resources:
limits:
memory: ${POSTGRES_MEMORY_LIMIT:-2g}
# The broker, for teams routed to JetStream (ADR 0001, phases 4 to 7).
#
# **Zero replicas by default**, so a default deployment runs no broker at
# all and the bus never contacts one. An operator who wants the JetStream
# path sets NATS_REPLICAS=1 and BUS_NATS_URL, provisions the team's streams
# with `ai-crew-sync team stream`, and only then routes a team to it. None
# of those steps implies the next.
nats:
image: ${NATS_IMAGE:-nats:2.12-alpine}
# The configuration is written by the container rather than mounted from
# the repository. A relative path in `configs.file` resolves against the
# project directory, and who deploys decides what that is: the Makefile
# pins it to the repo root, Portainer uses the compose file's own folder.
# The same file then works for one and fails for the other, which is a
# deployment failing over a detail that has nothing to do with it.
#
# max_payload is why there is a file at all: nats-server takes it only
# from configuration (there is no flag), and its 1 MiB default refuses a
# 1 MiB body once envelope headers are added, by about two hundred bytes.
entrypoint:
command:
- |
cat > /tmp/nats.conf <<'CONF'
max_payload: 2MB
http_port: 8222
jetstream {
store_dir: /data
max_memory_store: 256MB
max_file_store: 8GB
}
CONF
exec nats-server -c /tmp/nats.conf
volumes:
- nats-data:/data
deploy:
replicas: ${NATS_REPLICAS:-0}
restart_policy:
condition: on-failure
delay: 3s
resources:
limits:
memory: ${NATS_MEMORY_LIMIT:-1g}
bus:
# Published multi-arch image by default. `make up-dev` sets BUS_IMAGE and
# BUS_VERSION to a local tag and passes --build, so a checkout build never
# shadows the published tag. Production pins an immutable BUS_VERSION.
image: ${BUS_IMAGE:-ghcr.io/joaquinbejar/ai-crew-sync}:${BUS_VERSION:-latest}
build:
# Relative to the project directory (the repo root, which the Makefile
# pins with --project-directory), NOT to this file's location. Swarm
# ignores this block (it only deploys images).
context: .
dockerfile: Docker/Dockerfile
environment:
DATABASE_URL: postgres://bus:${POSTGRES_PASSWORD:-change-me}@db:5432/bus
BUS_BIND: 0.0.0.0:8787
# Anti DNS-rebinding. "*" is fine behind a proxy that validates Host;
# set your real hostname when the bus is exposed directly.
BUS_ALLOWED_HOSTS: ${BUS_ALLOWED_HOSTS:-*}
# Empty means "generate at startup": sessions end at restart and are not
# shared between replicas. Set the same value everywhere in production.
BUS_DASHBOARD_SECRET: ${BUS_DASHBOARD_SECRET:-}
# Empty by default: no broker, no JetStream, nothing to configure. Set
# it to nats://nats:4222 together with NATS_REPLICAS=1 to make the
# optional path available. It routes nobody on its own.
BUS_NATS_URL: ${BUS_NATS_URL:-}
BUS_NATS_CREDENTIALS: ${BUS_NATS_CREDENTIALS:-}
# Drain the publication outbox here. Turn it off on the replicas that
# only serve requests when you run dedicated drainers.
BUS_PUBLICATION_WORKER: ${BUS_PUBLICATION_WORKER:-true}
# Per replica, on purpose: with N replicas the effective ceiling is N
# times this. The hard global limit belongs in the proxy.
BUS_RATE_LIMIT_PER_MINUTE: ${BUS_RATE_LIMIT_PER_MINUTE:-600}
# Each replica pings itself through Postgres this often to prove its
# event listener still hears; the ping is also the traffic that keeps
# Swarm's IPVS from forgetting the idle LISTEN connection (15 min).
BUS_EVENT_PING_SECS: ${BUS_EVENT_PING_SECS:-30}
RUST_LOG: ${RUST_LOG:-ai_crew_sync=info}
ports:
- "${BUS_PORT:-8787}:8787"
# Short-form only: Swarm rejects the condition syntax. If the bus starts
# before the database is ready it exits, and restart (compose) or
# restart_policy (Swarm) retries until the stack converges.
depends_on:
- db
restart: on-failure
networks:
- default
# The reverse proxy's network. Only used when a Traefik (or any other
# proxy) lives there; harmless otherwise. It must exist before `up`
# (the Makefile creates it locally; in Swarm the proxy's stack owns it).
- edge
deploy:
# Stateless (no sessions, identity per request): scale replicas freely
# behind the routing mesh. Concurrent startup migrations are safe —
# sqlx takes a Postgres advisory lock.
replicas: ${BUS_REPLICAS:-1}
restart_policy:
condition: on-failure
delay: 3s
update_config:
order: start-first
resources:
limits:
memory: ${BUS_MEMORY_LIMIT:-512m}
# Traefik v3 (`--providers.swarm`) discovers the bus by these labels when
# TRAEFIK_ENABLE=true. The proxy owns TLS and the certificate; the bus
# keeps its plain published port for clients on the private network and
# is reached at https://${BUS_PUBLIC_HOST}/mcp from the internet.
labels:
traefik.enable: "${TRAEFIK_ENABLE:-false}"
traefik.http.routers.crew-bus.rule: "Host(`${BUS_PUBLIC_HOST:-localhost}`)"
traefik.http.routers.crew-bus.entrypoints: "${TRAEFIK_ENTRYPOINT:-websecure}"
traefik.http.routers.crew-bus.tls.certresolver: "${TRAEFIK_CERTRESOLVER:-le}"
traefik.http.routers.crew-bus.service: "crew-bus"
traefik.http.services.crew-bus.loadbalancer.server.port: "8787"
traefik.http.services.crew-bus.loadbalancer.passhostheader: "true"
# MCP sessions are long-lived streams: do not let the proxy cut them
traefik.http.services.crew-bus.loadbalancer.responseforwarding.flushinterval: "-1"
networks:
default:
edge:
external: true
name: ${TRAEFIK_NETWORK:-edge}
volumes:
bus-data:
nats-data: