[nonimo]
EN
Download the app

How to deploy an AI gateway on your own server with Docker

· Updated

Docker Compose settings to deploy an AI gateway on your own server: a pinned image tag, port 8080 and a masking test with labels
Joaquín Trapero

in Founder of Nonimo, writing on AI and privacy.

A self-hosted privacy proxy for LLM APIs is one more container on a server you already run. Personal data in each prompt becomes a placeholder before the provider sees it, and the reply comes back with the real values. New to the idea? Read our plain explanation of what an AI gateway does first.

This is the hands-on part, from an empty Linux host to HTTPS. The commands use Nonimo Gateway, a private container image; the Compose and nginx settings suit most proxies. Still undecided? I suspect a Compose file won’t settle that; first weigh running your own model against a proxy in front of someone else’s.

Quick answer

Log in to the private registry, write a short Compose file with the image pinned to a version, start it, and send one test request to port 8080. Then change your SDK's base URL, put nginx with TLS in front, run a masking test on fictional data, and update by changing the tag.

What the host needs before you pull the image

A Linux VM or server on amd64 or arm64, inside your network or cloud account, with Docker Engine and the Compose plugin. If docker compose version answers, you have both. The gateway reads and rewrites requests; it does not generate text, so the model’s hardware stays your provider’s problem.

The image is private. You choose a plan on the Nonimo Gateway page, and the licence email brings registry credentials and install notes. Those notes give the exact image reference and the setting names the container reads; this guide uses placeholders wherever they are the source of truth.

Keep the network rules short: inbound only from the services that call a model, outbound only to the places the table lists. A gateway the whole office LAN can reach is asking for trouble, as I see it: sooner or later someone points a browser at it.

docker login <registry-from-your-licence-email>

Traffic the gateway host should allow

Anything not in this table can stay closed.

DirectionFrom or toWhy
In, port 443Your internal services onlyThey send their API calls through nginx
In, port 8080The host itself (127.0.0.1)Local tests; nginx reaches it over the Compose network
Out, HTTPSYour AI providerThe masked request and the reply
Out, HTTPSNonimoLicence check and usage figures, no content
Out, HTTPSThe private registryPulling the image on install and update

What goes to Nonimo, per the FAQ on the Nonimo Gateway page: only the licence check and usage figures. Offline operation is available on request.

Writing the Docker Compose file: three files, one folder

Make a folder such as /srv/llm-gateway and put three files in it. .env is read by Compose itself and pins the image to a version tag. gateway.env is read by the container: licence, upstream provider and, if you enable it, the access token your services must present.

# .env
GATEWAY_IMAGE=<image-reference-from-the-install-notes>:<version>

On anything that sits in the path of client data, my preference is a pinned tag over latest. It turns every update into a decision someone made on purpose, and it gives you a line to put back when you roll back. Lock gateway.env with chmod 600 gateway.env; it holds secrets.

# compose.yaml
services:
  gateway:
    image: ${GATEWAY_IMAGE}
    restart: unless-stopped
    env_file: gateway.env
    ports:
      - "127.0.0.1:8080:8080"
    logging:
      driver: json-file
      options:
        max-size: "20m"
        max-file: "5"

Storage is left open on purpose. The audit log and your client list live on your server, and the install notes say whether that path needs a volume to survive a new container. An audit trail lost on update won’t help when an insurer’s questionnaire asks how you control AI use.

Which file holds what

FileRead byHoldsVersion control?
compose.yamlDocker ComposeService, port binding, log rotation, volumesYes
.envDocker ComposeThe image reference and its pinned tagYes, it records which version runs
gateway.envThe containerLicence, upstream provider, access tokenNo: secrets, mode 600

Variable names inside gateway.env come from the install notes sent with the licence.

First start: docker compose up and a test request

Start it and read what it says about itself:

cd /srv/llm-gateway
docker compose up -d
docker compose ps
docker compose logs --tail 50 gateway

A container marked as running is only half the answer. The other half is a real completion through the whole chain: container, licence, outbound network and provider key. Export the key as OPENAI_API_KEY and call the loopback address, adding the access token header if you enabled one.

curl -sS http://127.0.0.1:8080/v1/chat/completions \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model": "gpt-5.5", "messages": [{"role": "user", "content": "Answer with one word: ok"}]}'

I’d demand that one-word reply before pointing a single service at the gateway. If you want to see what a raw request carries before it is masked, the fields in an OpenAI API call where personal data sits are set out separately.

Is the container ready for live traffic?

Answer from what your terminal shows.

Does docker compose ps show the gateway as running, not restarting?

Did the curl test return a normal completion?

Was it a 401?

Read the first lines of docker compose logs gateway. A missing licence or a required setting left out of gateway.env is the usual reason.
Check who refused you. From the gateway: the access token header is missing or wrong. From the provider: the key was not forwarded, or the one the Gateway reads is not set.
Check outbound HTTPS from the host to your provider and to Nonimo; a proxy or firewall rule upstream is the likely block.
Ready for the next step: point one test service at it, then add TLS before any real data goes through.

Pointing the OpenAI and Anthropic SDKs at the proxy

Your services keep their SDK and their key; only the address changes. Both official Python SDKs take it in the constructor or from an environment variable, and the variable is tidier because no code moves. Note the difference in the table: OpenAI’s address ends in /v1, Anthropic’s does not.

from openai import OpenAI

client = OpenAI(base_url="https://llm-gateway.example.internal/v1")
reply = client.chat.completions.create(
    model="gpt-5.5",
    messages=[{"role": "user", "content": "Write a two-line reminder to Thea Mountjoy about Friday's inspection."}],
)

The Gateway speaks Chat Completions and Responses, with function calling and streaming, plus Claude’s Messages API. Each instance points at one provider, so OpenAI plus Anthropic means two instances. Already routing through LiteLLM? See the two ways Nonimo plugs into a LiteLLM proxy first.

Services can keep sending their own provider key in the Authorization header, or the Gateway can read it from a secret or an environment variable. For a new deployment, my preference is the second: one place to rotate the key, nothing for a developer to paste into a test script.

Where each SDK takes the gateway address

The host name is fictional; use your own.

ClientEnvironment variableValue
OpenAI SDK (Python, Node)OPENAI_BASE_URLhttps://llm-gateway.example.internal/v1
Anthropic SDK (Python)ANTHROPIC_BASE_URLhttps://llm-gateway.example.internal (no /v1: the SDK adds the path)
curl or raw HTTPnoneThe full path, for example /v1/chat/completions

Variable names from the OpenAI Python SDK README and Anthropic's Python SDK documentation. A /v1 on the Anthropic address is a common cause of 404s.

TLS with an nginx reverse proxy, without breaking streaming

Prompts carry exactly the data you are protecting, so they should not cross your network in plain HTTP. Add nginx to the same Compose file, under services:, terminating TLS with a certificate your clients already trust:

  proxy:
    image: nginx:1.30-alpine
    restart: unless-stopped
    depends_on: [gateway]
    ports:
      - "443:443"
    volumes:
      - ./nginx/gateway.conf:/etc/nginx/conf.d/default.conf:ro
      - ./certs:/etc/nginx/certs:ro
server {
    listen 443 ssl;
    server_name llm-gateway.example.internal;
    ssl_certificate     /etc/nginx/certs/llm-gateway.crt;
    ssl_certificate_key /etc/nginx/certs/llm-gateway.key;
    client_max_body_size 20m;
    location / {
        proxy_pass http://gateway:8080;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;
        proxy_buffering off;
        proxy_read_timeout 300s;
    }
}

nginx’s defaults suit web pages, not token streams, and three of them bite here. Buffering is the one I dislike most, because nothing errors: the chat simply looks broken.

nginx defaults that break LLM traffic

Directivenginx defaultWhat goes wrongSet it to
proxy_bufferingonA streamed answer can arrive in lumps or all at the end; the chat looks frozenoff
proxy_read_timeout60sThe connection drops when the upstream sends nothing for a minute: a long non-streamed answer, or a stream that pauses300s
client_max_body_size1mLong conversations or pasted documents get a 41320m

Defaults from the nginx documentation for the proxy and core HTTP modules; the read timeout counts the gap between two reads, not the whole reply. The values on the right are the ones in the configuration above; raise them if your agents run longer.

Docker adds its own catch: published ports are routed before ufw rules apply. On a host with a public interface, publish on the internal address only, such as "10.20.0.15:443:443", and close 443 at the security group. Weighing Kong’s sanitiser instead? Its PII service raises its own TLS and memory questions.

For an internal CA, Node clients need NODE_EXTRA_CA_CERTS=/path/to/internal-ca.pem, set before the process starts. Both Python SDKs now run on httpx2, which OpenAI’s migration notes say checks certificates against the operating system’s store. To pin your CA in code, hand the SDK’s own client class an SSL context:

import ssl
from openai import OpenAI, DefaultHttpx2Client

ctx = ssl.create_default_context(cafile="/path/to/internal-ca.pem")
client = OpenAI(
    base_url="https://llm-gateway.example.internal/v1",
    http_client=DefaultHttpx2Client(verify=ctx),
)

With Anthropic’s SDK the class is DefaultHttpxClient, imported from anthropic. Its documentation says a client built from the separate httpx package raises a TypeError.

Checking that personal data leaves masked

Before the first real client record goes through, send a made-up one and ask the model to repeat it. Your application should get the real values back, and the audit log or dashboard should show the request with a count of what was masked, by type. Counts only, never the data.

curl -sS http://127.0.0.1:8080/v1/chat/completions \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model": "gpt-5.5", "messages": [{"role": "user", "content": "Copy exactly: Ruairi Calloway, +1 202 555 0183, ruairi.calloway@example.com, SSN 987-65-4320"}]}'

A test using only neat English names proves very little. Build the set from what your business really sends, starting from our checklist of the details worth removing. Per data type you choose mask, block or flag, and if detection fails the request is not sent by default. I’d leave that default alone.

If you want to see the outgoing request exactly as the provider receives it, don’t improvise by repointing the upstream. Ask us, or check the install notes, for a supported way to do that in a test set-up.

What your masking test set covers

Tick each case you have put through with fictional values.

0 of 8 points covered

Too thin to trust. Add the cases closest to your real traffic.

The lone name counts double: it is the case pattern rules miss. Every value in the test request above is fictional.

Container logs or the audit log: which record for whom

Two records come out of a running gateway, and they answer different people. docker compose logs is for operations: start-up, warnings, errors. The Gateway’s audit log is for your DPO: one tamper-evident line per request with provider, model and how many items of each type were found, exportable as CSV or JSONL.

Docker’s json-file driver keeps logs without a size limit unless you set one, hence the cap of five 20 MB files. The bigger risk is downstream: if proxy logs reach a central platform with request bodies switched on, prompts sit there readable. It is a quiet way client data escapes through AI tooling.

Your client list lives on the same server. Export clients and companies from your CRM as CSV or Excel, load the file into the Gateway, and reload it when it changes, without restarting the container. A list that is a month out of date is the gap I’d watch most closely.

Three records, three readers

Container logsAudit logDashboard
Who reads itWhoever runs the hostDPO, auditors, insurersIT, day to day
What it holdsStart-up, warnings, errorsProvider, model, count per data typeRequests with personal data by type, and the blocked ones
Personal data in itShould be none; check what your proxy logsNever the data itselfNo text, no names
Size limitNone unless set; 5 × 20 MB hereSee the install notesn/a

Audit log and dashboard as described on the Nonimo Gateway page; log-size default from Docker's json-file driver documentation.

Updating a pinned image and rolling back

The Gateway never updates itself. It tells you a new version exists, and your team decides when to apply it:

# change the tag in GATEWAY_IMAGE inside .env, then:
docker compose pull gateway
docker compose up -d gateway
docker compose logs --tail 20 gateway

Rerun the masking test after every update; it takes a couple of minutes. If anything looks off, put the previous tag back in .env and run docker compose up -d gateway again. Unlike a library you assemble yourself, new versions arrive ready-made rather than as a maintenance job for your team.

With one instance, recreating the container means a few seconds without service. For an internal chatbot that is usually fine; for an agent pipeline running overnight, I’d rather have two copies behind a load balancer and move them one at a time. Pick your layout below for the order of the steps.

Rolling out a new tag: one instance or several

Pick the layout you run.

  1. Change the tag in .env and pull the image.
  2. Run docker compose up -d gateway and expect a pause of a few seconds.
  3. Rerun the masking test from the host.
  4. If it fails, put the old tag back and run the same command again.

This is the Practice plan's layout: it includes one instance. Schedule the pause for a quiet hour.

  1. Take one instance out of the load balancer's pool.
  2. Change its tag, pull, recreate it and rerun the masking test against it directly.
  3. Return it to the pool and repeat with the next.
  4. If the first one fails, keep it out and restore its old tag; the others never stopped.

Business includes up to three instances and Enterprise has no instance cap. Two providers with two copies each already make four.

Instance limits per plan from the Nonimo Gateway page, where each instance serves one provider.

Troubleshooting an AI gateway in Docker

Most failures land in one of five places: the image pull, the start, the request, TLS or streaming. Filter the table to the stage where yours stopped. I trust the stage more than the error text: the same 401 means different things depending on who sent it.

Symptoms, causes and where to look

SymptomLikely causeWhat to check
docker compose pull deniedNot logged in, or the wrong registryRepeat docker login with the credentials from the licence email
Container restarts in a loopLicence or a required setting missingFirst lines of docker compose logs gateway
401 from the GatewayAccess token missing or wrongThe header your service sends, against gateway.env
401 from the providerProvider key not forwarded or not setThe Authorization header, or the key the Gateway reads
404 from the Anthropic SDK/v1 added to the base URLUse the bare host in ANTHROPIC_BASE_URL
413 on long conversationsnginx body size limitclient_max_body_size
Certificate errors in clientsInternal CA not trustedNODE_EXTRA_CA_CERTS, or an SSL context in Python
TypeError on http_client in Anthropic's Python SDKClient built from the old httpx packageUse DefaultHttpxClient from anthropic
Answer arrives all at onceProxy bufferingproxy_buffering off
Long answers cut offProxy read timeoutproxy_read_timeout

The Python TypeError follows Anthropic's SDK documentation, which says a client from the separate httpx package is rejected.

If the stage is unclear, run the curl test from the host. Working there but not from a service puts the fault in nginx, the firewall or the client. Once the wiring is proven, an AI policy staff can actually sign can name the gateway as the only route to the API.

A test email address masked as [EMAIL_1]: the self-hosted AI gateway's audit log records counts by type, never the data

What leaves your network once the AI gateway is live

Once the masking test passes and TLS is in place, the picture is simple. Detection runs on your server, the link from each placeholder back to the real value stays there, and none of your content passes through Nonimo. If you go over your plan’s monthly volume, the Gateway tells you and keeps running.

With a hosted gateway, that inspection runs on the vendor’s network instead, as our look at its DLP profiles for AI traffic explains. For me, that is the whole case for running a container yourself: the inspection happens on a machine your team patches and your DPO can point at.

When the test server has done its job, choose a Gateway plan with room for the instances your layout needs, and the licence email gets the first production host going.

Stays on your server

  • Prompts and replies with the real values
  • The map from each placeholder to its value
  • Your client list from the CRM
  • The audit log and dashboard

Leaves your network

  • To your AI provider: the request with placeholders
  • To Nonimo: the licence check and usage figures
  • To the registry: image pulls when you update

Sources

Accessed 10 November 2026.

Common questions

What hardware do I need to run an AI gateway in Docker?

A modest Linux server will do to begin with. It needs amd64 or arm64, Docker Engine and the Compose plugin. The model still runs at your provider, so add capacity as your request volume grows.

Should the gateway be reachable from the internet?

No, only your own services should reach it. Keep it on an internal network or VPN, behind TLS, and allow outbound HTTPS to your AI provider and to Nonimo for the licence check.

Do I need to change my application code?

Usually not, only its configuration. The official OpenAI and Anthropic SDKs read another address from OPENAI_BASE_URL or ANTHROPIC_BASE_URL, so most services need one new variable and a restart.

Can one gateway instance serve both OpenAI and Claude?

No, each instance points at one provider. Calling OpenAI and Anthropic means two instances, so confirm your plan covers more than one before you design around both.

Can I run Nonimo Gateway on Kubernetes instead of Docker Compose?

Yes, it is an ordinary container. Pull the private image with your registry credentials, keep licence and keys in a secret, expose port 8080, end TLS at the ingress and leave streaming unbuffered.

Can the gateway run on a network with no internet access?

Yes, on request. Offline, it makes no calls to Nonimo's licence service and is designed for isolated networks. The terms are agreed with Nonimo directly.

How do I update a Docker gateway without downtime?

Run two or more instances behind a load balancer. Move them to the new tag one at a time. A single instance always pauses for a few seconds while its container is replaced.