# Bounded stdout logging for every service. Without this Docker's json-file # driver grows logs until the container is replaced — with it, retention has a # hard cap per service. Keep it applied to ALL services. x-logging: &bounded-logging driver: json-file options: max-size: "50m" max-file: "3" services: db: image: postgres:17-alpine # DB credentials are their own knobs — deliberately NOT tied to TENANT_ID # (that's a key-branding slug; changing it must never break DB auth). # NOTE: postgres only applies these on FIRST init of the data volume — # changing them later does NOT rename the existing user/db (reset with # `docker compose down -v` in dev, or migrate properly in prod). environment: POSTGRES_USER: ${POSTGRES_USER:-bevel} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-bevel} POSTGRES_DB: ${POSTGRES_DB:-bevel} expose: - "5432" volumes: - pgdata:/var/lib/postgresql/data restart: unless-stopped logging: *bounded-logging app: # The image CI publishes on every release — deploying does not compile # anything. Pin the version in `.env` (`HEXIS_VERSION=0.10.0`) so an # upgrade is a deliberate act: bump the value, `docker compose pull app`, # `up -d`. Left unset it tracks `latest` (the newest release) — right for # a first install, a footgun after: an unplanned `pull` becomes an # unplanned upgrade. # # Building from SOURCE (a staging server tracking dev, a fork) is what # `deployment/docker-compose.build.yml` is for — a standalone file an # orchestrator can be pointed at directly. image: ghcr.io/bevel-software/hexis:${HEXIS_VERSION:-latest} # Run node under a real init, so PID 1 reaps orphans. init: true # Cap the container's task count so a pid/thread leak degrades the app # instead of the host. pids_limit: 4096 # NO host port is published here, deliberately. A reverse proxy (Coolify, # Traefik, nginx) reaches the container on this port over the compose # network, and publishing a FIXED host port alongside that breaks every # redeploy: the replacement container starts while the outgoing one still # holds the port, and Docker refuses it with "port is already allocated". # # `docker compose up` with no `-f` still publishes it — that is what # `docker-compose.override.yml` is for, and Compose merges it # automatically. Passing `-f docker-compose.yml` (as orchestrators do) # skips the override and leaves the container unpublished, which is # exactly the split we want. expose: - "3001" # A NAME ON ITS OWN (`- GIT_TOKEN`) is a pass-through: the value reaches the # container when the operator sets it, and the variable simply does not # exist when they do not. Everything optional is written that way on # purpose, for two reasons. # # It keeps the deployment UI honest. An orchestrator that reads this file # turns every interpolated reference it finds into a variable you must fill # in or leave blank — and then refuses to let you delete it, because the # file still names it. A pass-through leaves nothing to find, so the # settings collected on the SETUP SCREEN stop being presented as deployment # configuration you forgot. # # And it keeps the empty-default form from lying. That form defines the # variable as an empty string, which is a value; a pass-through leaves it # genuinely unset. The app treats both as absent, so this is belt and # braces rather than load-bearing — but "unset" is what is meant, so it # is what is written. # # Variables that are genuinely REQUIRED keep an interpolated reference, so # the operator is still prompted for them. # # deployment/docker-compose.build.yml and docker-compose.portainer.yml # mirror this list (each must stand alone for its orchestrator) — a # variable added here is added to both. environment: - NODE_ENV=production - PORT=3001 # The commit this build came from, reported by `GET /api/health`. # Coolify injects SOURCE_COMMIT for git-based deploys — but only into the # environment it hands compose, so without a pass-through here it never # reached the container and health reported 'unknown' on every hosted # deploy. `version.ts` has named this fallback all along; this is what # makes it true. Nothing to fill in: absent, health still says 'unknown'. # # LEAVE COOLIFY'S "Include Source Commit in Build" OFF. It controls # BUILD-time availability, and turning it on defeats layer caching across # commits — the setting says so itself, and adds that SOURCE_COMMIT "will # still be available at runtime" either way. Runtime is all we need: # `version.ts` reads it from the process environment when the server # starts, not from anything baked into the image. - SOURCE_COMMIT # Git host credential. The KB can live on any git server (GitHub, GitLab, # Bitbucket, Azure DevOps, self-hosted): GIT_TOKEN is the Basic-auth # password and GIT_USERNAME the Basic username (host-specific — see # .env.example). - GIT_TOKEN - GIT_USERNAME - JWT_SECRET=${JWT_SECRET} # The deployment owner. REQUIRED: always an admin whatever the sign-in # method, and the initial Admin written into a freshly seeded KB. - ADMIN_EMAIL=${ADMIN_EMAIL:-} # Bootstrap password credential — checked against the environment at login # time and never stored. Read ONCE at startup, so changing or unsetting it # takes effect when the container restarts, not before. Required only # while password login is enabled. - ADMIN_PASSWORD=${ADMIN_PASSWORD:-} # Generic OIDC single sign-on; the method appears once all three are set. # Also settable on the setup screen, which shows the redirect URI. - OIDC_ISSUER_URL - OIDC_CLIENT_ID - OIDC_CLIENT_SECRET - OIDC_SCOPES - OIDC_PROVIDER_LABEL # Reverse-proxy hop count. Required behind a proxy (Coolify, Traefik, # nginx) or every request looks like it came from the proxy — which # collapses the per-IP login rate limit onto one bucket. (Defaults to 1 # when DOMAIN below is set — that declares the bundled Caddy in front.) - TRUST_PROXY # The public host name, for deployments fronted by the bundled `caddy` # service below (`docker compose -f docker-compose.yml --profile https up -d`). Setting it # declares "the bundled Caddy fronts this deployment", and the app # derives the rest: PUBLIC_BACKEND_URL / PUBLIC_FRONTEND_URL default to # https:// and TRUST_PROXY to 1 (one hop — Caddy). Explicit # values for any of those always win. - DOMAIN # Tenant slug branded into every credential prefix. Default `bevel` # keeps pre-existing keys/tokens valid — change it only for a fresh deploy. - TENANT_ID=${TENANT_ID:-bevel} - LOGIN_PASSWORD=${LOGIN_PASSWORD:-true} # SSO allow-list (part of the OIDC config above). SSO auto-provisions, so # against a multi-tenant issuer this is the only signup boundary. - ALLOWED_EMAIL_DOMAINS # The knowledge base. Left unset, an admin supplies these on the setup # screen at first sign-in — where the credentials can be tested against # the real host before they are saved. - KB_REPO_URL - KB_DIR_NAME # Branch model. Runtime only now — the frontend is served these over # /api/config instead of having them compiled in, so one image runs # anywhere and renaming a branch no longer needs a rebuild. Left unset, # they are collected on the setup screen. - DEFAULT_BRANCH - PROTECTED_BRANCHES # 32-byte key (base64 or hex) encrypting secrets-vault values + MCP # OAuth client secrets/tokens. - SECRETS_ENC_KEY=${SECRETS_ENC_KEY} # Update check (admins see a banner when a release is newer). Pass-through # so UPDATE_CHECK=false actually reaches the app — air-gapped and # opt-out deployments disable the one outbound request with it. - UPDATE_CHECK # Public URLs for OAuth redirects + post-login bounces. Pass-throughs, # like everything optional: the defaults live in the app (same move as # DATABASE_URL below), which in production falls back to https:// # when DOMAIN is set and to this container's own origin otherwise — the # backend serves the SPA itself. Writing a default HERE would defeat the # DOMAIN derivation: the container would always see a value. - PUBLIC_BACKEND_URL - PUBLIC_FRONTEND_URL # The database. Set DATABASE_URL to point at a Postgres you already have; # left unset, the app builds one from the POSTGRES_* values below, which # are the same ones the bundled `db` service is created with. # # The composition moved INTO the app (`resolveDatabaseUrl`) rather than # being written here as a nested default. Expressing it in compose needed # an interpolated default containing further interpolations, and a # deployment UI that scans this file with a regex rather than a YAML # parser cuts that at the first closing brace — offering the operator a # DATABASE_URL whose value is a truncated fragment. Doing it in code # also lets the parts be URL-encoded, which shell interpolation cannot: # a password containing `@` or `/` used to produce an unparseable URL. # NEITHER of these interpolates, and that is the whole point. A # deployment UI that reads this file creates a managed variable for every # interpolated reference it finds — and then will not let you delete it, # because the file still names it. A bare name (pass-through) and a # literal value both leave it nothing to create. # # So: DATABASE_URL passes through when the operator sets it, and the host # of the bundled database is written literally, because it is not a # choice — inside this compose network the service is called `db`. To # point at a Postgres somewhere else, set DATABASE_URL; that is what it # is for, and it reaches the container by the line below. - DATABASE_URL - POSTGRES_HOST=db - POSTGRES_USER=${POSTGRES_USER:-bevel} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-bevel} - POSTGRES_DB=${POSTGRES_DB:-bevel} # node:22-slim ships neither curl nor wget; Node 22's global fetch probes. healthcheck: test: ["CMD", "node", "-e", "fetch('http://localhost:3001/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] interval: 10s timeout: 5s retries: 6 # Boot includes DB migrations + KB seeding on first run; give it room # before failures start counting toward `retries`. start_period: 90s depends_on: db: condition: service_started restart: unless-stopped volumes: # Per-branch workspace clones + workspace-root scratch files. Without # this, a redeploy wipes them (KB content re-clones from origin, but # scratch files outside the KB dir are truly lost). - workspaces:/app/apps/server/workspaces # Diff-review backup ledger (sibling of `workspaces` by config default). - backups:/app/apps/server/backups # `call_tool_chain` spill files (sibling of `workspaces` by default). - spills:/app/apps/server/tool-chain-spills logging: *bounded-logging # Public HTTPS in one step, for a server with NO reverse proxy of its own — # a bare EC2 instance, a plain VPS. Opt in with: # # docker compose -f docker-compose.yml --profile https up -d # # The explicit `-f` matters from a CLONE: without it Compose also merges # docker-compose.override.yml, which publishes app port 3001 on the host — # beside Caddy, and reachable by clients that can then spoof forwarded # headers against the one-hop TRUST_PROXY this profile implies. (wget # deployments have no override file; the flag is harmless there.) # # Caddy obtains and renews Let's Encrypt certificates for $DOMAIN and # redirects HTTP→HTTPS; set DOMAIN in `.env`, and the app derives the public # origins and proxy hop from it (see the app's DOMAIN comment above). # Requirements: a DNS A (or AAAA) record for the domain pointing at this # server, and ports 80 + 443 reachable from the internet — 80 is not # optional, the certificate challenge and the HTTP→HTTPS redirect both use # it. Port 3001 stays closed; only Caddy talks to the app, over the compose # network. Deployments that already sit behind a proxy (Coolify, Traefik, # nginx) do NOT want this profile — two things terminating TLS for one app # is one too many. caddy: profiles: ["https"] image: caddy:2-alpine ports: - "80:80" - "443:443" # HTTP/3. Caddy offers it by default; without the UDP mapping the offer # dangles and browsers fall back after a timeout instead of instantly. - "443:443/udp" environment: # A soft `:-` default, NOT `:?`, even though DOMAIN is genuinely # required here: compose interpolates the WHOLE file even for services # whose profile is inactive, so a hard `:?` would break plain # `docker compose up` for everyone. The command below refuses to start # without it instead — same outcome, only when the profile is in play. - DOMAIN=${DOMAIN:-} # No Caddyfile: `caddy reverse-proxy` is Caddy's production-ready quick # subcommand — given a bare hostname in --from it serves that host over # HTTPS with automatic ACME certificates AND the port-80 HTTP→HTTPS # redirect, no config file needed. `$$DOMAIN` (doubled $) keeps compose # from interpolating at config time; the shell inside the container reads # the runtime value, which is what lets the empty check run at start # rather than at parse. command: - sh - -c - | if [ -z "$$DOMAIN" ]; then echo "The https profile needs DOMAIN: set it (in .env, next to the other values) to the public host name this deployment should serve, e.g. DOMAIN=bevel.your-domain.com" >&2 exit 1 fi exec caddy reverse-proxy --from "$$DOMAIN" --to http://app:3001 volumes: # Certificates + ACME account state. Named volumes so they survive # redeploys — Let's Encrypt rate-limits issuance per domain, and a # deploy loop that discards certs can lock the domain out for a week. - caddy_data:/data - caddy_config:/config depends_on: - app restart: unless-stopped logging: *bounded-logging volumes: pgdata: workspaces: backups: spills: # Only used by the `https` profile (the caddy service above). caddy_data: caddy_config: