docker-compose.yml
Rama main · réplica de lectura del repositorio original.
# 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: