Deployment

Five containers, one host, any model

Nothing here is installed on a desktop. The ERP, the RMM, the AI bridge, the execution plane and this website are containers with declared ports and volumes — so the whole system fits on one machine, survives a rebuild, and can run with the model hosted or entirely local.

control plane

ERP / helpdesk

Odoo container + Postgres. Owns tickets, KB articles, clients and the per-company notebook. The AI calls it over HTTP with a shared secret, and every read/write executes as the logged-in user.

execution

RMM

Django + Postgres + Redis + NATS + mesh. Owns devices, agents, procedures and the audit trail of every command. One agent per machine is the only way in.

reasoning

AI bridge

Node service that runs the model, exposes the ERP and RMM as tools, enforces capability gates, redacts secrets, and streams the conversation to the UI.

model

Provider

Any OpenAI-compatible endpoint: Ollama or vLLM on your own GPU, or Anthropic, OpenAI, xAI. Swapping it changes one environment variable.

presentation

This website

Static HTML/CSS/JS in an nginx:alpine container behind the same reverse proxy. No CMS, no PHP, no database — nothing to keep patched and nothing to exploit.

edge

Reverse proxy

One nginx container terminates TLS for every vhost, issues and renews Let's Encrypt certificates by webroot, and is the only thing publishing ports 80/443.

compose

The stack, in one file

Trimmed but real: the shapes below are what the deployment actually uses.

docker-compose.yml
name: bluehelp

services:
  proxy:                        # the only container with published ports
    image: nginx:alpine
    ports: ["80:80", "443:443"]
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./letsencrypt:/etc/letsencrypt
      - ./acme-challenge:/var/www/challenge
    depends_on: [site, erp, rmm]

  site:                         # this website — static, no runtime
    image: nginx:alpine
    volumes: [ ./site:/usr/share/nginx/html:ro ]
    read_only: true
    security_opt: ["no-new-privileges:true"]

  erp:                          # tickets, KB, clients
    image: odoo:19
    environment:
      HOST: db
      USER: odoo
      PASSWORD_FILE: /run/secrets/erp_db_password
    volumes:
      - ./addons:/mnt/extra-addons:ro
      - erp-data:/var/lib/odoo
    depends_on: [db]

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: odoo
      POSTGRES_PASSWORD_FILE: /run/secrets/erp_db_password
    volumes: [ db-data:/var/lib/postgresql/data ]   # never published

  rmm:                         # devices + agents + audit
    image: rmm-backend:local
    environment:
      POSTGRES_HOST: rmm-db
      REDIS_HOST: redis
      NATS_HOST: nats
    depends_on: [rmm-db, redis, nats]

  bridge:                      # the AI itself
    image: rmm-bridge:local
    environment:
      AI_PROVIDER: ${AI_PROVIDER:-ollama}
      AI_BASE_URL: ${AI_BASE_URL:-http://ollama:11434/v1}
      AI_MODEL:    ${AI_MODEL:-qwen2.5-coder:32b}
      AI_API_KEY:  ${AI_API_KEY:-}
      ERP_URL:     http://erp:8069
      ERP_SECRET_FILE: /run/secrets/erp_ai_secret
      RMM_URL:     http://rmm:8080
      CAPS_MODE:   enforce
    volumes: [ ./sessions:/opt/bridge/sessions ]
    depends_on: [erp, rmm]

  ollama:                      # optional: local model, no data leaves
    image: ollama/ollama
    volumes: [ ollama:/root/.ollama ]
    profiles: ["local-ai"]
    # deploy: { resources: { reservations: { devices: [{ capabilities: [gpu] }] } } }

secrets:
  erp_db_password: { file: ./secrets/erp_db_password }
  erp_ai_secret:   { file: ./secrets/erp_ai_secret }

volumes: { erp-data: {}, db-data: {}, ollama: {} }
Local AI

On your own GPU

For a demonstration on a laptop or a workstation, nothing has to leave the machine. Pull a model, point the bridge at it, and the whole helpdesk loop runs offline.

local, offline
# 1. bring up everything, including a local model
docker compose --profile local-ai up -d
docker compose exec ollama ollama pull qwen2.5-coder:32b

# 2. tell the bridge to use it
AI_PROVIDER=ollama
AI_BASE_URL=http://ollama:11434/v1
AI_MODEL=qwen2.5-coder:32b

# 3. demo ticket, end to end, no internet
docker compose exec bridge node scripts/demo-ticket.mjs

A 32B model is enough for triage, diff review and tool selection. Small models are cheap and honest: they escalate more, which is the correct failure mode.

Hosted AI

Or an API

Same tools, same gates, same audit trail. The key lives in the bridge container only, and is never placed in a prompt.

hosted providers
# Anthropic
AI_PROVIDER=anthropic
AI_MODEL=claude-sonnet-5
AI_API_KEY=${ANTHROPIC_API_KEY}

# OpenAI
AI_PROVIDER=openai
AI_MODEL=gpt-5.1
AI_API_KEY=${OPENAI_API_KEY}

# xAI
AI_PROVIDER=xai
AI_BASE_URL=https://api.x.ai/v1
AI_MODEL=grok-5
AI_API_KEY=${XAI_API_KEY}
Mixed routing is normal. Triage on a small local model, code review on a frontier model, embeddings local. Each worker in a team has its own provider and model, with a fallback.
Security posture

Why there is no CMS

The site you are reading is static files in an nginx container. That is a deliberate choice, not a lack of ambition.

Nothing to exploit

  • No PHP, no plugin surface, no admin login on this host
  • Read-only root filesystem, no writable content directory
  • Content is built from files, so a compromise has no write path
  • Patching is docker pull nginx:alpine — nothing else

Secrets never in the wrong layer

  • Database passwords are files mounted into one container
  • The AI receives [stored], not credentials; values are injected into the tool only
  • A tool call carrying a registered secret is refused, not redacted after the fact
  • Everything that changes a machine is audited with who asked and what ran

Bring it up in ten minutes

terminal
# clone, configure, start
git clone https://example.invalid/bluehelp-demo.git && cd bluehelp-demo
cp .env.example .env && $EDITOR .env          # pick a provider
mkdir -p secrets && printf %s "$(openssl rand -hex 24)" > secrets/erp_ai_secret

docker compose up -d
docker compose exec erp odoo -d helpdesk -i helpdesk_ai --stop-after-init
docker compose exec bridge node scripts/seed-demo.mjs   # fictional client + devices

# open it
open https://helpdesk.localhost              # ERP
open https://helpdesk.localhost/ai-decision  # decision chat
Run the demo with fictional data. The seed script creates a make-believe client, two devices and an agent registry. Pointing this at real machines is a separate decision with its own approvals — which is exactly what the automation-group gate is for.
See the runs → The ten steps →