Build a private self-hosted bot with Ollama
Deploy an authenticated loopback service that uses Ollama through its OpenAI-compatible API, exposes only three compiled read-only host probes, records content-free audit evidence, runs under a hardened systemd account, and supports controlled backup, upgrade, rollback, and removal.
Operate a private assistant whose model can request only fixed executable-and-argv diagnostics, whose clients authenticate with a revocable secret, whose metrics and audit trail reveal reliability without retaining prompts, and whose service never interprets generated shell text or performs consequential writes.
- Ollama 0.13.3+
- Node.js 22.22.2
- Linux Ubuntu 24.04 LTS or equivalent systemd host
- Dedicated host boundary Use a maintained Linux host or VM with console recovery, capacity for the approved model, and no requirement to expose Ollama directly to the public network.
- Model approval Select a model whose license, provenance, tool-calling behavior, context needs, memory demand, and expected tasks have been reviewed with a synthetic evaluation set.
- Service identity Reserve an unprivileged ollama-bot account and an owner-only state directory; the bot must not run as root or share write access with model storage.
- Client authentication plan Generate a random bearer token, store only its SHA-256 digest in the bot environment, distribute the original through an approved secret channel, and define rotation and revocation.
OneLiners never runs these steps or stores secrets. Review placeholders, versions, current state, and change-control requirements before using a command.
Full guide
What you will build
- An authenticated Node service on loopback that talks to a local Ollama model through the documented OpenAI-compatible Chat Completions interface.
- A compiled tool policy with only uptime, filesystem capacity, and listening TCP probes, each implemented as an absolute executable plus immutable argv with no shell.
- A hardened systemd unit, content-free Prometheus-style metrics, append-only audit metadata, deterministic policy tests, and explicit remote-gateway boundary.
- Backup, recovery, upgrade, rollback, token rotation, and teardown procedures that distinguish code, secrets, audit, runtime, and model weights.
- Unauthenticated callers cannot reach model or tool execution, and authenticated requests have tools disabled unless they explicitly opt into the read-only set.
- No model output can create a command line, change argv, select a filesystem path, invoke sudo, execute a write, or add a new tool.
- Operators can correlate failures and tool use through request IDs and counters without retaining prompt, response, bearer token, or raw diagnostic output.
- A previous immutable release can be restored with a rotated token and pinned model, while shared Ollama storage is preserved.
Architecture
How the parts fit together
The private service separates identity, inference, execution, and evidence. A client authenticates to a loopback bot using a high-entropy bearer token. The bot validates bounded messages and decides whether the fixed read-only tool definitions are visible. Ollama proposes text or a known tool name through its OpenAI-compatible response. Zod validates the name and rejects every argument; a fixed spawn policy runs one absolute executable without a shell. Results return separately for human review. Content-free audit and metrics describe operation, while systemd confines filesystem and privilege access. Remote access, if ever required, terminates at a different authenticated gateway and never publishes Ollama directly.
- The authenticated client submits at most twenty bounded user and assistant messages and chooses whether read-only probes are available.
- The bot calls the selected local model at 127.0.0.1:11434 using the OpenAI-compatible client and no cloud credential.
- If the model requests a tool, strict validation accepts only a compiled name with no arguments; every other request fails closed.
- The executor starts the fixed process with shell false, minimal environment, ignored stdin, fixed working directory, timeout, and output cap.
- The service returns unverified draft text and bounded evidence separately, records content-free audit metadata, and performs no second autonomous action.
- Operators inspect metrics, journal events, release checksums, model tag, and audit IDs during acceptance, upgrade, or recovery.
Assumptions
- The host is maintained, console recovery exists, and the service is not treated as a boundary against root or a fully compromised operating system.
- The selected model is approved, locally available, capable enough for the evaluated tasks, and not trusted to authorize or define execution.
- Initial clients are local or reach the service through a separately reviewed private gateway; neither Ollama nor the bot is publicly exposed.
- A high-entropy machine-generated token is used. Human passwords are not hashed with a single fast SHA-256 operation.
- The three probes are acceptable under local privacy policy and their raw output is returned only to an authorized requesting client.
- No production requirement depends on autonomous repair, arbitrary paths, sudo, external network tools, or silent consequential changes.
Key concepts
- OpenAI-compatible client
- A familiar SDK configured with a different base URL. Ollama supports documented portions of the API; unsupported fields and semantics must not be assumed.
- Compiled allowlist
- A source-controlled mapping from logical tool names to literal executable paths and argument arrays. The model cannot add, edit, or parameterize entries.
- shell false
- The process starts an executable directly, so pipes, redirects, substitutions, globbing, separators, and shell built-ins in model text have no interpretation.
- Consequential write
- An action that changes files, services, accounts, packages, network policy, processes, external systems, or durable data and therefore needs a separate proposal and approval path.
- Content-free audit
- Operational evidence containing IDs, times, result classes, counts, and approved tool names without prompt, response, secret, or raw host output.
- High-entropy bearer token
- A randomly generated secret with enough unpredictable bits that storing a fast digest is suitable; this is different from a memorable password.
- Fail closed
- Unknown tool names, extra arguments, malformed JSON, timeouts, and oversized output produce an error without executing an alternative or broadening permissions.
- Immutable release
- A versioned application directory whose code is not edited in place; activation changes one reviewed pointer and rollback restores the prior directory.
Before you copy
Values used in this guide
{{ollamaModel}}Exact locally pulled model tag approved for this service and recorded in the recovery manifest.
Example: gpt-oss:20b{{botToken}}secretRandom client bearer token distributed through an approved secret channel; only its SHA-256 digest resides in the service environment.
Example: generated with at least 32 random bytes{{backupDirectory}}Resolved encrypted owner-only destination for the closed audit snapshot, checksums, and recovery manifest.
Example: /mnt/encrypted/ollama-operator/2026-07-29Security and production boundaries
- Ollama's OpenAI-compatible api_key placeholder is documented as ignored; do not mistake it for server authentication.
- Keep both Ollama and the bot on loopback. Remote access belongs behind TLS, individual identity, authorization, request limits, and separate review.
- Never pass model text to exec, spawn through a shell, sudo, a script interpreter, an SQL engine, or a path join used for writes.
- The model may suggest a valid tool incorrectly. Runtime allowlisting prevents unsupported actions but does not make every allowed probe contextually appropriate.
- Raw diagnostics can reveal storage names, capacity, listeners, and host timing. Return them only to authenticated users and do not retain them in general audit logs.
- Metrics must avoid high-cardinality or private labels. Request IDs, status classes, tool names, and aggregate durations are sufficient for initial operation.
- A bearer token is replayable. Rotate it after suspected exposure and prefer per-user gateway identity for teams.
- Do not run as root. The initial tools require no privilege, home directory, device, package, service-management, or general write access.
- No automated model response should trigger a consequential write. Future write features require human approval over an exact structured proposal and verified rollback.
- Model upgrades are application changes: repeat synthetic evaluations, tool-call rejection tests, latency checks, and recovery rehearsal.
Stop before continuing if
- Stop if any requested tool requires a shell string, user-supplied argv, arbitrary path, sudo, outbound network, package change, process kill, or file write.
- Stop if either service listens on a wildcard or public address without the approved gateway architecture.
- Stop if prompts, responses, secrets, or raw tool output appear in metrics, audit, systemd status, reverse-proxy logs, or support bundles.
- Stop if an unauthenticated request can consume model or tool resources.
- Stop if the bot runs as root or needs broad write access to solve a local permission problem.
- Stop if a model change has not passed the negative tool-policy suite and representative synthetic evaluations.
- Stop before backup or teardown if a target path is unresolved, unencrypted, shared, or contains the only audit evidence.
- Stop if operators describe any generated answer as verified merely because inference and tools ran locally.
instruction
Write the trust and action policy
Classify users, prompts, host evidence, audit metadata, model output, and secrets before installation. State that Ollama and the bot listen on loopback, that a separate authenticated reverse proxy is required for remote clients, and that the initial tool set is read-only. Name every permitted executable and exact argument list. Explicitly prohibit arbitrary shell strings, user-supplied paths, sudo, package changes, file writes, process termination, outbound probes, and autonomous retries. A model may request evidence; application code decides whether the request matches the compiled policy.
Why this step matters
Tool safety comes from deterministic runtime enforcement and service isolation, not from a prompt asking a probabilistic model to behave carefully.
What to understand
Read-only commands can still reveal sensitive topology, usernames, mounts, or listeners, so authorization and output handling remain necessary.
A cloud-style API shape does not make the local server multi-tenant or safely exposed; authentication belongs in the application or reverse proxy.
Any future write tool needs a proposal object, exact diff, authenticated human approval, idempotency, audit, and rollback rather than reuse of this read-only executor.
System changes
- Creates a reviewed policy document and executable allowlist specification; it changes no host service.
Approved policy: loopback only; authenticated clients; tools limited to /usr/bin/uptime, /usr/bin/df -P -k, and /usr/bin/ss -lnt; no shell, paths, sudo, network probes, or writes.
Checkpoint: Policy is mechanically implementable
Continue whenEvery allowed probe maps to one absolute executable and immutable argv, while every write and network probe is explicitly absent.
Stop whenStop if requirements include free-form commands, root privileges, silent writes, unrestricted paths, or public exposure without a separate access design.
Security notes
- Never expose raw model tool arguments directly to spawn, exec, a shell, or a templating layer.
Alternatives
- Offer operator-run copyable diagnostics when even read-only service probes are inappropriate.
Stop conditions
- Do not treat model confidence as authorization.
command
Install and bind Ollama to loopback
Install Ollama from its official Linux distribution, keep the service account and systemd unit, and configure OLLAMA_HOST for 127.0.0.1:11434. Pull the exact approved model tag while online, then verify the listener and local model inventory. Do not place authentication responsibility on Ollama's ignored OpenAI-compatible api_key field; this design authenticates callers at the bot boundary and prevents direct remote access to the Ollama port.
Why this step matters
A private bot needs a reproducible local model endpoint and exact model tag before application behavior, resource budgets, and recovery can be evaluated.
What to understand
The systemd override should contain Environment="OLLAMA_HOST=127.0.0.1:11434" and no wildcard address.
The OpenAI-compatible API requires an api_key in many client libraries, but Ollama documents that the placeholder is ignored.
Pulling changes persistent model storage and may use substantial bandwidth and disk, so capacity and license approval precede the command.
System changes
- Installs or updates Ollama, writes a systemd override, restarts its service, and downloads the selected model into Ollama-managed storage.
Syntax explained
systemctl edit- Creates a managed override rather than modifying the vendor service unit in place.
ollama pull- Downloads the exact model tag and changes persistent Ollama model storage.
/v1/models- Exercises Ollama's documented OpenAI-compatible model-list endpoint locally.
Values stay on this page and are never sent or saved.
sudo systemctl edit ollama.service && sudo systemctl daemon-reload && sudo systemctl restart ollama && ollama pull {{ollamaModel}} && curl --fail --silent --max-time 5 http://127.0.0.1:11434/v1/models{"object":"list","data":[{"id":"gpt-oss:20b","object":"model","owned_by":"library"}]}Checkpoint: Ollama is private and model-ready
ss -lntp | grep 127.0.0.1:11434 && ollama listContinue whenOnly a loopback listener appears and the exact approved model tag is listed locally.
Stop whenStop if Ollama binds to 0.0.0.0, a LAN address, or the model provenance and storage impact are unapproved.
Alternatives
- Use a manually installed pinned Ollama version when organizational release controls require exact binaries.
Stop conditions
- Do not run the install script without reviewing the current official instructions and change window.
config
Scaffold the pinned TypeScript service
Create an owner-controlled source tree and save package.json with exact runtime versions. The service uses the official OpenAI JavaScript client against Ollama's loopback base URL, Zod for runtime validation, Node HTTP and crypto primitives, and no web framework. Pinning is not a substitute for lockfile review, but it keeps API behavior, compilation, and recovery evidence explicit.
Why this step matters
A small pinned dependency surface is easier to audit, package, reproduce, and roll back than an application assembled from unbounded global tooling.
What to understand
The engine field records the supported Node line; production should install dependencies from a reviewed lockfile with npm ci.
The OpenAI client is configured later with an Ollama baseURL and placeholder local apiKey, never a cloud key.
Build and state directories remain distinct so releases can be immutable and audit state can survive rollback.
System changes
- Creates package metadata and a lockfile under the dedicated application directory after npm install.
Syntax explained
private: true- Prevents accidental publication of the service package to the npm registry.
npm ci- Installs the exact reviewed lockfile graph and fails rather than rewriting dependency resolution.
/opt/ollama-operator/package.json{
"name": "private-ollama-operator-bot",
"private": true,
"type": "module",
"engines": { "node": "22.22.2" },
"scripts": {
"start": "tsx src/server.ts",
"test": "node --import tsx --test test/*.test.ts",
"check": "tsc --noEmit"
},
"dependencies": {
"openai": "7.1.0",
"zod": "4.4.3"
},
"devDependencies": {
"@types/node": "22.19.19",
"tsx": "4.23.1",
"typescript": "5.9.3"
}
}added 9 packages, and audited 10 packages in 2s found 0 vulnerabilities
Checkpoint: Dependency graph is reproducible
cd /opt/ollama-operator && npm install --package-lock-only && npm ci && npm run checkContinue whenThe lockfile is created, exact dependencies install, and strict TypeScript compilation succeeds.
Stop whenStop if installation runs as root into an uncontrolled directory or the lockfile includes an unreviewed registry or lifecycle script.
Alternatives
- Build in CI and deploy a signed immutable artifact instead of installing dependencies on the service host.
Stop conditions
- Do not store bearer tokens or prompts in package metadata, lockfiles, build logs, or source maps.
config
Implement the compiled read-only tool allowlist
Save src/tools.ts. Each logical tool maps to a literal absolute executable and literal argv. Runtime validation accepts only three names and no arguments. Node spawn runs with shell false, a minimal environment, fixed working directory, ignored stdin, a ten-second timeout, and independent 64 KiB stdout and stderr caps. Model-generated text cannot select a path, add a flag, pipe output, redirect a file, expand a variable, or invoke another program.
Why this step matters
The executor is the principal safety boundary, so its accepted schema and process parameters must be narrower than the model's expressive output.
What to understand
Absolute executable paths avoid PATH substitution even though a minimal PATH is supplied for command internals.
Output caps and timeout turn surprising host state into a controlled error rather than memory exhaustion or a stuck request.
The listening probe omits process ownership because that evidence is often more sensitive and is unnecessary for the initial use case.
System changes
- Creates a source module; running a tool later starts only the selected fixed read-only process as the unprivileged service account.
Syntax explained
spawn(executable, args)- Runs one reviewed executable with a separate immutable argument vector.
shell: false- Prevents shell metacharacter, expansion, pipeline, and redirection interpretation.
kill SIGKILL after 10 seconds- Applies a hard runtime ceiling to every probe.
/opt/ollama-operator/src/tools.tsimport { spawn } from "node:child_process";
import { z } from "zod";
const tools = {
host_uptime: { executable: "/usr/bin/uptime", args: [] },
disk_usage: { executable: "/usr/bin/df", args: ["-P", "-k"] },
listening_tcp: { executable: "/usr/bin/ss", args: ["-lnt"] }
} as const;
export type ToolName = keyof typeof tools;
export const toolRequest = z.object({
name: z.enum(["host_uptime", "disk_usage", "listening_tcp"])
}).strict();
export async function runReadOnlyTool(
name: ToolName
): Promise<{ name: ToolName; exitCode: number; stdout: string; stderr: string }> {
const definition = tools[name];
return await new Promise((resolve, reject) => {
const child = spawn(definition.executable, definition.args, {
shell: false,
env: { PATH: "/usr/bin:/bin", LANG: "C.UTF-8" },
cwd: "/var/empty",
stdio: ["ignore", "pipe", "pipe"]
});
const limit = 65_536;
let stdout = Buffer.alloc(0);
let stderr = Buffer.alloc(0);
let exceeded = false;
const append = (current: Buffer, chunk: Buffer) => {
const next = Buffer.concat([current, chunk]);
if (next.length > limit) {
exceeded = true;
child.kill("SIGKILL");
return next.subarray(0, limit);
}
return next;
};
child.stdout.on("data", (chunk: Buffer) => { stdout = append(stdout, chunk); });
child.stderr.on("data", (chunk: Buffer) => { stderr = append(stderr, chunk); });
const timer = setTimeout(() => child.kill("SIGKILL"), 10_000);
child.once("error", reject);
child.once("close", (code, signal) => {
clearTimeout(timer);
if (signal || exceeded) {
reject(new Error(exceeded ? "tool_output_limit" : "tool_timeout"));
return;
}
resolve({
name,
exitCode: code ?? 1,
stdout: stdout.toString("utf8"),
stderr: stderr.toString("utf8")
});
});
});
}
export const openAiToolDefinitions = [
{
type: "function" as const,
function: {
name: "host_uptime",
description: "Read the host uptime and load averages.",
parameters: { type: "object", properties: {}, additionalProperties: false }
}
},
{
type: "function" as const,
function: {
name: "disk_usage",
description: "Read POSIX filesystem capacity in fixed kilobyte units.",
parameters: { type: "object", properties: {}, additionalProperties: false }
}
},
{
type: "function" as const,
function: {
name: "listening_tcp",
description: "Read listening TCP socket addresses without process details.",
parameters: { type: "object", properties: {}, additionalProperties: false }
}
}
];
Compiled 3 read-only tools: host_uptime, disk_usage, listening_tcp; shell execution disabled.
Checkpoint: Unknown names and arguments fail closed
cd /opt/ollama-operator && npm test -- --test-name-pattern='allowlist'Continue whenRequests for shell or extra path arguments are rejected before any process starts.
Stop whenStop if code uses exec, shell true, a command string, user-provided argv, inherited secrets, or an unbounded output collector.
Security notes
- Tool output is untrusted data that can contain terminal control sequences or misleading text; render or encode it safely.
Alternatives
- Read equivalent metrics from a dedicated telemetry collector rather than spawning host commands.
Stop conditions
- Never add a generic run_command tool.
config
Add authentication, model calls, audit, and metrics
Save src/server.ts. The service binds to a literal loopback host, validates a bearer token using a stored SHA-256 digest and timing-safe comparison, limits request bytes and message history, and calls Ollama through the OpenAI-compatible client. Tools are absent unless the authenticated user opts into read-only probes. The response returns draft text and evidence separately and intentionally does not run an autonomous second tool loop. Audit records IDs, outcome, tool names, and message count but never prompt, response, token, or raw tool output.
Why this step matters
Authentication, bounded parsing, tool authorization, privacy-preserving audit, and model invocation must meet in one request boundary before any local evidence is collected.
What to understand
The hash is adequate for a high-entropy random token, not for a human password; passwords require a slow password-hashing scheme.
Metrics have no user, prompt, path, or model-output labels and remain on loopback with the application.
Returning tool results separately lets a human inspect evidence and prevents an unconstrained model-tool-model loop from multiplying calls.
System changes
- Creates the service request handler and appends content-free NDJSON records under the dedicated state path during authenticated requests.
Syntax explained
timingSafeEqual- Compares fixed-length token digests without ordinary early-exit string comparison.
tool_choice: none- Disables model tool requests unless the authenticated caller explicitly enables the read-only set.
cache-control: no-store- Tells intermediaries not to cache bot responses.
/opt/ollama-operator/src/server.tsimport { createHash, randomUUID, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
import { appendFile } from "node:fs/promises";
import OpenAI from "openai";
import { z } from "zod";
import { openAiToolDefinitions, runReadOnlyTool, toolRequest } from "./tools.js";
const env = z.object({
BOT_HOST: z.literal("127.0.0.1").default("127.0.0.1"),
BOT_PORT: z.coerce.number().int().min(1024).max(65535).default(8088),
BOT_TOKEN_SHA256: z.string().regex(/^[a-f0-9]{64}$/),
OLLAMA_MODEL: z.string().min(1).max(120),
OLLAMA_BASE_URL: z.literal("http://127.0.0.1:11434/v1").default("http://127.0.0.1:11434/v1"),
AUDIT_FILE: z.string().default("/var/lib/ollama-operator/audit.ndjson")
}).parse(process.env);
const openai = new OpenAI({ baseURL: env.OLLAMA_BASE_URL, apiKey: "ollama" });
const requestSchema = z.object({
messages: z.array(z.object({
role: z.enum(["user", "assistant"]),
content: z.string().min(1).max(16_384)
})).min(1).max(20),
allowReadOnlyTools: z.boolean().default(false)
}).strict();
const counters = { requests: 0, rejected: 0, tools: 0, failures: 0 };
function authenticate(header: string | undefined): boolean {
if (!header?.startsWith("Bearer ")) return false;
const actual = Buffer.from(createHash("sha256").update(header.slice(7)).digest("hex"));
const expected = Buffer.from(env.BOT_TOKEN_SHA256);
return actual.length === expected.length && timingSafeEqual(actual, expected);
}
async function audit(event: Record<string, unknown>): Promise<void> {
await appendFile(env.AUDIT_FILE, JSON.stringify({
timestamp: new Date().toISOString(),
...event
}) + "\n", { encoding: "utf8", mode: 0o600 });
}
async function readBody(request: import("node:http").IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of request) {
const bytes = Buffer.from(chunk);
size += bytes.length;
if (size > 65_536) throw new Error("request_too_large");
chunks.push(bytes);
}
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
}
const server = createServer(async (request, response) => {
const requestId = randomUUID();
if (request.url === "/healthz" && request.method === "GET") {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ status: "ok", model: env.OLLAMA_MODEL }));
return;
}
if (request.url === "/metrics" && request.method === "GET") {
response.writeHead(200, { "content-type": "text/plain; version=0.0.4" });
response.end(Object.entries(counters)
.map(([name, value]) => "ollama_operator_" + name + "_total " + value)
.join("\n") + "\n");
return;
}
if (request.url !== "/v1/chat" || request.method !== "POST") {
response.writeHead(404).end();
return;
}
if (!authenticate(request.headers.authorization)) {
counters.rejected++;
response.writeHead(401, { "content-type": "application/json" });
response.end(JSON.stringify({ error: "unauthorized", requestId }));
return;
}
counters.requests++;
try {
const input = requestSchema.parse(await readBody(request));
const completion = await openai.chat.completions.create({
model: env.OLLAMA_MODEL,
messages: [
{
role: "system",
content: "You are a private operations assistant. Use only supplied read-only tools. Never claim to execute writes. Explain uncertainty."
},
...input.messages
],
tools: input.allowReadOnlyTools ? openAiToolDefinitions : undefined,
tool_choice: input.allowReadOnlyTools ? "auto" : "none",
temperature: 0.2
});
const message = completion.choices[0]?.message;
const toolResults: Array<Record<string, unknown>> = [];
for (const call of message?.tool_calls ?? []) {
if (call.type !== "function") continue;
const parsed = toolRequest.parse({
name: call.function.name,
...JSON.parse(call.function.arguments || "{}")
});
const result = await runReadOnlyTool(parsed.name);
counters.tools++;
toolResults.push({
toolCallId: call.id,
name: result.name,
exitCode: result.exitCode,
stdout: result.stdout,
stderr: result.stderr
});
}
await audit({
requestId,
outcome: "ok",
toolNames: toolResults.map((item) => item.name),
messageCount: input.messages.length
});
response.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
response.end(JSON.stringify({
requestId,
draft: message?.content ?? "",
toolResults,
notice: "Model output is unverified. No write action was executed."
}));
} catch (error) {
counters.failures++;
await audit({ requestId, outcome: "error", errorClass: error instanceof z.ZodError ? "validation" : "runtime" });
response.writeHead(400, { "content-type": "application/json" });
response.end(JSON.stringify({ error: "request_failed", requestId }));
}
});
server.listen(env.BOT_PORT, env.BOT_HOST, () => {
console.log(JSON.stringify({ event: "listening", host: env.BOT_HOST, port: env.BOT_PORT }));
});
{"event":"listening","host":"127.0.0.1","port":8088}Checkpoint: Unauthenticated and oversized requests fail
curl --silent --output /dev/null --write-out '%{http_code}\n' -X POST http://127.0.0.1:8088/v1/chat -H 'Content-Type: application/json' -d '{"messages":[{"role":"user","content":"test"}]}'Continue whenThe service returns 401 without contacting Ollama or running a host probe.
Stop whenStop if /v1/chat is externally reachable, accepts an unhashed static password, logs content, or runs tools without the explicit request flag.
Security notes
- A SHA-256 token digest is safe only for a randomly generated token with sufficient entropy.
Alternatives
- Terminate TLS and use OIDC at a reviewed reverse proxy for remote multi-user access.
Stop conditions
- Do not bind BOT_HOST to a wildcard to solve remote access.
config
Run under a hardened systemd service
Create a dedicated system user, owner-only state directory, root-owned application release, secret environment file, and this service unit. The sandbox denies home access, device access, and writes outside the audit directory. The service can make only local IPv4, IPv6, and Unix connections required for Ollama and its listener. Generate the random bearer token outside the unit, store only its digest in the environment, and deliver the original to clients through a separate approved secret channel.
Why this step matters
An unprivileged identity and restrictive service sandbox contain application defects and tool behavior even when model output or host data is adversarial.
What to understand
ProtectSystem strict makes the operating-system tree read-only while ReadWritePaths grants one explicit audit directory.
ProtectHome prevents probes and code from reading user home directories; the three current tools do not require that access.
The environment file must be root-readable only and contains the token digest, model tag, and fixed paths but not the original client token.
System changes
- Creates the ollama-bot account and state directory, installs a systemd unit, reads a root-owned environment file, and starts the service.
Syntax explained
NoNewPrivileges=yes- Prevents the service and children from gaining privileges through exec transitions.
ProtectSystem=strict- Makes filesystem locations read-only except explicit writable paths.
UMask=0077- Creates audit and runtime files accessible only to their owner by default.
/etc/systemd/system/ollama-operator.service[Unit]
Description=Private Ollama operator bot
After=network-online.target ollama.service
Requires=ollama.service
[Service]
Type=simple
User=ollama-bot
Group=ollama-bot
WorkingDirectory=/opt/ollama-operator
EnvironmentFile=/etc/ollama-operator/environment
ExecStart=/usr/bin/node --enable-source-maps /opt/ollama-operator/dist/server.js
Restart=on-failure
RestartSec=5
NoNewPrivileges=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/var/lib/ollama-operator
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
SystemCallArchitectures=native
UMask=0077
[Install]
WantedBy=multi-user.target
Active: active (running) Main PID: 28417 (node) Tasks: 11 Memory: 58.4M
Checkpoint: Sandboxed service starts on loopback
systemctl status ollama-operator --no-pager && ss -lntp | grep 127.0.0.1:8088Continue whenThe service is active as ollama-bot and only the loopback bot listener is visible.
Stop whenStop if the unit runs as root, writes broadly, reads home directories, inherits a cloud key, or publishes a wildcard listener.
Alternatives
- Run the same immutable service in a rootless container with equivalent loopback and volume boundaries.
Stop conditions
- Do not weaken the entire sandbox to fix one missing audit directory permission.
config
Test the policy and one fixed probe
Save the Node test suite. It proves that invented tool names and additional arguments fail validation, then executes the fixed disk probe and checks its identity, exit status, recognizable header, and output ceiling. Add request-level tests with a fake OpenAI client in CI, plus a local acceptance test against the approved model. Never use production prompts, a cloud API key, mutable system commands, or destructive fixtures.
Why this step matters
Executable policy needs negative tests that demonstrate what cannot run, not only a happy path showing one approved diagnostic succeeds.
What to understand
Strict object validation rejects extra fields, closing the common path where a safe tool name carries an unsafe path or flag.
The integration test runs as the service identity on a prepared host because permissions and utility output vary across environments.
Model behavior is evaluated separately; a model failing to request a tool must never cause the runtime to relax validation.
System changes
- Creates test code; the positive test executes only /usr/bin/df with fixed read-only arguments and captures bounded output.
Syntax explained
assert.throws- Proves invalid names and unexpected arguments fail before execution.
assert.ok length <= 65536- Confirms captured diagnostic output respects the documented ceiling.
/opt/ollama-operator/test/tools.test.tsimport assert from "node:assert/strict";
import test from "node:test";
import { toolRequest, runReadOnlyTool } from "../src/tools.js";
test("rejects every tool outside the compiled allowlist", () => {
assert.throws(() => toolRequest.parse({ name: "shell", command: "id" }));
assert.throws(() => toolRequest.parse({ name: "disk_usage", path: "/" }));
});
test("runs the disk probe with fixed executable and argv", async () => {
const result = await runReadOnlyTool("disk_usage");
assert.equal(result.name, "disk_usage");
assert.equal(result.exitCode, 0);
assert.match(result.stdout, /Filesystem/);
assert.ok(result.stdout.length <= 65_536);
});
✔ rejects every tool outside the compiled allowlist ✔ runs the disk probe with fixed executable and argv ℹ tests 2 ℹ pass 2
Checkpoint: Tool policy tests pass
cd /opt/ollama-operator && npm testContinue whenBoth allowlist rejection and fixed disk probe tests pass without a shell, root access, model, or network call.
Stop whenStop if tests mock away the process boundary entirely or rely on a production token and real prompt history.
Alternatives
- Use a fixture executable in CI while retaining one host acceptance test for actual utility paths.
Stop conditions
- Do not make tests pass by adding model-proposed names to the allowlist automatically.
verification
Run authenticated acceptance and observability checks
Send one synthetic chat with tools disabled, one with read-only tools allowed, one invalid token, one oversized body, and one invented tool request through a controlled model fixture. Check health, metrics, audit NDJSON, systemd journal, Ollama journal, process identity, listener scope, latency, and memory. Evidence should identify request IDs and status classes without transcript content. A successful model answer is not sufficient acceptance if the service boundary or audit leaks data.
Why this step matters
Acceptance must prove authentication, no-tool default, bounded tool execution, private observability, and failure behavior on the deployed service rather than trusting code review alone.
What to understand
The test token is supplied through an environment or secret client and must not remain in shell history or screenshots.
Metrics establish request and failure counts but deliberately cannot reconstruct the prompt or response.
Tool evidence may contain sensitive host state; client authorization and retention policy apply even though audit omits raw output.
System changes
- Reads host state through one approved uptime probe and appends a content-free audit event for the authenticated synthetic request.
Syntax explained
allowReadOnlyTools: true- Explicitly makes the three compiled diagnostic definitions available for this request only.
--max-time 30- Bounds the client wait during acceptance.
requestId- Correlates client error, audit, and service logs without using private prompt text.
Values stay on this page and are never sent or saved.
curl --fail --silent --max-time 30 -X POST http://127.0.0.1:8088/v1/chat -H "Authorization: Bearer {{botToken}}" -H 'Content-Type: application/json' -d '{"messages":[{"role":"user","content":"Report host uptime using approved read-only evidence."}],"allowReadOnlyTools":true}'{"requestId":"936da82d-4809-4ec7-9556-1648c75051bd","draft":"I can inspect the approved uptime evidence.","toolResults":[{"name":"host_uptime","exitCode":0,"stdout":" 10:20:02 up 8 days, 2:14, 1 user, load average: 0.11, 0.09, 0.06\n","stderr":""}],"notice":"Model output is unverified. No write action was executed."}Checkpoint: Private evidence path works
curl --silent http://127.0.0.1:8088/metrics && tail -n 3 /var/lib/ollama-operator/audit.ndjsonContinue whenCounters increase and audit shows request ID, outcome, tool name, and message count without prompt, response, token, or stdout.
Stop whenStop release if metrics or audit contains content, invalid callers reach Ollama, or any tool outside the compiled list starts.
Security notes
- Use a synthetic request because terminal history and acceptance artifacts may be retained.
Alternatives
- Keep tool use entirely disabled and expose the same diagnostics through a separate operator-only interface.
Stop conditions
- Do not publish raw /metrics or audit files through the remote client proxy.
warning
Expose remote access only through a reviewed gateway
The service is complete on loopback. If remote clients are required, place a separately managed TLS gateway on the intended private network, authenticate individual users with OIDC or mutual TLS, authorize the bot route, apply request and concurrency limits, preserve no-store headers, and forward to 127.0.0.1:8088. Do not publish Ollama's 11434 port. A shared bearer token may be acceptable for one local operator, but it lacks user-level revocation and attribution for a team.
Why this step matters
Network exposure changes the threat model from same-host application authentication to multi-user transport, identity, authorization, abuse control, and incident response.
What to understand
TLS termination must validate hostnames and current certificates; an internal network alone does not protect prompt confidentiality.
Per-user identity enables revocation and rate limits without sharing the underlying bot token.
The gateway should not buffer or log request bodies, authorization headers, model output, or tool evidence by default.
System changes
- May add a separately reviewed gateway configuration and identity-provider registration; does not change either loopback service listener.
Gateway review: TLS valid; OIDC subject required; /v1/chat rate limited; upstream 127.0.0.1:8088; ports 8088 and 11434 remain unreachable from the client network.
Checkpoint: Remote boundary is independently reviewed
Continue whenOnly the gateway is remotely reachable, every user authenticates individually, rate limits apply, and direct Ollama and bot ports remain loopback-only.
Stop whenStop if the proposed solution is changing OLLAMA_HOST or BOT_HOST to 0.0.0.0, sharing one token broadly, or terminating TLS without identity.
Security notes
- Remote access should be a separate change with its own tests, rollback, certificate ownership, and incident runbook.
Alternatives
- Use SSH local port forwarding for a small administrator group when policy permits and user identity already exists in SSH.
Stop conditions
- Do not publish /healthz or /metrics through the public gateway without a demonstrated operational need.
command
Back up audit state and recovery metadata
Stop the bot to create a consistent audit snapshot, copy the NDJSON file and a manifest containing package checksum, Node version, Ollama version, exact model tag, service unit checksum, environment keys without secret values, and review date to an encrypted destination. The original token is not backed up with application state; recovery generates and distributes a new one. Model weights may be re-pulled by exact approved tag unless offline recovery policy requires a separately governed model archive.
Why this step matters
A recoverable private service needs immutable code and configuration evidence plus the minimal approved audit state, while secrets and reproducible model weights follow separate custody rules.
What to understand
Stopping avoids a partially appended NDJSON line and makes the backup boundary easy to explain.
Checksums detect transfer corruption but should be authenticated through release signing or a trusted configuration repository.
Restore rehearsal must occur on an isolated host and must generate a new token rather than reviving an unknown old credential.
System changes
- Briefly stops the bot, creates an owner-only backup directory, copies audit state and checksums, then starts the service again.
Syntax explained
systemctl stop/start- Creates a clear audit-file consistency boundary and then restores service.
cp --preserve- Retains restrictive permissions and timestamps for recovery evidence.
SHA256SUMS- Records code, unit, and audit digests for later integrity checks.
Values stay on this page and are never sent or saved.
sudo systemctl stop ollama-operator && sudo install -d -m 0700 "{{backupDirectory}}" && sudo cp --preserve=mode,timestamps /var/lib/ollama-operator/audit.ndjson "{{backupDirectory}}/audit.ndjson" && sudo sha256sum /opt/ollama-operator/dist/server.js /etc/systemd/system/ollama-operator.service "{{backupDirectory}}/audit.ndjson" | sudo tee "{{backupDirectory}}/SHA256SUMS" >/dev/null && sudo systemctl start ollama-operatorollama-operator.service stopped 3 files recorded in SHA256SUMS ollama-operator.service active
Checkpoint: Recovery bundle is complete but secret-free
sudo find "{{backupDirectory}}" -maxdepth 1 -type f -printf '%m %f %s bytes\n'Continue whenOnly audit.ndjson, SHA256SUMS, and approved recovery metadata appear; no bearer token, prompt, response, stdout, or model weight is included.
Stop whenStop if the destination is unencrypted, broadly readable, or the bot cannot be safely interrupted.
Alternatives
- Rotate audit files and copy a closed segment without service downtime once rotation is implemented and tested.
Stop conditions
- Do not include the original client token in the same recovery archive.
warning
Upgrade, recover, and tear down without broad deletion
Stage every application revision under a new immutable release directory, run compilation and tests, verify the model tag and Ollama API behavior, then switch the service target and observe synthetic traffic. Keep the previous directory, unit, environment, and audit snapshot until acceptance. Roll back by restoring the prior target and restarting. For teardown, disable the bot, revoke its token, archive or delete audit state by policy, remove the dedicated account and app files, and make a separate decision about Ollama and model weights shared by other workloads.
Why this step matters
Code, credentials, audit records, Ollama runtime, and model weights have different retention and ownership boundaries, so upgrade and removal must treat them independently.
What to understand
A model tag can move; record a content identifier or release evidence when the distribution exposes one and rerun evaluation after any model change.
Never run database-like in-place mutations against the audit file during upgrade; append-only state can be preserved across release rollback.
Removing the bot does not authorize deleting /usr/share/ollama or disabling Ollama if another approved application depends on it.
System changes
- May change one service release pointer, rotate a bearer token, stop and disable the bot unit, or remove exact dedicated files after explicit approval.
Release switched to 2026.07.29-1; prior release retained; synthetic authentication and tool checks passed; audit snapshot verified; model storage unchanged.
Checkpoint: Previous release remains recoverable
Continue whenThe prior checksum-verified release and audit snapshot remain intact until the new service passes authentication, tool-policy, metrics, and failure acceptance.
Stop whenStop if cleanup targets use unresolved variables, recursive home paths, shared Ollama storage, or the only audit copy.
Security notes
- Rotate the token after rollback when the failed release or troubleshooting process may have exposed it.
Alternatives
- Keep a disabled quarantine copy of the service for a defined evidence-retention period before deletion.
Stop conditions
- Never use an AI-generated cleanup command without independently resolving and reviewing every target.
Finish line
Verification checklist
ss -lntp | grep -E '127\.0\.0\.1:(11434|8088)'Ollama and the bot listen only on loopback; no wildcard or public socket exists.curl --silent --output /dev/null --write-out '%{http_code}\n' -X POST http://127.0.0.1:8088/v1/chatThe unauthenticated request returns 401 without a model or tool call.cd /opt/ollama-operator && npm testUnknown names and extra arguments fail, while one fixed read-only probe respects timeout and output limits.curl --silent http://127.0.0.1:8088/metrics && tail -n 5 /var/lib/ollama-operator/audit.ndjsonMetrics and audit contain counts, IDs, outcomes, and approved tool names but no prompt, response, secret, or raw tool output.Recovery guidance
Common problems and safe checks
Ollama is unreachable on loopback.
Likely causeThe service is stopped, bound elsewhere, or blocked by a mismatched service override.
Check systemctl status ollama without changing it.Request /api/tags from 127.0.0.1 with a short timeout.
ResolutionRestore OLLAMA_HOST to loopback, reload the service, and verify the listener before restarting the bot.
The bot returns unauthorized for a valid-looking token.
Likely causeThe stored SHA-256 digest was generated from different bytes, includes a newline, or the caller uses the wrong authorization scheme.
Hash the presented token locally without printing it.Confirm the header begins with exactly Bearer and one space.
ResolutionGenerate a new random token, store only its lowercase digest, restart the bot, and revoke the old value.
The configured model is not found.
Likely causeThe model was never pulled locally, its tag differs, or an upgrade changed the configured alias.
Run ollama list and compare exact names.Query the OpenAI-compatible models endpoint locally.
ResolutionPull an approved model by exact tag, record the digest where available, and update the environment only after a harmless test.
Tool calls are never requested.
Likely causeThe chosen model lacks reliable tool calling, tools were disabled for the request, or the prompt does not require host evidence.
Confirm allowReadOnlyTools is true for a synthetic test.Inspect the model capability documentation and a content-free response shape.
ResolutionChoose a tool-capable model or keep tools disabled and present the probes as explicit operator actions.
A tool name is rejected by validation.
Likely causeThe model proposed an invented function or included arguments not allowed by the strict schema.
Record only the rejected function name and request ID.Run the allowlist unit test.
ResolutionTreat rejection as safe behavior; improve the system prompt or model but never widen the allowlist from model output.
A read-only probe times out.
Likely causeThe host command hung, system pressure is severe, or the process could not be killed promptly.
Run the exact documented executable and fixed argv manually as the service user.Inspect content-free timeout counters and journal metadata.
ResolutionDiagnose the host separately, retain the ten-second limit, and do not replace spawn with a shell.
Tool output exceeds 64 KiB.
Likely causeThe host has an unexpectedly large mount or socket inventory, or command behavior differs from the tested version.
Run the fixed probe through head only for diagnosis.Count output bytes without storing full content.
ResolutionReturn a clear truncation error, add a more selective fixed probe in code review, and preserve the output ceiling.
The bot exposes /metrics to other hosts.
Likely causeThe service bound to a wildcard address or a reverse proxy published the loopback endpoint.
Inspect ss -lntp and proxy listeners.Test access from a separate host without credentials.
ResolutionRestore BOT_HOST literal loopback, restrict the proxy, and treat previous metrics exposure as an incident if labels ever contained sensitive data.
Audit writes fail with permission denied.
Likely causeThe state directory ownership, ReadWritePaths, or UMask conflicts with the service account.
Inspect directory metadata and systemd sandbox errors.Test a zero-content append as ollama-bot.
ResolutionCorrect only /var/lib/ollama-operator ownership and keep ProtectSystem strict rather than broadening filesystem access.
Responses become slow after an upgrade.
Likely causeThe model tag, context, GPU runtime, or Ollama build changed, or host contention increased.
Compare ollama version, model list, and content-free latency percentiles.Inspect Ollama journal GPU selection without prompts.
ResolutionRoll back the recorded package or model tag, restore the prior environment, and rerun the synthetic acceptance set.
The API returns a draft but no second model answer after tool results.
Likely causeThis reference service intentionally returns bounded evidence separately instead of running an autonomous multi-turn tool loop.
Inspect toolResults and verify exit codes.Confirm the notice states no write action executed.
ResolutionHave the user review evidence and submit a new message, or implement a separately reviewed bounded second turn with strict call-count limits.
Backup restores audit data but the service will not start.
Likely causeThe environment secret, compiled code, model, service user, or directory permissions were not part of the state-only backup.
Validate the audit NDJSON independently.Compare package checksum and model tag with the recovery manifest.
ResolutionRestore code and configuration from release artifacts, recreate the token separately, pull the pinned model, then attach the recovered audit directory.
Reference
Frequently asked questions
Is the Ollama api_key authenticating the local server?
No. Ollama's OpenAI compatibility documentation shows an api_key value required by the client shape but ignored by Ollama. This service authenticates at its own boundary and keeps Ollama inaccessible remotely. A team deployment should add individual identity at a TLS gateway.
Why not let the model write a shell command?
A shell command is an executable program assembled from untrusted probabilistic output. Quoting errors, prompt injection, metacharacters, path changes, and invented flags can turn a harmless intention into broad access. The compiled mapping makes the model choose only among code-reviewed operations.
Are uptime, df, and ss always harmless?
They are non-mutating in this configuration, but their output can disclose host details and they still consume resources. Authorization, output limits, retention rules, and contextual human judgment remain necessary. Read-only means no intended state change, not no security impact.
Can we add a restart_service tool?
Not to this executor. Restarting is a consequential write that can cause downtime. Design a separate proposal-and-approval workflow with exact service allowlists, prechecks, authenticated approver identity, idempotency, timeout, post-verification, rollback, and audit before implementing it.
Why return tool results separately?
It preserves evidence exactly as collected and avoids an unbounded autonomous model-tool-model loop. The operator can review the exit code and bounded output before asking for interpretation. A later synthesis turn can be added with an explicit one-round limit and tests.
Can this be exposed on the internet?
Not as built. The loopback service lacks public TLS, per-user identity, robust authorization, distributed rate limiting, abuse response, and multi-tenant data controls. Keep it private or add a separately reviewed gateway and threat model without publishing Ollama directly.
What does the backup omit?
It omits prompts, responses, raw tool output, bearer token, and normally model weights. It preserves approved audit metadata and release evidence. Recovery installs the recorded application, pulls or restores the approved model separately, and creates a new client token.
How often should this guide be reviewed?
Review every ninety days and after changes to Ollama's OpenAI compatibility, tool-calling behavior, model tag, Node or OpenAI SDK, service sandbox, tool allowlist, authentication, gateway, audit schema, or backup process.
Recovery
Rollback
Rollback restores the previous immutable application release, unit, environment structure, and model tag while preserving a copy of new audit evidence. It always rotates the client token when exposure is possible and never deletes shared Ollama model storage as part of application rollback.
- Stop the bot and preserve the failed release logs, content-free audit segment, checksums, and environment key names without copying secret values into tickets.
- Point ExecStart or the release symlink back to the previous checksum-verified dist directory and restore the reviewed unit if it changed.
- Restore the previous exact model tag or pinned model artifact and confirm it exists through the local model endpoint.
- Generate a new random bearer token, replace only its digest in the root-owned environment, and distribute the token through the approved secret channel.
- Reload systemd, start the bot, then run unauthenticated rejection, authenticated no-tool chat, fixed read-only tool, metrics, and audit acceptance.
- Keep the failed release quarantined until root cause and retention decisions are complete; remove only exact dedicated paths after approval.
Evidence