# ============================================================================ # Krow API — Docker environment # # cd infrastructure && cp .env.docker.example .env # # Compose reads `.env` from the directory the compose file lives in, so this # copy belongs in infrastructure/, NOT the repository root .env used by # `make run`. Both are gitignored. # # Every value here is a placeholder. No real password or host belongs in a # file that is committed. # ============================================================================ # ── Application ───────────────────────────────────────────────────────────── APP_ENV=production # development | staging | production LOG_LEVEL=info # debug | info | warn | error VERSION=1.0.0 # build arg only IMAGE=doormile/krowbackend:latest # what `docker compose build` tags and `up` runs # ── Where the API is published ────────────────────────────────────────────── # Loopback by default: put a reverse proxy in front to terminate TLS. The API # speaks plain HTTP and its session cookie is Secure outside development, so a # browser will not send that cookie back over a plain connection anyway. API_BIND=127.0.0.1 API_PORT=8080 # Browser origins allowed to call this API, comma-separated. Exact strings: # scheme included, no trailing slash. Cookies are the auth mechanism, so # anything listed here can hold a live session. # # "*" is REJECTED at startup — browsers refuse Allow-Origin: "*" together with # credentials, so it would break every authenticated call rather than loosen # anything. HTTP_CORS_ORIGINS=https://platform.krowforce.com,https://mcp.krowforce.com # SameSite on the session cookie: lax | none | strict. # # "lax" is right while the API is on *.krowforce.com — the two origins above # are then cross-ORIGIN (CORS applies) but same-SITE (the cookie is still # sent). Move the API to any other registrable domain and this must become # "none", or login will succeed and every following request will arrive # anonymous. HTTP_COOKIE_SAMESITE=lax # Networks whose X-Forwarded-For header may be believed. Comma-separated CIDR # blocks or bare addresses. # # THIS DEPLOYMENT NEEDS IT SET, AND THE VALUE IS NOT KNOWABLE FROM THIS REPO. # # The API binds loopback and something else terminates TLS in front of it, so # Go sees the proxy's address on every request. Three limits are keyed by that # address — failed logins (20 per 15 minutes), OAuth client registration (10 # per hour) and OAuth authorization before sign-in (20 per hour) — and while it # is unset, all three are ONE budget for the whole deployment. The observable # symptoms are users rate-limiting each other: a connector that refuses to # register because somebody else already did, and sign-in refused platform-wide # after twenty bad passwords anywhere. # # Set it to the address or network the ingress reaches the API from. Find it # rather than guess it — on the API host, with the stack running: # # docker inspect -f '{{range .NetworkSettings.Networks}}{{.Gateway}}{{end}}' krow-api # # That gateway is the address a proxy on the host arrives as. If the proxy runs # in a container on a shared Docker network, use that network's subnet instead: # # docker network inspect -f '{{range .IPAM.Config}}{{.Subnet}}{{end}}' # # Confirm before committing to it: with LOG_LEVEL=debug, one request's logged # address should be the real client's, not the proxy's. # # NEVER 0.0.0.0/0. That trusts every caller's own header — not a weaker limit # but no limit at all, since anyone could then mint a budget per request. # # Left unset here deliberately. An operator supplying a wrong value gets the # shared bucket back; an operator supplying 0.0.0.0/0 gets no protection at all, # so this file ships no value rather than a plausible-looking one to copy. HTTP_TRUSTED_PROXIES= # ── Database ──────────────────────────────────────────────────────────────── # Point at a managed PostgreSQL. With docker-compose.local-db.yml layered on # top, set DATABASE_HOST=postgres instead. DATABASE_HOST=your-database-host.example.com DATABASE_PORT=5432 DATABASE_NAME=krow DATABASE_USER=krow DATABASE_PASSWORD=change-me DATABASE_SCHEMA=public # require encrypts but does not verify the server; verify-full also checks the # certificate against a CA and is what you want against a managed database. # # `disable` is REJECTED at startup when APP_ENV=production. That is deliberate. DATABASE_SSLMODE=require # ── Migration URL ─────────────────────────────────────────────────────────── # golang-migrate takes a single URL, and Compose cannot assemble or encode one. # # ⚠️ PERCENT-ENCODE THE PASSWORD. A password containing @ : / ? # or % will # otherwise be parsed as part of the host or the path, and the failure looks # like a wrong host rather than a wrong password: # # @ → %40 : → %3A / → %2F ? → %3F # → %23 % → %25 # # python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=""))' 'your-password' # # search_path must match DATABASE_SCHEMA above. DATABASE_URL=postgres://krow:change-me@your-database-host.example.com:5432/krow?sslmode=require&search_path=public # ── Pool and timeouts ─────────────────────────────────────────────────────── DATABASE_MAX_OPEN_CONNS=25 DATABASE_MIN_IDLE_CONNS=2 DATABASE_CONN_MAX_LIFETIME=30m DATABASE_CONNECT_TIMEOUT=10s DATABASE_STATEMENT_TIMEOUT=10s # ── Model gateway ─────────────────────────────────────────────────────────── # THIS BLOCK WAS MISSING and a deployment copying this file could not start: # APP_ENV=production with no MODEL_API_KEY is refused, because the alternative # is an API that accepts agent runs and fails every one of them at the gateway. # # One wire protocol: the openai chat-completions shape. Groq, Gemini, # OpenRouter, Together, vLLM and a local Ollama all serve it, so switching # vendors is a base URL and a model id, not a code change. # # Empty MODEL_PROVIDER means openai — the only implementation. Empty # MODEL_BASE_URL means Groq. MODEL_PROVIDER=openai MODEL_BASE_URL=https://api.groq.com/openai/v1 # REQUIRED in production. There is no ANTHROPIC_API_KEY fallback: that variable # is now REFUSED at startup if it is set while this one is empty, because # silently authenticating to Groq with a key named for a vendor this service # cannot call is a lie the next operator has to unpick. Rename it here, and # replace the value — an Anthropic key boots fine and then fails every run with # 401, which the startup check cannot catch and only the model call can. MODEL_API_KEY= # Model ids must be ones MODEL_BASE_URL actually serves. A leftover claude-* # id is refused at startup by name and tier: nothing configured serves one, so # every run on that tier would 400 at the gateway. MODEL_FAST=openai/gpt-oss-20b MODEL_BALANCED=openai/gpt-oss-120b MODEL_DEEP=openai/gpt-oss-120b # Safe to turn on with the gpt-oss ids above: both accept reasoning_effort at # low, medium and high, which is exactly the scale the gateway maps its three # efforts onto. VERIFIED against the live Groq API, not assumed. # # It is NOT portable. qwen/qwen3.6-27b on the same account rejects all three # ("must be one of `none` or `default`") and fails the whole request rather than # ignoring the key, so switching model id and leaving this on breaks every run. # Re-check it whenever MODEL_* changes. MODEL_REASONING_EFFORT= # ── HTTP timeouts ─────────────────────────────────────────────────────────── HTTP_READ_TIMEOUT=15s # 180s, not 30s. internal/config REFUSES TO START when this is below the deep # tier's 2m agent deadline: the server would abort the response mid-run and the # caller would see 502 from the proxy in front, a gateway error for something no # gateway did. 30s shipped here for a long time and was the cause of exactly # that incident. Anything at or under 2m0s is a container that will not boot. HTTP_WRITE_TIMEOUT=180s HTTP_IDLE_TIMEOUT=60s HTTP_SHUTDOWN_TIMEOUT=10s # ── Container resources ───────────────────────────────────────────────────── API_CPU_LIMIT=2 API_MEMORY_LIMIT=512M # ── Local database only (docker-compose.local-db.yml) ─────────────────────── POSTGRES_BIND=127.0.0.1 POSTGRES_PORT=5432 POSTGRES_MEMORY_LIMIT=1G