# ─── NovelWorld microservice stack ─────────────────────────────────────────── # Service topology: # nginx (80) → gateway (8080) → [novel-service, agent-service, # narrative-service, user-service] # The default stack uses PostgreSQL 18; the Redis projection uses the explicit redis profile. x-logging: &bounded-logs driver: local networks: novel-net: driver: bridge volumes: postgres_data: redis_data: embedding_model_cache: services: # ─── Infrastructure ──────────────────────────────────────────────────────── postgres: image: ${POSTGRES_IMAGE:-pgvector/pgvector:pg18@sha256:2ba9ca5f2e7daa0f0e7723cba1ee9167bab54efd3640516a44ac1a928dd67e7a} container_name: ${CONTAINER_PREFIX:-novel}-postgres restart: unless-stopped logging: *bounded-logs environment: POSTGRES_USER: ${POSTGRES_USER:-novel} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} POSTGRES_DB: ${POSTGRES_DB:-novel_world} PGDATA: /var/lib/postgresql/data/pgdata volumes: - postgres_data:/var/lib/postgresql/data - ./infra/postgres/init.sql:/docker-entrypoint-initdb.d/01_init.sql:ro # ports: # - "5432:5432" # uncomment for local dev networks: - novel-net healthcheck: # pg_isready also succeeds against the temporary server used by the # official entrypoint while init scripts are still running. PID 1 becomes # postgres only after initialization completes. test: ["CMD-SHELL", "test \"$$(cat /proc/1/comm)\" = postgres && pg_isready -U ${POSTGRES_USER:-novel} -d ${POSTGRES_DB:-novel_world}"] interval: 10s timeout: 5s retries: 5 postgres-migrate: image: ${POSTGRES_IMAGE:-pgvector/pgvector:pg18@sha256:2ba9ca5f2e7daa0f0e7723cba1ee9167bab54efd3640516a44ac1a928dd67e7a} restart: "no" logging: *bounded-logs environment: PGHOST: postgres PGUSER: ${POSTGRES_USER:-novel} PGPASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} PGDATABASE: ${POSTGRES_DB:-novel_world} volumes: - ./infra/postgres/migrations:/migrations:ro depends_on: postgres: condition: service_healthy command: - /bin/sh - -ec - | for migration in /migrations/*.sql; do echo "Applying $$migration" psql -v ON_ERROR_STOP=1 -f "$$migration" done networks: - novel-net redis: profiles: ["redis"] image: ${REDIS_IMAGE:-redis:8.10.2-alpine@sha256:3811787313eba226a2ef38658c6ccb91cd5e110edc89c37767de373120a0e5a0} container_name: ${CONTAINER_PREFIX:-novel}-redis restart: unless-stopped logging: *bounded-logs environment: CACHE_MODE: ${CACHE_MODE:-postgres} REDIS_PASSWORD: ${REDIS_PASSWORD:-} entrypoint: ["/bin/sh", "-ec"] command: - | mode=$${CACHE_MODE:-} [ "$$mode" = redis ] \ || { echo 'Redis profile requires CACHE_MODE=redis' >&2; exit 64; } password=$${REDIS_PASSWORD:-} lower=$$(printf '%s' "$$password" | tr '[:upper:]' '[:lower:]') distinct=$$(printf '%s' "$$password" | fold -w1 | sort -u | wc -l | tr -d ' ') [ "$${#password}" -ge 16 ] \ && [ "$$distinct" -ge 8 ] \ && case "$$password" in *[!A-Za-z0-9._~-]*) false;; *) true;; esac \ && case "$$lower" in *placeholder*|*change_me*|your_redis_password_here|runtime-redis-only) false;; *) true;; esac \ || { echo 'REDIS_PASSWORD must be URL-safe, non-placeholder, at least 16 characters, and contain at least 8 distinct characters' >&2; exit 64; } exec docker-entrypoint.sh redis-server --requirepass "$$password" --maxmemory 256mb --maxmemory-policy allkeys-lru volumes: - redis_data:/data # ports: # - "6379:6379" # uncomment for local dev networks: - novel-net healthcheck: test: ["CMD", "redis-cli", "--no-auth-warning", "-a", "${REDIS_PASSWORD:-}", "ping"] interval: 10s timeout: 5s retries: 5 embedding: profiles: ["local-embedding"] image: ghcr.io/huggingface/text-embeddings-inference:cpu-1.9.4@sha256:2538ea1c9640d3763b15af668039d24172d063b42337b0c27796fc2be180c78d container_name: ${CONTAINER_PREFIX:-novel}-embedding restart: unless-stopped logging: *bounded-logs environment: RAYON_NUM_THREADS: "4" MKL_NUM_THREADS: "4" command: - --model-id - Qwen/Qwen3-Embedding-0.6B - --revision - 97b0c614be4d77ee51c0cef4e5f07c00f9eb65b3 - --served-model-name - Qwen/Qwen3-Embedding-0.6B - --dtype - float32 - --max-concurrent-requests - "4" - --max-batch-tokens - "4096" - --max-client-batch-size - "1" - --payload-limit - "8192" - --json-output volumes: - embedding_model_cache:/data networks: - novel-net read_only: true tmpfs: - /tmp:size=64m,mode=1777 cap_drop: - ALL security_opt: - no-new-privileges:true mem_limit: 12g cpus: 4.0 # ─── Services ────────────────────────────────────────────────────────────── user-service: image: ${USER_SERVICE_IMAGE:-novel-world-user-service:local} build: context: . dockerfile: infra/docker/Dockerfile.rust-service args: SERVICE: user-service container_name: ${CONTAINER_PREFIX:-novel}-user-service restart: unless-stopped logging: *bounded-logs environment: DATABASE_URL: postgres://${POSTGRES_USER:-novel}:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}@postgres:5432/${POSTGRES_DB:-novel_world} JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required} RUNTIME_CONFIG_KEY: ${RUNTIME_CONFIG_KEY:?RUNTIME_CONFIG_KEY is required} INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN:?INTERNAL_SERVICE_TOKEN is required} AGENT_SERVICE_URL: http://agent-service:8003 USER_SERVICE_URL: http://127.0.0.1:8001 LLM_DIAGNOSTIC_BUDGET_ID: ${LLM_DIAGNOSTIC_BUDGET_ID:-} LLM_DIAGNOSTIC_BUDGET_LIMITS: ${LLM_DIAGNOSTIC_BUDGET_LIMITS:-} LLM_DIAGNOSTIC_PROFILE: ${LLM_DIAGNOSTIC_PROFILE:-vision-journey-diagnostic-v1} LLM_API_URL: ${LLM_API_URL:-https://api.openai.com} LLM_API_KEY: ${LLM_API_KEY:-} LLM_MODEL: ${LLM_MODEL:-gpt-4o-mini} LLM_PRICING_USD_PER_MILLION: '${LLM_PRICING_USD_PER_MILLION:-{}}' LLM_PRICING_CNY_PER_MILLION: '${LLM_PRICING_CNY_PER_MILLION:-{}}' USD_CNY_RATE: ${USD_CNY_RATE:-} PROMETHEUS_URL: ${PROMETHEUS_URL:-http://prometheus:9090} AUTH_ACCESS_TOKEN_EXPIRY: ${AUTH_ACCESS_TOKEN_EXPIRY:-3600} AUTH_REFRESH_TOKEN_EXPIRY: ${AUTH_REFRESH_TOKEN_EXPIRY:-604800} PORT: 8001 RUST_LOG: ${RUST_LOG:-info} depends_on: postgres-migrate: condition: service_completed_successfully networks: - novel-net healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8001/ready"] interval: 15s timeout: 5s retries: 3 novel-service: image: ${NOVEL_SERVICE_IMAGE:-novel-world-novel-service:local} build: context: . dockerfile: infra/docker/Dockerfile.rust-service args: SERVICE: novel-service container_name: ${CONTAINER_PREFIX:-novel}-novel-service restart: unless-stopped logging: *bounded-logs environment: DATABASE_URL: postgres://${POSTGRES_USER:-novel}:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}@postgres:5432/${POSTGRES_DB:-novel_world} LLM_API_URL: ${LLM_API_URL:-https://api.openai.com} LLM_API_KEY: ${LLM_API_KEY:-} LLM_MODEL: ${LLM_MODEL:-gpt-4o-mini} LLM_CHARACTER_OUTPUT_MODE: ${LLM_CHARACTER_OUTPUT_MODE:-legacy-json} LAYA_API_URL: ${LAYA_API_URL:-} LAYA_API_KEY: ${LAYA_API_KEY:-} USER_SERVICE_URL: http://user-service:8001 LLM_DIAGNOSTIC_BUDGET_ID: ${LLM_DIAGNOSTIC_BUDGET_ID:-} LLM_DIAGNOSTIC_PROFILE: ${LLM_DIAGNOSTIC_PROFILE:-vision-journey-diagnostic-v1} INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN:?INTERNAL_SERVICE_TOKEN is required} AGENT_SERVICE_URL: http://agent-service:8003 IMAGE_GEN_API_URL: ${IMAGE_GEN_API_URL:-https://api.openai.com} IMAGE_GEN_API_KEY: ${IMAGE_GEN_API_KEY:-} S3_ENABLED: ${S3_ENABLED:-false} S3_ENDPOINT: ${S3_ENDPOINT:-} S3_BUCKET: ${S3_BUCKET:-novel-world-uploads} S3_REGION: ${S3_REGION:-us-east-1} S3_FORCE_PATH_STYLE: ${S3_FORCE_PATH_STYLE:-false} S3_ACCESS_KEY: ${S3_ACCESS_KEY:-} S3_SECRET_KEY: ${S3_SECRET_KEY:-} S3_SESSION_TOKEN: ${S3_SESSION_TOKEN:-} PORT: 8002 RUST_LOG: ${RUST_LOG:-info} depends_on: postgres-migrate: condition: service_completed_successfully networks: - novel-net healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8002/ready"] interval: 15s timeout: 5s retries: 3 agent-service: image: ${AGENT_SERVICE_IMAGE:-novel-world-agent-service:local} build: context: . dockerfile: infra/docker/Dockerfile.rust-service args: SERVICE: agent-service container_name: ${CONTAINER_PREFIX:-novel}-agent-service restart: unless-stopped logging: *bounded-logs environment: DATABASE_URL: postgres://${POSTGRES_USER:-novel}:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}@postgres:5432/${POSTGRES_DB:-novel_world} CACHE_MODE: ${CACHE_MODE:-postgres} REDIS_URL: ${REDIS_URL:-memory://} LLM_API_URL: ${LLM_API_URL:-https://api.openai.com} LLM_API_KEY: ${LLM_API_KEY:-} LLM_MODEL: ${LLM_MODEL:-gpt-4o} USER_SERVICE_URL: http://user-service:8001 LLM_DIAGNOSTIC_BUDGET_ID: ${LLM_DIAGNOSTIC_BUDGET_ID:-} LLM_DIAGNOSTIC_PROFILE: ${LLM_DIAGNOSTIC_PROFILE:-vision-journey-diagnostic-v1} INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN:?INTERNAL_SERVICE_TOKEN is required} EMBEDDING_PROVIDER: ${EMBEDDING_PROVIDER:-} EMBEDDING_API_URL: ${EMBEDDING_API_URL:-} EMBEDDING_API_KEY: ${EMBEDDING_API_KEY:-} EMBEDDING_MODEL: ${EMBEDDING_MODEL:-} PORT: 8003 NOVEL_SERVICE_URL: http://novel-service:8002 NARRATIVE_SERVICE_URL: http://narrative-service:8004 RUST_LOG: ${RUST_LOG:-info} depends_on: postgres-migrate: condition: service_completed_successfully novel-service: condition: service_healthy narrative-service: condition: service_healthy networks: - novel-net healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8003/ready"] interval: 15s timeout: 5s retries: 3 narrative-service: image: ${NARRATIVE_SERVICE_IMAGE:-novel-world-narrative-service:local} build: context: . dockerfile: infra/docker/Dockerfile.rust-service args: SERVICE: narrative-service container_name: ${CONTAINER_PREFIX:-novel}-narrative-service restart: unless-stopped logging: *bounded-logs environment: DATABASE_URL: postgres://${POSTGRES_USER:-novel}:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}@postgres:5432/${POSTGRES_DB:-novel_world} LLM_API_URL: ${LLM_API_URL:-https://api.openai.com} LLM_API_KEY: ${LLM_API_KEY:-} LLM_MODEL: ${LLM_MODEL:-gpt-4o} LAYA_API_URL: ${LAYA_API_URL:-} LAYA_API_KEY: ${LAYA_API_KEY:-} USER_SERVICE_URL: http://user-service:8001 LLM_DIAGNOSTIC_BUDGET_ID: ${LLM_DIAGNOSTIC_BUDGET_ID:-} LLM_DIAGNOSTIC_PROFILE: ${LLM_DIAGNOSTIC_PROFILE:-vision-journey-diagnostic-v1} INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN:?INTERNAL_SERVICE_TOKEN is required} PORT: 8004 NOVEL_SERVICE_URL: http://novel-service:8002 AGENT_SERVICE_URL: http://agent-service:8003 RUST_LOG: ${RUST_LOG:-info} depends_on: postgres-migrate: condition: service_completed_successfully novel-service: condition: service_healthy networks: - novel-net healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8004/ready"] interval: 15s timeout: 5s retries: 3 # ─── API Gateway ─────────────────────────────────────────────────────────── gateway: image: ${GATEWAY_IMAGE:-novel-world-gateway:local} build: context: . dockerfile: infra/docker/Dockerfile.rust-service args: SERVICE: gateway container_name: ${CONTAINER_PREFIX:-novel}-gateway restart: unless-stopped logging: *bounded-logs environment: USER_SERVICE_URL: http://user-service:8001 NOVEL_SERVICE_URL: http://novel-service:8002 AGENT_SERVICE_URL: http://agent-service:8003 NARRATIVE_SERVICE_URL: http://narrative-service:8004 JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required} INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN:?INTERNAL_SERVICE_TOKEN is required} PORT: 8080 RATE_LIMIT_RPS: ${RATE_LIMIT_RPS:-500} CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:5173,http://127.0.0.1:5173,http://localhost,http://127.0.0.1} RUST_LOG: ${RUST_LOG:-info} # ports: # - "8080:8080" # uncomment for local dev depends_on: user-service: condition: service_healthy novel-service: condition: service_healthy agent-service: condition: service_healthy narrative-service: condition: service_healthy networks: - novel-net healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/ready"] interval: 15s timeout: 5s retries: 3 # ─── Frontend ────────────────────────────────────────────────────────────── frontend: image: ${FRONTEND_IMAGE:-novel-world-frontend:local} build: context: ./frontend dockerfile: Dockerfile args: VITE_API_URL: ${VITE_API_URL:-/api} container_name: ${CONTAINER_PREFIX:-novel}-frontend restart: unless-stopped logging: *bounded-logs networks: - novel-net # ─── Nginx reverse proxy ─────────────────────────────────────────────────── nginx: image: ${NGINX_IMAGE:-nginx:alpine@sha256:060849a174666c53439dc7cecbf8f67e225c7cbfb1a44ca97915d9a172a81e6d} container_name: ${CONTAINER_PREFIX:-novel}-nginx restart: unless-stopped logging: *bounded-logs ports: - "${NGINX_HTTP_BIND:-127.0.0.1}:${NGINX_HTTP_PORT:-80}:80" volumes: - ./infra/nginx/nginx.conf:/etc/nginx/nginx.conf:ro depends_on: - gateway - frontend networks: - novel-net