Your Ollama API has no password
The Ollama server ships with no authentication, so anything that reaches port 11434 can run your models and pull new ones. The three fixes, in order.
The Ollama API has no password
The Ollama API has no authentication. There is no user, no password, no key check and no allowlist anywhere in the server you run. Anything that can open a TCP connection to port 11434 can list your models, run them, download new ones and delete the ones you have.
The official documentation states it plainly: "No authentication is required when accessing Ollama's API locally via http://localhost:11434." The word locally carries the entire security model. Ollama binds to 127.0.0.1 by default, so on a laptop the loopback interface is the access control. Move that listener onto a public address and the access control is gone, because nothing replaced it.
That is why this matters on a VPS (virtual private server). The default is safe. The first change most people make, opening the listener so a second machine can use the model, is the change that removes every protection at once.
What an open port 11434 gives away
Every endpoint. There is no read-only mode and no separate admin port. These are the real requests, aimed at a server address instead of localhost:
# List every model on the box
curl http://SERVER_IP:11434/api/tags
# See what is loaded into memory right now
curl http://SERVER_IP:11434/api/ps
# Run a prompt on your hardware
curl http://SERVER_IP:11434/api/generate -d '{"model":"llama3.2","prompt":"Why is the sky blue?"}'
# Write several gigabytes to your disk
curl http://SERVER_IP:11434/api/pull -d '{"model":"llama3.2"}'
# Remove a model
curl -X DELETE http://SERVER_IP:11434/api/delete -d '{"model":"llama3.2"}'In operator terms, four things go wrong:
- Your CPU or GPU runs inference for someone else. On a plan with a fair-use CPU allowance, sustained load is your allowance being spent by a stranger, and keeping AI workload costs under control on a VPS gets much harder once you are not the only caller.
/api/pullwrites to your disk. Models run from two to forty gigabytes each. A loop of pulls fills the volume, and a full disk breaks every other service on the box, not only Ollama.- Requests arrive inside your process and get logged. At the default log level Ollama records metadata only, so you get the endpoint, the status, the latency and the client address, not the prompt text. That is still a record of who used your box and for what, sitting in your journal, and you did not choose to collect it.
/api/deleteremoves models. Getting them back means downloading them again over your own bandwidth.
None of this needs an exploit. It is the documented API behaving exactly as designed.
The Ed25519 key is not an access control
Search for "Ollama API key" and you land on two different things. Neither one is a password for your server, and sorting them apart removes most of the confusion.
The first is the identity key pair. Ollama generates an Ed25519 key pair on first run. On Linux the install script creates a system user named ollama with its home directory at /usr/share/ollama, so the pair lives here:
/usr/share/ollama/.ollama/id_ed25519
/usr/share/ollama/.ollama/id_ed25519.pubThat key points outward. ollama signin registers the public half with your ollama.com account, and it is what authorises you to push a model to the registry or pull a private one. It proves your machine to ollama.com. It asks nothing of the clients connecting to your machine. Deleting it, rotating it or never creating it changes nothing about who may call your API.
The second is OLLAMA_API_KEY. That variable holds a key you create at https://ollama.com/settings/keys, and your client sends it as Authorization: Bearer $OLLAMA_API_KEY when calling the hosted API at https://ollama.com/api. It is a credential for their service, used by you as the client. Your own ollama serve never reads it. Setting OLLAMA_API_KEY on your VPS does not put a password on your VPS.
So there is no setting to switch on. The three defences below all work the same way: keep the port unreachable, and put something in front of it that does check.
Check what your server is listening on right now
sudo ss -tlnp | grep 11434The safe result names the loopback address:
LISTEN 0 4096 127.0.0.1:11434 0.0.0.0:* users:(("ollama",pid=812,fd=3))The exposed result names every interface:
LISTEN 0 4096 0.0.0.0:11434 0.0.0.0:* users:(("ollama",pid=812,fd=3))0.0.0.0 means all IPv4 addresses on the box, including the public one. *:11434 and [::]:11434 mean the same thing with IPv6 included.
Now confirm from outside. Run this on your laptop, not on the server:
curl -m 5 http://YOUR_SERVER_IP:11434/api/versioncurl: (28) Connection timed out after 5001 milliseconds is the answer you want, and so is curl: (7) Failed to connect ... Connection refused. A JSON object carrying a version field means the whole API is reachable by anyone who asks. Testing with curl on the server itself proves nothing, because loopback always answers.
Exposure usually arrives in one of two ways. The first is a deliberate edit, because someone needed a second machine to reach the model:
sudo systemctl edit ollama.service[Service]
Environment="OLLAMA_HOST=0.0.0.0:11434"That single line is the whole exposure. The second way is Docker, and it does not ask you to edit anything at all. That one has its own section below.
Defence 1: keep it on localhost and tunnel in
Reach for this first. It needs no new software and it creates no credential that can leak. The port never exists on a public interface, so scanning cannot find it.
Set the bind address explicitly instead of relying on the default:
sudo systemctl edit ollama.service[Service]
Environment="OLLAMA_HOST=127.0.0.1:11434"That writes /etc/systemd/system/ollama.service.d/override.conf. Apply it and check:
sudo systemctl daemon-reload
sudo systemctl restart ollama
sudo ss -tlnp | grep 11434ss should now show 127.0.0.1:11434. If it still shows 0.0.0.0, a second drop-in file is winning. Run systemctl cat ollama.service to list the unit and every drop-in with its path, then delete the stale one.
To use the model from your laptop, forward the port over SSH:
ssh -N -L 11434:127.0.0.1:11434 you@your-server-L 11434:127.0.0.1:11434 opens port 11434 on your laptop and sends anything arriving there to 127.0.0.1:11434 as seen from the server. -N tells SSH not to run a remote command, so the process just holds the tunnel open. While it runs, this works on your laptop:
curl -s http://localhost:11434/api/tagsTwo failures you will meet. bind [127.0.0.1]:11434: Address already in use means your laptop is running its own Ollama on that port, so choose a different local port with -L 11500:127.0.0.1:11434 and aim your client at 11500. An empty reply through a tunnel that connected fine means SSH is working and Ollama is not listening on the server side, so check ss there before you touch the SSH command.
For several client machines, a private network beats one tunnel per person. Put the machines on WireGuard or Tailscale, then bind Ollama to its address on that network instead of to 0.0.0.0:
[Service]
Environment="OLLAMA_HOST=10.8.0.1:11434"The port then exists only on an interface you need a key to join. This also survives a firewall mistake, because a rule that accidentally allows the world still cannot expose a listener that the public interface does not hold.
Defence 2: a reverse proxy that checks a bearer token
When something on the public internet must call the model, keep Ollama on loopback and put a proxy in front of it. The proxy terminates TLS (transport layer security) and rejects requests without the right header. Ollama still accepts connections only from 127.0.0.1, so the proxy is the only route in.
Generate a real token first. Do not invent one by hand:
openssl rand -base64 36An nginx site that checks it:
map $http_authorization $ollama_ok {
default 0;
"Bearer PASTE_YOUR_GENERATED_TOKEN_HERE" 1;
}
server {
listen 443 ssl;
server_name llm.example.com;
ssl_certificate /etc/letsencrypt/live/llm.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/llm.example.com/privkey.pem;
location = /api/pull { return 403; }
location = /api/delete { return 403; }
location = /api/push { return 403; }
location / {
if ($ollama_ok = 0) { return 401; }
proxy_pass http://127.0.0.1:11434;
proxy_set_header Host 127.0.0.1:11434;
proxy_buffering off;
proxy_read_timeout 600s;
}
}Five lines there are doing real work, and each one prevents a failure you would otherwise hit.
if inside a location block is usually a bad idea in nginx, but a body of exactly return is one of the two forms that behave predictably, so this use is safe.
location = /api/pull is an exact match, and nginx ranks exact matches above the location / prefix, so those three endpoints are refused before the token is even considered. A valid token then buys inference, not the ability to fill your disk.
proxy_set_header Host 127.0.0.1:11434; matters because Ollama inspects the incoming Host and Origin headers. Passing the proxy's public hostname straight through can produce a 403 Forbidden that came from Ollama rather than from nginx, which is confusing to debug. OLLAMA_ORIGINS is the other lever, for a browser client that needs a specific origin allowed.
proxy_buffering off; matters because Ollama streams its response token by token. With buffering on, nginx holds the stream and delivers it in one piece at the end, so your client looks frozen for the whole generation.
proxy_read_timeout 600s; matters because nginx defaults to 60 seconds. A long generation on CPU passes that easily, the client gets 504 Gateway Time-out, and /var/log/nginx/error.log records upstream timed out (110: Connection timed out) while reading response header from upstream. The request was still working. nginx gave up on it.
Reload and test both paths:
sudo nginx -t && sudo systemctl reload nginx
curl -s -o /dev/null -w '%{http_code}\n' https://llm.example.com/api/tags
curl -s -H "Authorization: Bearer YOUR_TOKEN" https://llm.example.com/api/tagsThe first should print 401. The second should print your model list. If the first one also returns the model list, the map block is in the wrong scope. It belongs at http level, so put it in a file under /etc/nginx/conf.d/ or above the server block, never inside server.
Caddy does the same job with basic authentication in four lines, which suits a browser client better than a bearer token does:
llm.example.com {
basic_auth {
apiuser PASTE_BCRYPT_HASH_HERE
}
reverse_proxy 127.0.0.1:11434
}Run caddy hash-password to produce the bcrypt hash it expects. One naming trap: the directive was basicauth before Caddy v2.8 and is basic_auth now, so a config copied from an older guide refuses to load and Caddy names the directive it did not recognise.
Whichever proxy you pick, this is one shared secret for everyone. Every client holding it has identical access, and revoking it means editing the config and updating every caller at the same time.
Defence 3: a gateway that issues keys per client
Once more than one person or application calls the model, a shared token runs out of road. You cannot tell which client caused the load, and you cannot cut one of them off without cutting off all of them. A gateway sits where the proxy sat, speaks the same OpenAI-compatible API, issues a separate key per client and records what each key used. A self-hosted LiteLLM gateway is the usual answer, and it adds per-key budgets and request logs on top of the access control.
The rule from defence 1 does not change. Ollama binds to 127.0.0.1, the gateway is the only process that talks to it, and the gateway is the only service with a public listener. A gateway on a box where port 11434 is still open to the world is decoration, because callers can simply go around it.
The firewall trap: a published container port skips UFW
This is why exposed instances exist on servers whose owners configured a firewall correctly.
UFW (uncomplicated firewall) writes its rules into the INPUT chain of the kernel's filter table, and INPUT handles packets addressed to the host itself. Docker's -p flag writes a destination NAT (network address translation) rule into the PREROUTING chain of the nat table, which the kernel evaluates before it decides where the packet is going. By the time the routing decision happens, the destination has already been rewritten to the container's address, so the packet is forwarded rather than delivered locally and it traverses FORWARD instead of INPUT. UFW's INPUT rules are never consulted, so the packet goes around the firewall rather than through it.
That is why this sequence leaves port 11434 open to the internet:
sudo ufw default deny incoming
sudo ufw enable
docker run -d -v ollama:/root/.ollama -p 11434:11434 --name ollama ollama/ollamaand sudo ufw status still reports the firewall active with a default deny. Both readings are correct at the same time, which is exactly why people trust the wrong one. You can see the rule that did it:
sudo iptables -t nat -L DOCKER -nThe fix is one address in the publish flag:
docker rm -f ollama
docker run -d -v ollama:/root/.ollama -p 127.0.0.1:11434:11434 --name ollama ollama/ollama-p 11434:11434 is shorthand for -p 0.0.0.0:11434:11434. Naming 127.0.0.1 binds the host side of the mapping to loopback, so your SSH tunnel and your reverse proxy still reach it and the internet cannot. Recreating the container is safe here because the models live in the named ollama volume, not inside the container.
Confirm that both views agree:
docker port ollama
sudo ss -tlnp | grep 11434docker port ollama should print 11434/tcp -> 127.0.0.1:11434. If it prints 0.0.0.0:11434 you are still exposed. Learn the mechanism once and it applies to every container you ever publish: why Docker published ports bypass UFW covers the DOCKER-USER chain and the rules that survive a Docker restart. If you are still building the host policy itself, the UFW rules a new VPS needs covers the base this sits on.
Who is the process running as
The Linux install script creates a dedicated account and runs the service under it:
useradd -r -s /bin/false -U -m -d /usr/share/ollama ollamaThe unit at /etc/systemd/system/ollama.service then sets User=ollama and Group=ollama. Leave that alone. A quick ollama serve started by hand in a terminal runs as whoever you logged in as, and if that is root then an unauthenticated API is writing files as root. Check which it is:
ps -o user= -C ollamaThe answer should be ollama. Anything else means a hand-started process is running alongside or instead of the unit. The same thinking applies to every daemon you add later, and running services as least-privilege users works through it properly.
How to check the Ollama API endpoint is secure
Whatever you chose, one test settles it, and it has to run from another machine:
curl -m 5 http://YOUR_SERVER_IP:11434/api/version
curl -m 5 http://YOUR_SERVER_IP:11434/api/tagsBoth should time out or be refused. If you built a proxy, the same two paths on the proxy hostname should return 401 without credentials and real JSON with them.
Then read the access log once, because it tells you whether anyone found the port while it was open:
journalctl -u ollama --since "-30 days" | grep GIN | grep -v 127.0.0.1Ollama writes one line per request and includes the client address:
[GIN] 2026/08/12 - 14:01:10 | 200 | 103.965898ms | 127.0.0.1 | POST "/api/generate"Every line should show 127.0.0.1 once Ollama is bound to loopback, because that is the only address a connection can arrive from. A public address in that column is a request from outside, and the timestamp tells you when. No output at all from that command is the result you want. If the model side of this is new to you, running Ollama on a VPS covers the install, model sizing and the memory limits that decide what will actually load.
FAQ
Does Ollama have an API key or a password?
No. The server you run has no authentication of any kind, and the official documentation states that no authentication is required to reach the API. Both things called an "Ollama API key" point the other way. The Ed25519 pair in /usr/share/ollama/.ollama/ proves your machine to ollama.com so you can push models and pull private ones. OLLAMA_API_KEY is a credential your client sends to the hosted API at https://ollama.com/api. Your own ollama serve reads neither one, so access control has to come from the network or from a proxy in front.
Is OLLAMA_HOST=0.0.0.0 safe if I have a firewall?
Only while nothing else writes firewall rules on that box. 0.0.0.0 means the listener really exists on the public interface, and you are trusting the firewall alone to keep it unreachable. That trust breaks the moment Docker publishes a port, because the DNAT rule Docker adds to the nat table is evaluated before the packet would reach the INPUT chain where UFW lives, so the packet is forwarded and UFW never sees it. Binding to 127.0.0.1 or to a private tunnel address removes the listener from the public interface, so a firewall mistake has nothing left to expose.
How do I check whether my Ollama port is open to the internet?
Run sudo ss -tlnp | grep 11434 on the server, and curl -m 5 http://YOUR_SERVER_IP:11434/api/version from a different machine. ss showing 127.0.0.1:11434 and the remote curl timing out is the pair of answers you want. ss showing 0.0.0.0:11434 or *:11434 while the remote curl returns JSON means the full API is reachable. Never test with curl on the server itself, because loopback answers whatever the bind address happens to be.
Can I just move the port from 11434 to something random?
No, and the reason is worth stating. A different port slows down nothing except a scan of one single port. Scanners walk the whole range, and one request to /api/tags identifies the service whatever port it arrived on. Moving the port also breaks every client default and makes your own setup harder to reason about later. Bind to loopback instead, which removes the listener rather than relocating it.
Someone reached my open Ollama. What should I check?
Bind it to 127.0.0.1 and restart the service first, so the exposure stops before you start investigating. Then run journalctl -u ollama --since "-30 days" | grep GIN | grep -v 127.0.0.1 to see which outside addresses called which endpoints and when. Compare ollama list against the models you meant to have, since /api/pull is unauthenticated and a model you did not pull is both disk usage and evidence. Check free space with df -h. Ollama does not record prompt text at the default log level, so you have a record of who asked and for which model, not of what was generated.