# Managoat, self-hosted. # # cp .env.compose.example .env # then fill in the generated keys # docker compose up -d # open http://localhost:4000 # # For local development you only want the database, and `docker compose up -d # postgres` still does exactly that — `mix setup` creates fountain_dev itself. # See SETUP.md. services: postgres: image: postgres:16 environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} POSTGRES_DB: fountain ports: # Host-side convenience only (psql, and the database-only dev path) — # the app reaches Postgres over the compose network. Parameterised # because a machine evaluating Managoat usually already runs its own # Postgres on 5432 (#549). - "${POSTGRES_HOST_PORT:-5432}:5432" volumes: - postgres_data:/var/lib/postgresql/data healthcheck: # The app runs migrations at boot, so it must not start against a # database that is still initialising. test: ["CMD-SHELL", "pg_isready -U postgres -d fountain"] interval: 5s timeout: 5s retries: 10 app: # The fallback tag is a pinned release, deliberately not `latest`: # `latest` moves on every merge to main, across breaking minors, with # migrations applied at boot and downgrade unsupported — the exact # combination the versioning guide tells you never to run. Upgrade by # setting MANAGOAT_IMAGE_TAG in .env (see .env.compose.example). An .env # from before ADR 0082 that sets FOUNTAIN_IMAGE_TAG still works. image: ghcr.io/managoat/managoat:${MANAGOAT_IMAGE_TAG:-${FOUNTAIN_IMAGE_TAG:-v0.39.2}} # To run your own build instead of the published image, comment the line # above and uncomment this: # build: . depends_on: postgres: condition: service_healthy ports: - "${PORT:-4000}:4000" # The egress broker. Sandboxes dial it at BROKER_PROXY_URL, so this # port has to be reachable from the sandbox provider's network. - "${BROKER_PORT:-14322}:14322" environment: PHX_SERVER: "true" PORT: "4000" DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/fountain # A stock postgres image does not serve TLS, and this was hardcoded on — # which is why a compose setup could not have worked before. DATABASE_SSL: "false" # Only meaningful against an external Postgres that serves TLS (which # also means editing DATABASE_URL and DATABASE_SSL above): verify its # certificate instead of the default encrypted-but-unauthenticated mode. DATABASE_SSL_VERIFY: ${DATABASE_SSL_VERIFY:-} DATABASE_SSL_CA_FILE: ${DATABASE_SSL_CA_FILE:-} # Absolute and scheme-ful: it builds the links in verification emails and # is handed to every sprite as MANAGOAT_BASE_URL. Change it if you reach # this instance by anything other than localhost. PUBLIC_URL: ${PUBLIC_URL:-http://localhost:4000} # Behind a reverse proxy, set this to the proxy's address range before # exposing the instance — otherwise per-IP rate limits collapse into a # single bucket keyed on the proxy's address. TRUSTED_PROXIES: ${TRUSTED_PROXIES:-} # Required, but enforced by the app at boot (config/runtime.exs raises # a descriptive error for either one missing or empty) rather than by a # compose `:?` marker: compose interpolates this whole file at load # time regardless of which service is targeted, so a hard requirement # here made even `docker compose up -d postgres` — the documented # database-only dev path — abort on a fresh clone (#392). SECRET_KEY_BASE: ${SECRET_KEY_BASE:-} MASTER_SECRETS_KEY: ${MASTER_SECRETS_KEY:-} # Conversations need a Sprites token. The app boots without one; every # attempt to start a conversation then fails. SPRITES_TOKEN: ${SPRITES_TOKEN:-} SPRITES_BASE_URL: ${SPRITES_BASE_URL:-} SANDBOX_PROVIDER: ${SANDBOX_PROVIDER:-} E2B_API_KEY: ${E2B_API_KEY:-} E2B_BASE_URL: ${E2B_BASE_URL:-} E2B_TEMPLATE: ${E2B_TEMPLATE:-} E2B_USER: ${E2B_USER:-} # The browser apps on top of this server's /api. The console is not an # interactive client: watching a conversation lives in a separate # single-page app, and CONVERSATIONS_APP_URL already defaults to the # hosted build. That default links # somewhere real, so a deployment that does not admit its origin in # API_CORS_ORIGINS shows working links that fail CORS in the reader's # browser with nothing in the server log to explain it (#1215). # # These three are written with no value on purpose. That form passes the # variable through only when .env actually sets it, which is the only # way to keep all three states the app distinguishes: unset keeps the # app's own default, `CONVERSATIONS_APP_URL=` passes "" and means this # deployment has no such app, and a URL points at your own build. # `${VAR:-}` cannot express the first state — it would send "" for an # absent key and silently retire the hosted app. API_CORS_ORIGINS: OAUTH_CLIENTS: CONVERSATIONS_APP_URL: # Empty means the app's defaults apply. Advertised in # .env.compose.example; a key listed there but not passed through here # is silently ignored, which is what the drift test now refuses. CHECK_ORIGIN_EXTRA: ${CHECK_ORIGIN_EXTRA:-} SANDBOX_IDLE_TIMEOUT_MINUTES: ${SANDBOX_IDLE_TIMEOUT_MINUTES:-} SANDBOX_MAX_LIFETIME_HOURS: ${SANDBOX_MAX_LIFETIME_HOURS:-} # Mail is off until SMTP_HOST is set, so a first run needs nothing # external. Accounts self-verify at registration while it is off # (ADR 0011); set SMTP_HOST before you invite anyone. SMTP_HOST: ${SMTP_HOST:-} SMTP_PORT: ${SMTP_PORT:-587} SMTP_USERNAME: ${SMTP_USERNAME:-} SMTP_PASSWORD: ${SMTP_PASSWORD:-} # Deliberately blank, not defaulted: with SMTP_HOST set the app # refuses to boot until EMAIL_FROM names an address on a domain your # provider is verified for. With mail off it is unused. EMAIL_FROM: ${EMAIL_FROM:-} # Blank means auto (ADR 0011): open so the first account can be # created, then closed to self-registration once an admin exists. # "true" keeps it open for everyone, for good; "false" closes it now. REGISTRATION_ENABLED: ${REGISTRATION_ENABLED:-} # Error tracking. Off unless set — nothing leaves the instance. SENTRY_DSN: ${SENTRY_DSN:-} # The first account to verify becomes the admin (ADR 0011), so # register before exposing the instance. Images after v0.39.2 do this # unconditionally and ignore FIRST_USER_ADMIN; up to v0.39.2 it is the # switch. Drop it once the pin is past v0.39.2. FIRST_USER_ADMIN: ${FIRST_USER_ADMIN:-true} # ── The credential broker ─────────────────────────────────────────── # # Every conversation is brokered (ADR 0019): the real token never # enters the sandbox. The proxy listens on 14322 inside the container, # published above. BROKER_PROXY_URL is required, and enforced by the # app at boot for the same reason as SECRET_KEY_BASE: it is the # address a sandbox dials, through an ingress the sandbox provider can # reach (a public IP, Tailscale Funnel, an ngrok TCP tunnel). See # docs/configuration.md. BROKER_LISTEN_PORT: "14322" BROKER_PROXY_URL: ${BROKER_PROXY_URL:-} BROKER_LOG_RETENTION_HOURS: ${BROKER_LOG_RETENTION_HOURS:-} # There is no OTLP collector in a default compose setup, and without this # the exporter retries per span and floods the logs. OTEL_TRACES_EXPORTER: "none" healthcheck: # Probes the documented /health endpoint, so `docker compose ps` flags a # hung-but-running app instead of reporting it healthy forever. The # runtime image (debian slim) ships neither curl nor wget, so this does # the HTTP round-trip with bash's /dev/tcp — bash, printf and grep are # all in the base image. test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/4000 && printf 'GET /health HTTP/1.1\\r\\nHost: 127.0.0.1\\r\\nConnection: close\\r\\n\\r\\n' >&3 && head -n1 <&3 | grep -q ' 200 '"] interval: 30s timeout: 5s retries: 3 # Boot runs migrations before the endpoint listens; don't count failures # against the container while that is still happening. start_period: 40s restart: unless-stopped # Nightly pg_dump into the backup_data volume, pruned after # BACKUP_RETENTION_DAYS. Off unless you opt in: # # docker compose --profile backup up -d # # The volume lives on the same host as the database, so this protects # against bad migrations and fat fingers, not a dead machine — copy dumps # off-host too, and back up MASTER_SECRETS_KEY separately: a database # backup alone cannot decrypt itself. Restore drill: # https://managoat.com/docs/guides/operate/back-up-and-restore backup: image: postgres:16 profiles: ["backup"] depends_on: postgres: condition: service_healthy environment: PGHOST: postgres PGUSER: postgres PGPASSWORD: ${POSTGRES_PASSWORD:-postgres} PGDATABASE: fountain BACKUP_INTERVAL_SECONDS: ${BACKUP_INTERVAL_SECONDS:-86400} BACKUP_RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-14} entrypoint: ["/bin/bash", "-c"] command: # A failed dump logs, deletes its partial file, and leaves every old # backup alone — pruning happens only after a successful dump, so a # broken database can never age out the backups you would restore from. - | while true; do TS="$$(date -u +%Y-%m-%dT%H-%M-%SZ)" OUT="/backups/fountain-$${TS}.dump" echo "Dumping to $${OUT}..." if pg_dump -Fc -Z6 --no-owner --no-privileges -f "$$OUT" \ && [ "$$(stat -c %s "$$OUT")" -ge 10240 ]; then echo "Backup complete: $${OUT} ($$(stat -c %s "$$OUT") bytes)" find /backups -name 'fountain-*.dump' -mtime "+$${BACKUP_RETENTION_DAYS}" -delete else echo "FAIL: dump did not complete; keeping old backups" rm -f "$$OUT" fi sleep "$$BACKUP_INTERVAL_SECONDS" done volumes: - backup_data:/backups restart: unless-stopped volumes: postgres_data: backup_data: