Two comments in the compose model block were wrong in a way that mattered to the question "do we actually need a Groq key". The note about agent routes had lost its antecedent in the previous commit and dangled above MODEL_PROVIDER, appearing to describe provider selection. It belongs to MODEL_API_KEY. It was also only half true. It said an absent key is "a legitimate way to run this", which is correct outside production and impossible inside it: this stack defaults to APP_ENV=production, where validateModel refuses to start without a credential unless MODEL_BASE_URL is loopback. isLoopback accepts only localhost, 127.0.0.1 and ::1, so host.docker.internal does not qualify and no containerised deployment can take the keyless path. Reading the old comment, an operator would reasonably conclude they could leave the key empty and get a working API without Owliver. They get a container that will not boot. The endpoint count was stale too: routeRuns registers two, not three. routeOwliver's one endpoint does not touch s.agents and stays registered. Comments only; no behaviour change. vet clean, config and httpserver pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PJvibeSc1JYXjatankqM1g
187 lines
8.5 KiB
YAML
187 lines
8.5 KiB
YAML
# ============================================================================
|
|
# Krow API — production stack
|
|
#
|
|
# cd infrastructure
|
|
# cp .env.docker.example .env
|
|
# docker compose up -d --build
|
|
#
|
|
# This file assumes a MANAGED PostgreSQL (RDS, Cloud SQL, Neon, a DBA's
|
|
# cluster) reachable from wherever these containers run. That is deliberate:
|
|
# a production database wants backups, point-in-time recovery, failover and a
|
|
# patch schedule, none of which a container in this file provides.
|
|
#
|
|
# To run PostgreSQL alongside the API — for staging, or to test the stack
|
|
# end-to-end on one host — layer the override on top:
|
|
#
|
|
# docker compose -f docker-compose.yml -f docker-compose.local-db.yml up -d
|
|
#
|
|
# ── ORDER OF OPERATIONS ─────────────────────────────────────────────────────
|
|
#
|
|
# `migrate` runs to completion BEFORE `api` starts, and `api` will not start if
|
|
# it fails. That is the expand/migrate/contract discipline from README.md
|
|
# expressed in the dependency graph rather than in a runbook: schema first,
|
|
# binary second, and every migration backwards-compatible with the version
|
|
# still running.
|
|
#
|
|
# ── TLS IS NOT OPTIONAL HERE ────────────────────────────────────────────────
|
|
#
|
|
# internal/config rejects DATABASE_SSLMODE=disable when APP_ENV=production, so
|
|
# this stack will refuse to start against a database that cannot do TLS. That
|
|
# is the intended behaviour and not something to work around by dropping to
|
|
# APP_ENV=staging — a staging flag on a production deployment is a lie the next
|
|
# person has to discover.
|
|
# ============================================================================
|
|
|
|
name: krow
|
|
|
|
# The database settings the API and the migration runner must agree on.
|
|
x-database-env: &database-env
|
|
DATABASE_HOST: ${DATABASE_HOST:?DATABASE_HOST is required}
|
|
DATABASE_PORT: ${DATABASE_PORT:-5432}
|
|
DATABASE_NAME: ${DATABASE_NAME:?DATABASE_NAME is required}
|
|
DATABASE_USER: ${DATABASE_USER:?DATABASE_USER is required}
|
|
DATABASE_PASSWORD: ${DATABASE_PASSWORD:?DATABASE_PASSWORD is required}
|
|
DATABASE_SCHEMA: ${DATABASE_SCHEMA:-public}
|
|
DATABASE_SSLMODE: ${DATABASE_SSLMODE:-require}
|
|
|
|
x-logging: &logging
|
|
driver: json-file
|
|
options:
|
|
max-size: "10m"
|
|
max-file: "5"
|
|
|
|
services:
|
|
# ── Schema ────────────────────────────────────────────────────────────────
|
|
# One-shot. Exits 0 when the schema is current, including when there was
|
|
# nothing to apply. `api` waits on that exit code.
|
|
#
|
|
# DATABASE_URL is a single pre-built string rather than assembled from the
|
|
# parts above because golang-migrate takes a URL, and a password containing
|
|
# @ : / ? or # must be percent-encoded in one. Compose cannot encode it, so
|
|
# the encoding is the operator's job — see .env.docker.example.
|
|
migrate:
|
|
image: migrate/migrate:v4.19.0
|
|
container_name: krow-migrate
|
|
volumes:
|
|
- ../migrations:/migrations:ro
|
|
command:
|
|
- -path=/migrations
|
|
- -database=${DATABASE_URL:?DATABASE_URL is required — see .env.docker.example}
|
|
- up
|
|
restart: "no"
|
|
logging: *logging
|
|
networks: [krow]
|
|
|
|
# ── API ───────────────────────────────────────────────────────────────────
|
|
api:
|
|
build:
|
|
context: ..
|
|
dockerfile: infrastructure/Dockerfile.api
|
|
args:
|
|
VERSION: ${VERSION:-dev}
|
|
image: ${IMAGE:-doormile/krowbackend:latest}
|
|
container_name: krow-api
|
|
depends_on:
|
|
migrate:
|
|
condition: service_completed_successfully
|
|
environment:
|
|
<<: *database-env
|
|
APP_ENV: ${APP_ENV:-production}
|
|
LOG_LEVEL: ${LOG_LEVEL:-info}
|
|
# Provider selection. Empty MODEL_PROVIDER means openai — the only
|
|
# implementation — and empty MODEL_BASE_URL means Groq.
|
|
#
|
|
# ANTHROPIC_API_KEY is passed through DELIBERATELY even though nothing
|
|
# reads it: a host that still exports it gets a loud startup failure
|
|
# telling it to rename the variable, instead of a container that silently
|
|
# boots with no credential and fails every agent run.
|
|
MODEL_PROVIDER: ${MODEL_PROVIDER:-}
|
|
MODEL_BASE_URL: ${MODEL_BASE_URL:-}
|
|
# REQUIRED HERE. This stack defaults to APP_ENV=production, and config
|
|
# refuses to start in production without a credential unless MODEL_BASE_URL
|
|
# is loopback — which host.docker.internal is not, so there is no keyless
|
|
# option in a container.
|
|
#
|
|
# Outside production the key may be empty, and that is a real mode rather
|
|
# than a broken one: the agent routes are then not registered at all, so
|
|
# the deployment answers 404 on /agents/{id}/runs and reports two fewer
|
|
# endpoints on /version. An API without Owliver, saying so once at boot
|
|
# instead of once per request.
|
|
MODEL_API_KEY: ${MODEL_API_KEY:-}
|
|
MODEL_REASONING_EFFORT: ${MODEL_REASONING_EFFORT:-}
|
|
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
|
MODEL_FAST: ${MODEL_FAST:-}
|
|
MODEL_BALANCED: ${MODEL_BALANCED:-}
|
|
MODEL_DEEP: ${MODEL_DEEP:-}
|
|
# voyage | ollama | lexical. Unset means ingest stores chunks that are
|
|
# keyword-searchable only — see the ingest output.
|
|
EMBED_PROVIDER: ${EMBED_PROVIDER:-}
|
|
EMBED_MODEL: ${EMBED_MODEL:-}
|
|
EMBED_DIMENSIONS: ${EMBED_DIMENSIONS:-}
|
|
# Ollama runs on the HOST, so "localhost" here would be this container.
|
|
# host.docker.internal is what reaches the host from inside it.
|
|
EMBED_BASE_URL: ${EMBED_BASE_URL:-}
|
|
VOYAGE_API_KEY: ${VOYAGE_API_KEY:-}
|
|
# 0.0.0.0 so the published port actually reaches the process. The binary
|
|
# defaults to 127.0.0.1, which inside a container means "nobody".
|
|
HTTP_HOST: 0.0.0.0
|
|
HTTP_PORT: "8080"
|
|
HTTP_READ_TIMEOUT: ${HTTP_READ_TIMEOUT:-15s}
|
|
# Must exceed the deep tier's 2m agent deadline or config refuses to
|
|
# start — see .env.docker.example. The old 30s default could not boot.
|
|
HTTP_WRITE_TIMEOUT: ${HTTP_WRITE_TIMEOUT:-180s}
|
|
HTTP_IDLE_TIMEOUT: ${HTTP_IDLE_TIMEOUT:-60s}
|
|
HTTP_SHUTDOWN_TIMEOUT: ${HTTP_SHUTDOWN_TIMEOUT:-10s}
|
|
# Browser origins allowed to call this API. Empty means same-origin only,
|
|
# and the CORS middleware is then not installed at all. Cookies are the
|
|
# auth mechanism, so any origin listed here can hold a session.
|
|
#
|
|
# "*" is refused at startup: browsers reject Allow-Origin "*" together
|
|
# with credentials, so it would break authenticated calls rather than
|
|
# loosen anything.
|
|
HTTP_CORS_ORIGINS: ${HTTP_CORS_ORIGINS:-}
|
|
# lax | none | strict, or unset to let the server choose.
|
|
#
|
|
# "none" is required when the frontend is on a different registrable
|
|
# DOMAIN from the API — otherwise the browser withholds the cookie
|
|
# however correct the CORS headers are. A different ORIGIN on the same
|
|
# domain (platform.krowforce.com → mcp.krowforce.com) needs CORS but not
|
|
# this: a Lax cookie already travels between them, and "none" would give
|
|
# up the only CSRF protection this API has.
|
|
#
|
|
# Unset, the server derives it from HTTP_CORS_ORIGINS. The default below
|
|
# is deliberate: a compose deployment keeps Lax unless told otherwise.
|
|
HTTP_COOKIE_SAMESITE: ${HTTP_COOKIE_SAMESITE:-lax}
|
|
DATABASE_MAX_OPEN_CONNS: ${DATABASE_MAX_OPEN_CONNS:-25}
|
|
DATABASE_MIN_IDLE_CONNS: ${DATABASE_MIN_IDLE_CONNS:-2}
|
|
DATABASE_CONN_MAX_LIFETIME: ${DATABASE_CONN_MAX_LIFETIME:-30m}
|
|
DATABASE_CONNECT_TIMEOUT: ${DATABASE_CONNECT_TIMEOUT:-10s}
|
|
DATABASE_STATEMENT_TIMEOUT: ${DATABASE_STATEMENT_TIMEOUT:-10s}
|
|
ports:
|
|
# Bound to loopback by default. A reverse proxy in front of this is what
|
|
# terminates TLS; publishing 0.0.0.0:8080 would put a plain-HTTP service
|
|
# carrying session cookies straight onto the network.
|
|
- "${API_BIND:-127.0.0.1}:${API_PORT:-8080}:8080"
|
|
restart: unless-stopped
|
|
stop_grace_period: 30s
|
|
read_only: true
|
|
security_opt:
|
|
- no-new-privileges:true
|
|
cap_drop:
|
|
- ALL
|
|
tmpfs:
|
|
- /tmp:size=32m
|
|
deploy:
|
|
resources:
|
|
limits:
|
|
cpus: "${API_CPU_LIMIT:-2}"
|
|
memory: ${API_MEMORY_LIMIT:-512M}
|
|
reservations:
|
|
memory: 128M
|
|
logging: *logging
|
|
networks: [krow]
|
|
|
|
networks:
|
|
krow:
|
|
driver: bridge
|