A lightweight, high-performance reverse proxy for Odoo APIs.
3.1K
ODXProxy is a lightweight, high-performance reverse proxy for Odoo APIs, built in Rust and designed for enterprise environments. It provides API key authentication, rate limiting, request validation, and controlled access in front of Odoo—without modifying the Odoo application itself. Rust-based, minimal attack surface Static binary on distroless/static, non-root container: no libc or OpenSSL in the image Configuration-driven, predictable behavior Optimized for performance and low resource usage Ideal for teams that want a fast, secure, and auditable gateway for Odoo API traffic Visit ODXPROXY for trial and licensing
config.conf
# Address and Port for the proxy server to listen on
LISTEN_ADDR=0.0.0.0:6600
# The API key required from your api gateway
PROXY_API_KEY=YOUR_PROXY_KEY # Change this to your actual secret key
# Default log level
RUST_LOG=info,TheODXProxy=info # Info default, debug for our app
# Default request timeout to Odoo in seconds if x-request-timeout header is missing/invalid
DEFAULT_ODOO_TIMEOUT_SECS=15
LOG_PATH=/var/log/odxproxy
HOSTNAME=YOUR_ODX_HOSTNAME_FOR_HEARTBEAT
REDIS_URL=YOUR_REDIS_URL_IF_USING_HEARTBEAT
HEARTBEAT=false
LICENSE_KEY=/usr/share/odxproxy/config/license.json
docker-compose.yaml
services:
odxproxy:
image: terrakernel/odxproxy
ports:
- "6600:6600"
volumes:
- /host/config/file/path:/usr/share/odxproxy/config:ro
- /host/log/path:/var/log/odxproxy
restart: unless-stopped
for library integration you can visit github repos or join our discord channel
ODXProxy can also answer two questions an AI agent asks before it calls Odoo, by forwarding them to ODXBrain on your own network:
Your clients keep the one ODXProxy key they already use. ODXProxy holds a separate key for ODXBrain, and never forwards Odoo credentials there. Without ODXBrain, ODXProxy works exactly as before.
client --PROXY_API_KEY--> ODXProxy --ODXBRAIN_PROXY_KEY--> ODXBrain <--ODXBRAIN_DOCS_KEY-- odxdocs publish
agent --ODXDOCS_API_KEY--> odxdocs serve (the SOP text, fetched by id and version)
Three environment variables on the ODXProxy container. Leave ODXBRAIN_URL unset and the
brain routes answer 501.
| Variable | Meaning |
|---|---|
ODXBRAIN_URL | ODXBrain's address, e.g. http://odxbrain:8080. Unset means ODXBrain isn't installed |
ODXBRAIN_API_KEY | The one key ODXProxy sends to ODXBrain. Required unless the URL is loopback. It can't contain : |
ODXBRAIN_TIMEOUT_SECS | Default 5. A client's x-request-timeout header overrides it per call |
ODXProxy refuses to start if ODXBRAIN_URL is set but can't work: not http/https, no host, or
a non-loopback host with no key.
One name, two meanings. On ODXBrain, ODXBRAIN_API_KEY is the colon-separated list of keys it
accepts. On ODXProxy it's the single key it sends. Give ODXProxy and odxdocs a key each, and
list both on ODXBrain, as the compose file below does. Then either one can be rotated without
touching the other.
| Route | Forwards to | What it does |
|---|---|---|
POST /brain/v1/resolve | /v1/resolve | A question in, ranked Odoo model names out |
POST /brain/v1/sops/resolve | /v1/sops/resolve | A task in, ranked SOP ids and versions out |
GET /brain/v1/info | /info | The model, corpus and SOP generation ODXBrain is serving |
They take the same x-api-key as every other ODXProxy route. The body is ODXBrain's request plus
an id, which is required and echoed back. The answer is ODXBrain's response, unchanged, in
ODXProxy's usual JSON-RPC envelope. An odoo_instance block in the body is dropped, so an Odoo API
key never reaches ODXBrain.
curl -s http://127.0.0.1:6600/brain/v1/resolve \
-H "x-api-key: $PROXY_API_KEY" -H 'Content-Type: application/json' \
-d '{"id":"q-1","query":"which invoice is not paid yet","odoo_version":"18.0","edition":"community"}'
{"jsonrpc":"2.0","id":"q-1",
"result":{"candidates":[{"model":"account.move","score":0.513},
{"model":"account.payment","score":0.492},
{"model":"account.payment.term","score":0.462}],
"corpus_version":"2026.09.4","dim":1024,"model":"bge-m3","usage":{"prompt_tokens":10}}}
The ODXBrain page documents the request fields and the response.
There is no route to ODXBrain's /v1/sops/reload, so no client key can change what ODXBrain
serves; only odxdocs publish can. ODXProxy has no route to odxdocs either. Agents fetch the SOP
text from odxdocs serve directly, with their own key.
ODXBrain's own errors (a bad request, no pack loaded) come back with ODXBrain's HTTP status as the
code. Failures of the hop itself have their own codes:
| HTTP | code | Meaning |
|---|---|---|
| 501 | -32008 | ODXBRAIN_URL is unset on this proxy |
| 502 | -32009 | ODXBrain is unreachable, or didn't answer with its JSON |
| 504 | -32010 | The call took longer than the timeout |
| 502 | -32011 | ODXBrain refused ODXProxy's key: check ODXBRAIN_API_KEY on both containers |
GET /_/about reports "features":{"odxbrain":true} when ODXBrain is configured. On
/_/metrics, the gauge odxbrain_ready is 1 while ODXBrain's /ready answers. ODXProxy
checks it every 10 seconds. Brain calls are counted under api="brain".
All three on one private network. Clients reach ODXProxy on port 6600. ODXBrain's port is published on loopback only, for the odxdocs GUI. The files ODXBrain needs, the model and the corpus pack, are described on the ODXBrain page. The SOP folders are described on the odxdocs page.
odx/
├── compose.yaml
├── .env
├── odxproxy/ config.conf, license.json (readable by UID 65532)
├── models/ bge-m3-f16.gguf
├── pack/ the corpus pack
├── sop-files/ your SOPs, in git
├── sops/ snapshot.json, written by odxdocs
└── store/ every published SOP version, written by odxdocs
.env (chmod 600). Make each key with openssl rand -hex 32:
ODXBRAIN_PROXY_KEY=... # ODXProxy -> ODXBrain
ODXBRAIN_DOCS_KEY=... # odxdocs publish/gui -> ODXBrain
ODXDOCS_API_KEY=... # agents -> odxdocs serve
ODX_UID=1000 # `id -u` of the user who owns the folders
ODX_GID=1000 # `id -g`
odxproxy/config.conf. The container's root filesystem is read-only, so logs go to a tmpfs:
LISTEN_ADDR=0.0.0.0:6600
PROXY_API_KEY=YOUR_PROXY_KEY
RUST_LOG=info
DEFAULT_ODOO_TIMEOUT_SECS=15
LOG_PATH=/tmp
HEARTBEAT=false
LICENSE_KEY=/usr/share/odxproxy/config/license.json
compose.yaml:
x-hardened: &hardened
read_only: true
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
services:
odxproxy:
<<: *hardened
image: terrakernel/odxproxy:0.10.1
environment:
ODXBRAIN_URL: http://odxbrain:8080
ODXBRAIN_API_KEY: ${ODXBRAIN_PROXY_KEY:?set ODXBRAIN_PROXY_KEY in .env}
volumes:
- ./odxproxy:/usr/share/odxproxy/config:ro # config.conf + license.json
tmpfs: ["/tmp"] # LOG_PATH=/tmp in config.conf
ports:
- "127.0.0.1:6600:6600" # drop "127.0.0.1:" to serve other machines
depends_on: [odxbrain]
restart: unless-stopped
networks: [odx]
odxbrain:
<<: *hardened
image: terrakernel/odxbrain:0.1.0
command: ["--model", "/models/bge-m3-f16.gguf", "--model-id", "bge-m3",
"--pack", "/pack", "--sops", "/sops/snapshot.json",
"--host", "0.0.0.0", "--port", "8080"]
environment:
# ODXBrain accepts both keys: odxdocs' and ODXProxy's
ODXBRAIN_API_KEY: "${ODXBRAIN_DOCS_KEY:?set ODXBRAIN_DOCS_KEY in .env}:${ODXBRAIN_PROXY_KEY:?}"
volumes:
- ./models:/models:ro
- ./pack:/pack:ro
- ./sops:/sops:ro
cpus: 2
expose: ["8080"]
ports:
- "127.0.0.1:8080:8080" # only for the odxdocs GUI, which runs on host networking
restart: unless-stopped
networks: [odx]
odxdocs:
<<: *hardened
image: terrakernel/odxdocs:0.1.0
environment:
ODXDOCS_API_KEY: ${ODXDOCS_API_KEY:?set ODXDOCS_API_KEY in .env}
volumes:
- ./store:/store:ro
expose: ["8090"]
restart: unless-stopped
networks: [odx]
publish:
<<: *hardened
image: terrakernel/odxdocs:0.1.0
profiles: [tools]
user: "${ODX_UID:?}:${ODX_GID:?}"
command: ["publish", "/sop-files", "--out", "/sops/snapshot.json", "--store", "/store",
"--brain", "http://odxbrain:8080", "--label", "${ODX_SOP_LABEL:-}"]
environment:
ODXDOCS_BRAIN_KEY: ${ODXBRAIN_DOCS_KEY:?}
volumes:
- ./sop-files:/sop-files:ro
- ./sops:/sops
- ./store:/store
depends_on: [odxbrain]
networks: [odx]
gui:
image: terrakernel/odxdocs:0.1.0
profiles: [tools]
network_mode: host
user: "${ODX_UID:?}:${ODX_GID:?}"
command: ["gui", "/sop-files", "--out", "/sops/snapshot.json", "--store", "/store",
"--brain", "http://127.0.0.1:8080"]
environment:
ODXDOCS_BRAIN_KEY: ${ODXBRAIN_DOCS_KEY:?}
volumes:
- ./sop-files:/sop-files
- ./sops:/sops
- ./store:/store
networks:
odx: {}
# first time only: write the first SOP snapshot, which ODXBrain needs to start
docker compose run --rm --no-deps publish build /sop-files --out /sops/snapshot.json --store /store
docker compose up -d # ODXProxy + ODXBrain + odxdocs serve
docker compose run --rm publish # after an SOP change: check, publish, reload ODXBrain
docker compose run --rm gui # SOP editor on http://127.0.0.1:8091/
Not using SOPs? Drop the odxdocs, publish and gui services, ODXBrain's --sops flag and its
/sops mount, and set ODXBrain's ODXBRAIN_API_KEY to ${ODXBRAIN_PROXY_KEY:?} alone.
Check it:
curl -s http://127.0.0.1:6600/_/about # "features":{"odxbrain":true}
curl -s http://127.0.0.1:6600/brain/v1/info -H "x-api-key: $PROXY_API_KEY"
Network. ODXBrain and odxdocs make no outbound connections. ODXProxy does: it checks its
license against time.odxproxy.io, so its container needs a route to the internet.
distroless/static-debian13, with no
glibc or OpenSSL, and Docker Scout reports 0 vulnerabilities (earlier glibc images had 8 Low
from the Debian base).Content type
Image
Digest
sha256:1237e0b5b…
Size
5.3 MB
Last updated
8 days ago
docker pull terrakernel/odxproxy