# ============================================================================ # 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} # The agent run routes are not registered without this, so a deployment # without it answers 404 on /agents/{id}/runs and reports three fewer # endpoints on /version. Empty by default: absent is a working API # without Owliver, which is a legitimate way to run this. # 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:-} 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