D1: deploy.sh CI gate — read-only SHA via git ls-remote, Gitea commit-status
poll, portable mkdir deploy lock (macOS, no flock), TOCTOU guard, token
passed via curl --config - (not argv), graceful misconfig tolerance.
D2: version-tagged images — OIKOS_VERSION=v$VERSION, keep-last-3 prune derived
from 'docker compose config --images'; VERSION read after pull.
D3: per-IP rate limiting — new internal/httpapi/ratelimit.go (x/time/rate),
rightmost-XFF, /healthz exempt, ctx-driven sweep; disabled by default.
D4: mem_limit/cpus on all 10 compose services.
D5: staleness-aware health probes — new internal/health package wired into
scheduler (:8093) and notifier (:8094); nomos already had :8092.
Two /review passes hardened the deploy lock, TOCTOU guard, token hygiene,
and XFF handling.
301 lines
11 KiB
YAML
301 lines
11 KiB
YAML
# Docker Compose for Oikos development
|
|
# Usage: docker compose up -d postgres (just the DB)
|
|
# make dev (full dev stack)
|
|
#
|
|
# The SPA isn't embedded in the oikos binary (see
|
|
# plans/2026-07-12-wails-desktop-app.md 0.1/0.6) but it IS part of this
|
|
# stack as its own `web` service (compose/web/Dockerfile), so it deploys
|
|
# through the same push-to-main pipeline as everything else. `npm run dev`
|
|
# in web/ is still the fast local-iteration path.
|
|
|
|
services:
|
|
postgres:
|
|
image: timescale/timescaledb:2.17.2-pg16
|
|
restart: unless-stopped
|
|
environment:
|
|
POSTGRES_DB: oikos
|
|
POSTGRES_USER: oikos
|
|
POSTGRES_PASSWORD: ${OIKOS_DB_PASSWORD:-oikos_dev}
|
|
ports:
|
|
- "5432:5432"
|
|
volumes:
|
|
- pg-data:/var/lib/postgresql/data
|
|
mem_limit: 1g
|
|
cpus: 2.0
|
|
healthcheck:
|
|
test: ["CMD", "pg_isready", "-U", "oikos"]
|
|
interval: 5s
|
|
timeout: 5s
|
|
retries: 5
|
|
|
|
# One-shot: run migrations then exit
|
|
migrate:
|
|
image: oikos-migrate:${OIKOS_VERSION:-latest}
|
|
build:
|
|
context: .
|
|
dockerfile: compose/oikos/Dockerfile
|
|
depends_on:
|
|
postgres:
|
|
condition: service_healthy
|
|
environment:
|
|
OIKOS_DATABASE_URL: postgres://oikos:${OIKOS_DB_PASSWORD:-oikos_dev}@postgres:5432/oikos?sslmode=disable
|
|
command: ["migrate"]
|
|
restart: "no"
|
|
mem_limit: 512m
|
|
cpus: 1.0
|
|
|
|
# One-shot: ingest seeds then exit
|
|
seed:
|
|
image: oikos-seed:${OIKOS_VERSION:-latest}
|
|
build:
|
|
context: .
|
|
dockerfile: compose/oikos/Dockerfile
|
|
depends_on:
|
|
migrate:
|
|
condition: service_completed_successfully
|
|
environment:
|
|
OIKOS_DATABASE_URL: postgres://oikos:${OIKOS_DB_PASSWORD:-oikos_dev}@postgres:5432/oikos?sslmode=disable
|
|
OIKOS_SEEDS_DIR: /seeds
|
|
command: ["seed"]
|
|
restart: "no"
|
|
mem_limit: 512m
|
|
cpus: 1.0
|
|
|
|
# API server (Phase 2)
|
|
api:
|
|
image: oikos-api:${OIKOS_VERSION:-latest}
|
|
build:
|
|
context: .
|
|
dockerfile: compose/oikos/Dockerfile
|
|
restart: unless-stopped
|
|
profiles: ["dev", "full"]
|
|
depends_on:
|
|
seed:
|
|
condition: service_completed_successfully
|
|
environment:
|
|
OIKOS_DATABASE_URL: postgres://oikos:${OIKOS_DB_PASSWORD:-oikos_dev}@postgres:5432/oikos?sslmode=disable
|
|
OIKOS_API_LISTEN: ":8090"
|
|
OIKOS_ENV: dev
|
|
OIKOS_DEBUG: "true"
|
|
# No dev-open auth bypass (plans/2026-07-12-wails-desktop-app.md 0.4) —
|
|
# every request needs this token. nomos uses the same value to call
|
|
# back into api's /mcp and /api/v1/approvals/*/decision.
|
|
OIKOS_MCP_BEARER_TOKEN: ${OIKOS_MCP_BEARER_TOKEN:-dev-token}
|
|
OIKOS_OIDC_ISSUER: ${OIKOS_OIDC_ISSUER:-https://auth.hubris.network/application/o/oikos/}
|
|
OIKOS_OIDC_CLIENT_ID: ${OIKOS_OIDC_CLIENT_ID:-otkHBSueHJsYtOHstL6rn5izeGgyOsavp1qA1hod}
|
|
OIKOS_NOMOS_AGENT_SLUG: ${OIKOS_NOMOS_AGENT_SLUG:-agent:nomos}
|
|
NOMOS_PROXY_URL: http://nomos:8092
|
|
# Rate limiting (plan D3). Default off; set OIKOS_API_RATE_LIMIT to a
|
|
# requests/sec value to throttle runaway agent loops per source IP.
|
|
OIKOS_API_RATE_LIMIT: ${OIKOS_API_RATE_LIMIT:-}
|
|
OIKOS_API_RATE_BURST: ${OIKOS_API_RATE_BURST:-}
|
|
# Infisical secret store (Phase 5)
|
|
OIKOS_INFISICAL_SITE_URL: ${OIKOS_INFISICAL_SITE_URL:-}
|
|
OIKOS_INFISICAL_CLIENT_ID: ${OIKOS_INFISICAL_CLIENT_ID:-}
|
|
OIKOS_INFISICAL_CLIENT_SECRET: ${OIKOS_INFISICAL_CLIENT_SECRET:-}
|
|
OIKOS_INFISICAL_PROJECT_ID: ${OIKOS_INFISICAL_PROJECT_ID:-}
|
|
OIKOS_INFISICAL_ENV: ${OIKOS_INFISICAL_ENV:-dev}
|
|
volumes:
|
|
- ${OIKOS_SSH_KEY_PATH:-~/.ssh/id_ed25519}:/etc/oikos/ssh_key:ro
|
|
ports:
|
|
- "8090:8090"
|
|
command: ["api"]
|
|
stop_signal: SIGTERM
|
|
stop_grace_period: 30s
|
|
mem_limit: 512m
|
|
cpus: 1.0
|
|
# Exists so nomos can wait for the API to actually answer rather than just
|
|
# for its container to exist — see nomos's depends_on below. wget is
|
|
# BusyBox's, already in the alpine runtime image, so this adds no
|
|
# dependency. /healthz pings the DB, so "healthy" means genuinely ready.
|
|
healthcheck:
|
|
test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:8090/healthz"]
|
|
interval: 5s
|
|
timeout: 3s
|
|
retries: 10
|
|
# Migrations and seed run before this container, but the first bind can
|
|
# still take a moment; failures inside the start period don't count.
|
|
start_period: 10s
|
|
|
|
# Scheduler (Phase 3) — observe loop
|
|
scheduler:
|
|
image: oikos-scheduler:${OIKOS_VERSION:-latest}
|
|
build:
|
|
context: .
|
|
dockerfile: compose/oikos/Dockerfile
|
|
restart: unless-stopped
|
|
profiles: ["dev", "full"]
|
|
depends_on:
|
|
seed:
|
|
condition: service_completed_successfully
|
|
environment:
|
|
OIKOS_DATABASE_URL: postgres://oikos:${OIKOS_DB_PASSWORD:-oikos_dev}@postgres:5432/oikos?sslmode=disable
|
|
OIKOS_DEBUG: "true"
|
|
OIKOS_SCHEDULER_INTERVAL: "30s"
|
|
OIKOS_SSH_KEY_PATH: /etc/oikos/ssh_key
|
|
OIKOS_SSH_USER: root
|
|
# Liveness probe (plan D5): exposes a staleness-aware /healthz inside
|
|
# the container; the scheduler bumps it each check pass.
|
|
OIKOS_HEALTH_LISTEN: ":8093"
|
|
volumes:
|
|
- ${OIKOS_SSH_KEY_PATH:-~/.ssh/id_ed25519}:/etc/oikos/ssh_key:ro
|
|
cap_add:
|
|
- NET_RAW
|
|
command: ["scheduler"]
|
|
stop_signal: SIGTERM
|
|
stop_grace_period: 30s
|
|
mem_limit: 256m
|
|
cpus: 1.0
|
|
healthcheck:
|
|
test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:8093/healthz"]
|
|
interval: 30s
|
|
timeout: 5s
|
|
retries: 3
|
|
start_period: 90s
|
|
|
|
# Notifier (Phase 3) — Matrix alerts
|
|
notifier:
|
|
image: oikos-notifier:${OIKOS_VERSION:-latest}
|
|
build:
|
|
context: .
|
|
dockerfile: compose/oikos/Dockerfile
|
|
restart: unless-stopped
|
|
profiles: ["dev", "full"]
|
|
depends_on:
|
|
seed:
|
|
condition: service_completed_successfully
|
|
environment:
|
|
OIKOS_DATABASE_URL: postgres://oikos:${OIKOS_DB_PASSWORD:-oikos_dev}@postgres:5432/oikos?sslmode=disable
|
|
OIKOS_DEBUG: "true"
|
|
OIKOS_APPROVAL_HMAC_SECRET: ${OIKOS_APPROVAL_HMAC_SECRET:-dev-secret}
|
|
OIKOS_MATRIX_HOMESERVER: ${OIKOS_MATRIX_HOMESERVER:-https://matrix.hubris.network}
|
|
OIKOS_MATRIX_USER: ${OIKOS_MATRIX_USER:-@hermes:hubris.network}
|
|
OIKOS_MATRIX_TOKEN: ${OIKOS_MATRIX_TOKEN}
|
|
OIKOS_MATRIX_ROOM: ${OIKOS_MATRIX_ROOM:-!alerts:hubris.network}
|
|
# Liveness probe (plan D5): bumps each approval/reaction tick.
|
|
OIKOS_HEALTH_LISTEN: ":8094"
|
|
OIKOS_INFISICAL_SITE_URL: ${OIKOS_INFISICAL_SITE_URL:-}
|
|
OIKOS_INFISICAL_CLIENT_ID: ${OIKOS_INFISICAL_CLIENT_ID:-}
|
|
OIKOS_INFISICAL_CLIENT_SECRET: ${OIKOS_INFISICAL_CLIENT_SECRET:-}
|
|
OIKOS_INFISICAL_PROJECT_ID: ${OIKOS_INFISICAL_PROJECT_ID:-}
|
|
OIKOS_INFISICAL_ENV: ${OIKOS_INFISICAL_ENV:-dev}
|
|
command: ["notifier"]
|
|
stop_signal: SIGTERM
|
|
stop_grace_period: 30s
|
|
mem_limit: 128m
|
|
cpus: 0.5
|
|
healthcheck:
|
|
test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:8094/healthz"]
|
|
interval: 30s
|
|
timeout: 5s
|
|
retries: 3
|
|
start_period: 120s
|
|
|
|
# Nomos agent gateway (Phase 4) — mesh-published :8092
|
|
nomos:
|
|
image: oikos-nomos:${OIKOS_VERSION:-latest}
|
|
build:
|
|
context: .
|
|
dockerfile: compose/nomos/Dockerfile
|
|
restart: unless-stopped
|
|
profiles: ["full"]
|
|
depends_on:
|
|
api:
|
|
# service_started only waits for the container to exist, so nomos came
|
|
# up while the API was still binding :8090, failed its MCP initialize,
|
|
# exited 1, and crash-looped for ~25s on every single deploy. It always
|
|
# recovered, which is exactly why it went unnoticed. service_healthy
|
|
# waits for the API to actually answer.
|
|
condition: service_healthy
|
|
environment:
|
|
NOMOS_MCP_URL: http://api:8090/mcp
|
|
NOMOS_AGENT_SLUG: agent:nomos
|
|
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
|
|
NOMOS_MODEL: ${NOMOS_MODEL:-deepseek/deepseek-v4-pro}
|
|
DATABASE_URL: postgres://oikos:${OIKOS_DB_PASSWORD:-oikos_dev}@postgres:5432/oikos?sslmode=disable
|
|
# Must match api's OIKOS_MCP_BEARER_TOKEN above — api's combinedAuth
|
|
# rejects every request without it now (no dev-open bypass).
|
|
OIKOS_MCP_BEARER_TOKEN: ${OIKOS_MCP_BEARER_TOKEN:-dev-token}
|
|
# Infisical secret store (Phase 5) — nomos resolves mcp_bearer-token
|
|
# and openrouter_api-key from here, overriding the env values above.
|
|
OIKOS_INFISICAL_SITE_URL: ${OIKOS_INFISICAL_SITE_URL:-}
|
|
OIKOS_INFISICAL_CLIENT_ID: ${OIKOS_INFISICAL_CLIENT_ID:-}
|
|
OIKOS_INFISICAL_CLIENT_SECRET: ${OIKOS_INFISICAL_CLIENT_SECRET:-}
|
|
OIKOS_INFISICAL_PROJECT_ID: ${OIKOS_INFISICAL_PROJECT_ID:-}
|
|
OIKOS_INFISICAL_ENV: ${OIKOS_INFISICAL_ENV:-dev}
|
|
ports:
|
|
- "8092:8092"
|
|
stop_signal: SIGTERM
|
|
stop_grace_period: 10s
|
|
mem_limit: 512m
|
|
cpus: 1.0
|
|
healthcheck:
|
|
test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:8092/healthz"]
|
|
interval: 30s
|
|
timeout: 5s
|
|
retries: 3
|
|
start_period: 15s
|
|
|
|
# Control-room SPA — static build served behind Caddy. The outer
|
|
# production Caddy (caddy-conf repo, LXC 121) splits /api/*, /mcp,
|
|
# /agent/* off to api:8090 and sends everything else here; this
|
|
# container only serves static files with SPA-fallback routing.
|
|
web:
|
|
image: oikos-web:${OIKOS_VERSION:-latest}
|
|
build:
|
|
context: .
|
|
dockerfile: compose/web/Dockerfile
|
|
restart: unless-stopped
|
|
profiles: ["dev", "full"]
|
|
ports:
|
|
- "8091:80"
|
|
stop_signal: SIGTERM
|
|
mem_limit: 64m
|
|
cpus: 0.25
|
|
|
|
# Redis (required by Infisical — Phase 5)
|
|
redis:
|
|
image: redis:7-alpine
|
|
restart: unless-stopped
|
|
profiles: ["infisical", "full"]
|
|
volumes:
|
|
- redis-data:/data
|
|
mem_limit: 128m
|
|
cpus: 0.5
|
|
healthcheck:
|
|
test: ["CMD", "redis-cli", "ping"]
|
|
interval: 5s
|
|
timeout: 3s
|
|
retries: 5
|
|
|
|
# Infisical self-hosted (Phase 5 secrets management)
|
|
infisical:
|
|
image: infisical/infisical:latest
|
|
restart: unless-stopped
|
|
profiles: ["infisical", "full"]
|
|
depends_on:
|
|
postgres:
|
|
condition: service_healthy
|
|
redis:
|
|
condition: service_healthy
|
|
environment:
|
|
ENCRYPTION_KEY: ${INFISICAL_ENCRYPTION_KEY:-dev-encryption-key-change-me}
|
|
JWT_SIGNUP_SECRET: ${INFISICAL_JWT_SIGNUP_SECRET:-dev-jwt-change-me}
|
|
JWT_REFRESH_SECRET: ${INFISICAL_JWT_REFRESH_SECRET:-dev-refresh-change-me}
|
|
JWT_AUTH_SECRET: ${INFISICAL_JWT_AUTH_SECRET:-dev-auth-change-me}
|
|
JWT_SERVICE_SECRET: ${INFISICAL_JWT_SERVICE_SECRET:-dev-service-change-me}
|
|
JWT_MFA_SECRET: ${INFISICAL_JWT_MFA_SECRET:-dev-mfa-change-me}
|
|
JWT_PROVIDER_SECRET: ${INFISICAL_JWT_PROVIDER_SECRET:-dev-provider-change-me}
|
|
SITE_URL: ${INFISICAL_SITE_URL:-http://localhost:8080}
|
|
SMTP_HOST: ""
|
|
DB_CONNECTION_URI: postgres://oikos:${OIKOS_DB_PASSWORD:-oikos_dev}@postgres:5432/infisical?sslmode=disable
|
|
REDIS_URL: redis://redis:6379
|
|
ports:
|
|
- "8080:8080"
|
|
mem_limit: 512m
|
|
cpus: 1.0
|
|
|
|
volumes:
|
|
pg-data:
|
|
redis-data:
|