Ollama has no login, so securing an Ollama API server on a VPS means putting something in front of it that does. The pattern is short: keep Ollama on 127.0.0.1, let Caddy handle HTTPS, and have Caddy return 401 to any request that does not carry your secret key. Your own scripts, n8n and apps then call one HTTPS URL with a Bearer token.
Disclosure: the Hostinger and AICredits links in this post are affiliate links. I ran the Caddy configuration with Caddy v2.10.0 on 2026-10-23 against a tiny stand-in server that pretends to be Ollama. I did not run a real Ollama model, a real domain with a certificate, or any Hostinger server.
How is this different from the other Ollama posts here?
The private ChatGPT sizing post is about picking a server and putting Open WebUI on top of Ollama for people. The gateway post adds LiteLLM for per-person keys and multiple backends. This one is the minimal thing: one shared secret, one proxy, programmatic access, no UI and no extra services. If you need several users with separate keys and budgets, use the gateway post instead.
What does Ollama expose by default?
The Ollama FAQ (read 2026-10-23) says: "Ollama binds 127.0.0.1 port 11434 by default," and that you change it with the OLLAMA_HOST environment variable. It also says Ollama can be exposed through a proxy such as Nginx. Its example just forwards to http://localhost:11434 and includes no access control, and I found no authentication described for the local server.
That means two things. Never set OLLAMA_HOST=0.0.0.0 on a public VPS just to make a remote client work, because anyone who reaches the port can list models, pull new ones and run generation on your CPU. And the Ollama default is already the right one for this setup, so you change nothing on the Ollama side.
Check what it is listening on:
ss -ltnp | grep 11434
You want 127.0.0.1:11434, not 0.0.0.0:11434 or *:11434. If a previous experiment set OLLAMA_HOST, remove it from the systemd override (systemctl edit ollama.service) and restart.
Which server do you need?
A VPS that can hold your model in RAM. Model size drives everything, and the sizing post above has the tables. For a small model on CPU, Hostinger KVM 2 (2 vCPU, 8 GB RAM, 100 GB disk, $8.79 on a term and $14.99 on renewal, checked 2026-10-23) is the entry point, and KVM 4 (4 vCPU, 16 GB, $12.99, renewing at $28.99) is the sensible step up for 7B to 8B class models. Plans are paid upfront for a term. Hetzner and DigitalOcean are non-affiliate alternatives with similar specs (comparison). Whatever you pick, apply the VPS hardening checklist first: SSH keys, a firewall allowing only 22, 80 and 443, and automatic updates.
How do you generate and store the API key?
Make a long random string. It is a password, so treat it like one.
openssl rand -hex 32
Put it in an environment variable that the Caddy service reads, not in the Caddyfile you commit anywhere. On a systemd-managed Caddy, run systemctl edit caddy and add:
[Service]
Environment="OLLAMA_API_KEY=paste-the-64-hex-characters-here"
Caddy's docs describe two kinds of placeholders. {$VAR} is substituted before the Caddyfile is parsed, and {env.VAR} is resolved at runtime only where a module supports it. The header matcher below is a plain string comparison, so I used {$OLLAMA_API_KEY}, the parse-time form, and that is what I tested.
What does the Caddyfile look like?
Replace ollama.example.com with a domain whose DNS A record points at your VPS. Caddy gets and renews the HTTPS certificate automatically for a real domain, which I could not test here.
ollama.example.com {
@authorized header Authorization "Bearer {$OLLAMA_API_KEY}"
handle @authorized {
reverse_proxy 127.0.0.1:11434 {
flush_interval -1
}
}
handle {
respond "Unauthorized" 401
}
}
How it works. The header matcher compares the Authorization request header; per Caddy's docs, a value with no wildcard * is an exact match. Requests that match go to Ollama. Anything else falls into the second handle block and gets a 401 without ever touching Ollama. flush_interval -1 is there because Caddy's docs say reverse_proxy partially buffers responses by default, and a negative value disables buffering, which matters for token-by-token streaming.
Does it actually reject bad requests?
I tested the rules with Caddy v2.10.0 on 2026-10-23. To avoid needing a domain and certificate, I used :8081 with auto_https off instead of the domain block, and a 15-line Python server on 127.0.0.1:11434 that answers GET with {"models": [{"name": "fake:1b"}]}. The authorization logic is the same. The key was testkey123 in the environment.
Real output, with log lines trimmed:
Valid configuration
--- no key
401
--- wrong key
401
--- right key
{"models": [{"name": "fake:1b"}]}
200
So the three cases behave as intended. This does not prove anything about Ollama itself, TLS, or long streaming responses.
How do you test it with curl on your server?
After Caddy reloads (sudo systemctl reload caddy), try the real thing. /api/tags is the documented "list local models" endpoint (a GET).
curl -i https://ollama.example.com/api/tags
curl -i -H "Authorization: Bearer wrong" https://ollama.example.com/api/tags
curl -i -H "Authorization: Bearer $OLLAMA_API_KEY" https://ollama.example.com/api/tags
Expect 401, 401, then 200 with your model list. Then confirm Ollama is not reachable directly from your laptop with curl -m 5 http://YOUR_VPS_IP:11434/api/tags, which should time out or refuse. If that returns models, your Ollama is still bound to a public interface.
How do apps and scripts call it?
Ollama's OpenAI compatibility docs give http://localhost:11434/v1/ as the local base URL and note that the OpenAI client requires an API key value but Ollama ignores it. That is useful here: the client sends your key as a Bearer token, and Caddy is what checks it. In Python, that should look like this. It is an untested sketch, since I had no real Ollama running:
from openai import OpenAI
client = OpenAI(
base_url="https://ollama.example.com/v1",
api_key="your-64-hex-key",
)
resp = client.chat.completions.create(
model="llama3.2",
messages=[{"role": "user", "content": "Say hello in five words."}],
)
print(resp.choices[0].message.content)
The model name must be one you have pulled. Keep the key in an environment variable in your own app too. The same URL and header work from n8n's HTTP Request node.
What are the limits of a single shared key?
Be honest about it. This is one password for everything:
- No per-person revocation. If a key leaks, you rotate it for everyone: generate a new one, update the systemd override, restart Caddy, update clients.
- No rate limits or usage logs per user. Anyone with the key can keep your CPU busy. The gateway post covers per-key budgets.
- I did not check how Caddy's access log treats the Authorization header. If you turn on request logging, check what lands in the log file before keeping it.
- Do not send the key over plain HTTP. Use the HTTPS domain only. Block port 11434 and keep 80 only for certificate issuance and redirects.
- Comparison timing. I did not verify whether Caddy's header match is constant-time. With a 64-character random key and the internet's latency noise this is a low risk, but it is a reason to use a VPN for anything sensitive. Tailscale lets you skip the public endpoint entirely.
When should you use a hosted API instead?
If your CPU-only VPS answers in 20 seconds and you need quality that a small local model cannot give, a hosted model is the better tool. To pay in INR by UPI, AICredits offers an OpenAI-compatible base URL at https://api.aicredits.in/v1, with a Rs 50 minimum top-up (check their site for current terms). It is an affiliate link; the non-affiliate route is paying the model vendor directly. The same OpenAI client code above works with either by changing base_url. For the broader local-model picture see the Ollama beginners guide.
What I did not verify
I did not run a real Ollama, a real domain or certificate, long streaming responses through Caddy, or the OpenAI client against this proxy. The Caddy rule logic and the 401/200 behavior ran as shown. Ollama defaults, the compatibility notes and Caddy placeholder and buffering behavior come from their docs, read on 2026-10-23.



