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.
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.
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.
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.
Any OpenAI-compatible endpoint: Ollama or vLLM on your own GPU, or Anthropic, OpenAI, xAI. Swapping it changes one environment variable.
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.
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.
Trimmed but real: the shapes below are what the deployment actually uses.
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: {} }
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.
# 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.
Same tools, same gates, same audit trail. The key lives in the bridge container only, and is never placed in a prompt.
# 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}
The site you are reading is static files in an nginx container. That is a deliberate choice, not a lack of ambition.
docker pull nginx:alpine — nothing else[stored], not credentials; values are injected into the tool only# 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