Ollama Production Deployment: systemd, TLS, and API Auth

ollama production deployment diagram showing nginx TLS termination and API key auth in front of the Ollama service

Ollama production deployment means three things the official docs don’t cover: the default systemd unit runs Ollama unsandboxed, the reverse-proxy example in Ollama’s own FAQ has no TLS and no auth, and there’s no built-in authentication at all — anyone who reaches the port can call the API. None of that is a bug; it’s just out of scope for a quickstart. This closes the gap with the actual hardening steps.

This is provider-agnostic — VPS, bare metal, home server, any Linux box. If you’re specifically on a DigitalOcean droplet and haven’t gotten Ollama installed yet, the droplet quickstart covers that first step; this picks up after ollama serve is already working and you’re ready to expose it safely.

Hardening the systemd Service Beyond the Default Unit

Ollama’s installer already does the right first step: it creates a dedicated ollama system user (via useradd -r -s /bin/false) instead of running as root, and the default unit looks like this:

[Unit]
Description=Ollama Service
After=network-online.target

[Service]
ExecStart=/usr/bin/ollama serve
User=ollama
Group=ollama
Restart=always
RestartSec=3
Environment="PATH=$PATH"

[Install]
WantedBy=multi-user.target

That’s a reasonable baseline, not a hardened one — the gap between “Ollama runs” and “Ollama production-ready” starts right here. systemd.exec supports sandboxing directives this unit doesn’t use at all, and they cost nothing at runtime. Add these to an override (sudo systemctl edit ollama, which creates /etc/systemd/system/ollama.service.d/override.conf without touching the original file):

[Service]
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
ReadWritePaths=/usr/share/ollama/.ollama

Each one closes a specific hole: NoNewPrivileges stops the process from gaining privileges through a setuid binary even if one gets planted. ProtectSystem=strict makes the entire filesystem read-only except the API filesystems (/dev, /proc, /sys), so ReadWritePaths is what carves out the one directory Ollama actually needs to write models and config into — without it, model pulls fail outright. PrivateDevices strips access to physical devices, keeping only harmless ones like /dev/null. None of this is Ollama-specific; it’s the same sandboxing you’d add to any long-running network service, and Ollama’s default unit simply doesn’t ship with it.

Important: apply this incrementally and check systemctl status ollama after each addition. ProtectSystem=strict is the one most likely to break something if you’ve moved model storage outside the default path — ReadWritePaths has to list every directory Ollama writes to, or the service fails silently on the next model pull.

Ollama Production Traffic Belongs Behind Nginx, Not Facing the Internet Directly

Ollama’s own FAQ gives the minimal proxy example, and it’s worth starting from since it’s the maintainers’ own baseline:

server {
    listen 80;
    server_name example.com;
    location / {
        proxy_pass http://localhost:11434;
        proxy_set_header Host localhost:11434;
    }
}

That’s HTTP-only, with no access control — fine for confirming the proxy works, not for anything reachable from outside your own machine. Two things need adding before this is production-shaped: TLS, and something checking who’s allowed to call the API at all.

Leave OLLAMA_HOST at its default (127.0.0.1:11434, loopback-only) rather than setting it to 0.0.0.0 to “expose Ollama on the network” the way some guides suggest. Nginx is what should face the network; Ollama itself has no reason to listen on anything but localhost once a reverse proxy is in front of it.

Adding TLS with Let’s Encrypt

certbot handles the certificate and the nginx config edit together:

sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d example.com

certbot rewrites the server block to listen on 443 with the issued certificate and adds an HTTP→HTTPS redirect on port 80 automatically. Certificates renew via a systemd timer certbot installs for you — systemctl list-timers | grep certbot confirms it’s active. No cron job to remember, no manual renewal.

The Real Blocker to Ollama Production Deployments: No Built-In Auth

Ollama has no authentication layer at all. Every endpoint — /api/generate, /api/pull, /api/delete — answers anyone who can reach the port, and /api/pull will happily fill your disk with whatever model a caller names. Auth has to live in nginx, since it isn’t coming from Ollama itself:

server {
    listen 443 ssl;
    server_name example.com;

    location / {
        if ($http_x_api_key != "your-long-random-key-here") {
            return 401;
        }
        proxy_pass http://127.0.0.1:11434;
        proxy_set_header Host localhost:11434;
    }
}

⚠️ Note: nginx’s if inside a location block is famously unreliable for anything beyond a single simple condition — it’s fine for one static-key comparison like this, but don’t stack multiple if blocks in the same location expecting them to compose predictably. For more than one key, or expiring keys, move the check into an auth_request subrequest or a small auth service instead of nesting more if logic.

Generate the key with openssl rand -hex 32, put it in the nginx config (or better, an included file outside version control), and every client sends it as X-API-Key: <key> on each request. This is deliberately simple — a shared key, not per-user accounts — and that’s the right amount of complexity for a single-operator or small-team deployment. Don’t build a JWT auth layer for a service with three callers.

Logs and Monitoring: What journald Already Gives You

Ollama running as a systemd service logs to journald by default, not to a plain file — so classic logrotate doesn’t apply here at all, and reaching for it is a wasted step. journalctl -e -u ollama tails the current log. Retention is controlled centrally in /etc/systemd/journald.conf:

[Journal]
SystemMaxUse=500M
MaxRetentionSec=1month

SystemMaxUse caps total disk space the journal can consume; MaxRetentionSec deletes entries past that age regardless of size. Set both — space caps alone can still let ancient, useless entries linger if the service is quiet, and age caps alone don’t protect against a sudden burst of log volume filling the disk. Reload with sudo systemctl restart systemd-journald after editing.

For anything beyond “is it running and how big are the logs,” journalctl -u ollama -p err filters to just error-level entries — the fastest way to check if a production Ollama instance has been quietly failing model loads without anyone noticing.

Frequently Asked Questions

Q: Does Ollama support API keys or user accounts natively?
A: No. Ollama has no authentication of any kind built in — every API endpoint is open to anything that can reach the port. Auth has to be added at the reverse-proxy layer, as shown above, not configured in Ollama itself.

Q: Should OLLAMA_HOST be set to 0.0.0.0 for a production deployment?
A: No — leave it at the default 127.0.0.1:11434 and let nginx be the only thing listening on a public interface. Binding Ollama itself to 0.0.0.0 skips the proxy entirely and exposes the unauthenticated API directly.

Q: Do I need logrotate for Ollama’s logs?
A: No, not if it’s running as a systemd service — those logs go to journald, which has its own retention controls (SystemMaxUse, MaxRetentionSec in journald.conf). logrotate only matters if you’ve redirected Ollama’s output to a plain file yourself.

Q: Is a shared API key enough security, or do I need real user accounts?
A: For a single operator or small team, a shared key checked in nginx is a reasonable, low-maintenance amount of security. Move to per-user tokens or auth_request-based auth only once you have actual multiple distinct callers who need separate access or revocation.

Quick Summary — what an Ollama production deployment actually needs beyond ollama serve:
– Ollama’s default systemd unit isn’t sandboxed — add ProtectSystem=strict, PrivateTmp, NoNewPrivileges, and friends via systemctl edit, plus a ReadWritePaths entry for the model directory
– Keep OLLAMA_HOST at the loopback default; let nginx be the only thing facing the network
– certbot --nginx handles TLS issuance and auto-renewal in two commands
– Ollama has zero built-in auth — an X-API-Key header check in nginx is the minimum viable fix
– Ollama logs to journald by default, not a file — logrotate doesn’t apply; set SystemMaxUse and MaxRetentionSec in journald.conf instead

For sizing the box this all runs on, see Ollama hardware requirements. For the full API surface being protected here, the Ollama API guide covers every endpoint in detail.

Related guides