Ollama in Docker Compose with GPU and Persistent Model Storage

Ollama works great on bare metal. It gets even more interesting when you treat it like a service: a stable endpoint, pinned versions, persistent storage, and a GPU that is either available or it is not.

This post focuses on one goal: a reproducible local or single-node Ollama “server” using Docker Compose, with GPU acceleration and persistent model storage.

It intentionally skips generic Docker and Compose basics. When you need a compact list of the commands you reach for most often (images, containers, volumes, docker compose), the Docker Cheatsheet is a good companion.

When you want HTTPS in front of Ollama, correct streaming and WebSocket proxying, and edge controls (auth, timeouts, rate limits), see Ollama behind a reverse proxy with Caddy or Nginx for HTTPS streaming.

For how Ollama fits alongside vLLM, Docker Model Runner, LocalAI, and cloud hosting trade-offs, see
LLM Hosting in 2026: Local, Self-Hosted & Cloud Infrastructure Compared.

When Compose beats a bare metal install

A native install is frictionless for one developer on one machine. The moment you have any of the following, Compose starts to win on ergonomics:

A team setup benefits because the service definition is a file you can review, version, and share. A single-node server benefits because upgrades turn into an image tag bump and a restart, while your model storage stays put (as long as it is on a volume). Ollama also tends to live next to sidecars: a Web UI, a reverse proxy, an auth gateway, a vector DB, or an agent runtime. Compose is good at “one command to start the whole stack”, without turning your host into a snowflake.

This approach aligns well with how the official Ollama container is designed: the image runs ollama serve by default, exposes port 11434, and is meant to keep state under a mountable directory.

A Compose skeleton that is actually useful for Ollama

Start with two decisions:

First, how you will pin versions. The Docker Hub image is ollama/ollama, so you can pin a specific tag in .env instead of relying on latest.

Second, where the model data will live. The official docs mount a volume to /root/.ollama so models are not re-downloaded each time the container is replaced.

Here is a Compose file that bakes those decisions in, and keeps the “knobs” close to the service:

services:
  ollama:
    image: ollama/ollama:${OLLAMA_IMAGE_TAG:-latest}
    container_name: ollama
    restart: unless-stopped

    # Keep it local by default, expose it later if you need to.
    ports:
      - "${OLLAMA_BIND_IP:-127.0.0.1}:11434:11434"

    # Persistent models and server state.
    volumes:
      - ollama:/root/.ollama

    environment:
      # The official image already defaults to 0.0.0.0:11434 inside the container,
      # but keeping it explicit helps when you override things later.
      - OLLAMA_HOST=0.0.0.0:11434

      # Service tuning.
      - OLLAMA_KEEP_ALIVE=${OLLAMA_KEEP_ALIVE:-5m}
      - OLLAMA_NUM_PARALLEL=${OLLAMA_NUM_PARALLEL:-1}
      - OLLAMA_MAX_LOADED_MODELS=${OLLAMA_MAX_LOADED_MODELS:-1}

      # Optional, but relevant when a browser-based UI talks to Ollama directly.
      # See the Networking section for why this exists.
      - OLLAMA_ORIGINS=${OLLAMA_ORIGINS:-}

    # GPU reservation is a separate section below.
    # Add it only on hosts that actually have NVIDIA GPUs.

volumes:
  ollama: {}

A matching .env keeps upgrades boring:

# Pin the image version you have tested.
OLLAMA_IMAGE_TAG=latest

# Local by default. Change to 0.0.0.0 when you intentionally expose it.
OLLAMA_BIND_IP=127.0.0.1

# Keep-alive tweaks cold-start latency vs memory footprint.
OLLAMA_KEEP_ALIVE=5m

# Concurrency knobs.
OLLAMA_NUM_PARALLEL=1
OLLAMA_MAX_LOADED_MODELS=1

# Leave empty unless you are serving browser clients that hit Ollama directly.
OLLAMA_ORIGINS=

A small but important nuance: Ollama itself has a default host bind of 127.0.0.1:11434 in the general configuration, but the official container image sets OLLAMA_HOST=0.0.0.0:11434 so the service is reachable through published ports.

If you want a quick sanity check without involving any client SDKs, the Ollama API includes a “list local models” endpoint at GET /api/tags.

Persistent model storage and the least painful way to move it

If you only remember one thing, make it this: the container must have persistent storage, otherwise every rebuild is a re-download.

Ollama lets you choose the models directory using OLLAMA_MODELS. In the reference implementation, the default is $HOME/.ollama/models, and setting OLLAMA_MODELS overrides that.

Inside the official Docker image, $HOME maps naturally to the /root layout used by the documented volume mount (/root/.ollama), which is exactly why the official docker run examples mount that directory.

There are two storage patterns that tend to work well in practice:

A named Docker volume is simplest and portable. It is also easy to accidentally orphan, so it is worth naming it intentionally (for example ollama) and keeping it stable across Compose refactors.

A bind mount to a dedicated disk is better when model sizes start to dominate your root filesystem. In that case, you either mount the whole /root/.ollama to that disk, or you mount a custom directory and point OLLAMA_MODELS at it.

If you are actively reorganising storage, this is where an explicit “move models” playbook helps. See: move-ollama-models .

NVIDIA GPU support with Compose and the NVIDIA Container Toolkit

Ollama can use NVIDIA GPUs in Docker, but the image cannot magic a GPU into existence. The host needs working NVIDIA drivers and the NVIDIA Container Toolkit, and Docker must be configured to use it. The Ollama Docker docs explicitly call out installing nvidia-container-toolkit, configuring the runtime via nvidia-ctk runtime configure --runtime=docker, and restarting Docker.

On the Compose side, the clean, modern way is device reservations. Docker documents GPU access in Compose using deploy.resources.reservations.devices, with capabilities: [gpu], driver: nvidia, and either count (including all) or device_ids.

Add this to the ollama service when you are on an NVIDIA host:

deploy:
  resources:
    reservations:
      devices:
        - driver: nvidia
          count: all
          capabilities: [gpu]

If you have multiple GPUs and want to keep Ollama on specific devices, switch from count to device_ids as documented by Docker (they are mutually exclusive).

You will sometimes see legacy Compose examples that use runtime: nvidia. That can fail on newer setups with errors like “unknown or invalid runtime name: nvidia”, which is a strong hint that you should move to the supported device reservation pattern and make sure the toolkit is configured on the host.

A useful detail hiding in plain sight: the official ollama/ollama image sets NVIDIA_VISIBLE_DEVICES=all and NVIDIA_DRIVER_CAPABILITIES=compute,utility. These are standard knobs recognised by the NVIDIA container runtime, and they are already present unless you overwrite them.

To confirm whether you are actually getting GPU inference (not just a container that starts), Ollama recommends using ollama ps and checking the “Processor” column, which shows whether the model is on GPU memory.

Platform reality check: Ollama notes that GPU acceleration in Docker is available on Linux (and Windows with WSL2), and not available on Docker Desktop for macOS due to the lack of GPU passthrough.

Networking choices: host vs bridge, ports, and CORS

Networking is where most “it runs but my app cannot connect” bugs come from.

Bridge networking with published ports

The default Compose network is a bridge network. In this setup, publishing 11434:11434 makes Ollama reachable from the host on port 11434, while other containers should talk to it using the service name ollama (not localhost). A lot of people trip on this because localhost inside a container means “this container”, not “the Ollama container”.

Ollama itself runs an HTTP server on port 11434 (the image exposes it), and the common convention is that clients use http://localhost:11434 on the host when ports are published.

Host networking

network_mode: host can be tempting on a single-node server because it removes port publishing and makes localhost semantics simpler. The trade-off is you lose the isolation and namespacing benefits of a bridge network, and you are more likely to hit port conflicts.

Exposing Ollama intentionally

Ollama on a normal install binds to 127.0.0.1 by default, and the documented way to change the bind address is OLLAMA_HOST.

In Docker, you have two layers:

Ollama bind address, controlled by OLLAMA_HOST (the container image defaults to binding on all interfaces inside the container).

Reachability from outside the container, controlled by Compose ports and the host firewall.

A pattern I like is “bind locally by default” via 127.0.0.1:11434:11434, then switch to 0.0.0.0:11434:11434 only when I have a reason to expose it.

Browser clients and OLLAMA_ORIGINS

If a browser-based UI or extension calls Ollama directly, you are in CORS territory. Ollama allows cross-origin requests from 127.0.0.1 and 0.0.0.0 by default, and you can configure additional origins using OLLAMA_ORIGINS.

This matters even on a single node, because “it works with curl” does not mean “it works from a browser app”.

Upgrade and rollback patterns that fit a single-node server

Ollama evolves quickly. Your Compose file can make that a calm process instead of a late-night surprise.

Upgrade by bumping a tag, not by hoping “latest” behaves

The most practical upgrade strategy is to pin the image to a known-good tag in .env, and bump it intentionally. The image is published as ollama/ollama on Docker Hub.

Because model data and server state are stored under a mounted directory (/root/.ollama in the official docs), replacing the container does not imply re-downloading models.

Rollback is just switching the tag back

Rollback is the same mechanism in reverse: set the previous tag, recreate the container, keep the same volume. This is where pinning pays for itself.

Data migration is mostly about storage paths

Most “migrations” in a single-node setup are not about database schemas. They are about disk layout. If you change the models directory (via OLLAMA_MODELS) or move the mounted volume to a new disk, you are doing a data migration whether you call it that or not.

If you want a practical guide for reorganising the model directory on real machines, see: move-ollama-models .

A final note that is easy to miss: Ollama’s API documentation explicitly says the API is expected to be stable and backwards compatible, with rare deprecations announced in release notes. That makes “upgrade the server, keep clients working” a reasonable default expectation for a single-node service endpoint.

Common failures: GPU permissions, driver mismatch, and OOM

This section is deliberately symptom-driven. The goal is not “every possible Docker error”, only the failures that show up specifically in Ollama + GPU + persistent storage setups.

GPU visible on the host, missing in the container

If the host has a working NVIDIA driver but the container does not see a GPU, the common causes are:

The NVIDIA Container Toolkit is not installed or the Docker runtime is not configured via nvidia-ctk. Ollama’s Docker docs call this out directly.

Compose is not reserving a GPU device. The supported way is deploy.resources.reservations.devices with the gpu capability as documented by Docker.

A legacy runtime: nvidia configuration is being used on a daemon that does not recognise it, producing “unknown or invalid runtime name: nvidia”.

For validation, ollama ps gives you a pragmatic check: it shows whether a model is loaded in GPU memory.

Permission denied on GPU devices

The “permission denied” flavour of GPU failures typically points to environment constraints rather than Ollama itself. Examples include running rootless Docker, security policies, or device nodes not being exposed as expected. The Docker Compose GPU support docs are explicit that the host must have GPU devices and that the Docker daemon must be set accordingly.

When in doubt, reduce the variables: confirm the toolkit configuration (host), then confirm GPU reservation (Compose), then confirm GPU usage (ollama ps).

Wrong driver, wrong expectation

Ollama in Docker relies on the host driver stack. If the host driver is missing, too old, or misconfigured, you will see failures that look like “Ollama is broken” but are really “CUDA stack is not usable”. The official docs place the container toolkit and Docker daemon configuration as prerequisites for NVIDIA GPU usage.

Out of memory: VRAM or RAM disappears fast

OOM is the most predictable failure mode for local inference, and it is usually self-inflicted by configuration.

Ollama supports concurrent processing through multiple loaded models and parallel request handling, but it is constrained by available memory (system RAM on CPU inference, VRAM on GPU inference). When GPU inference is used, new models must fit in VRAM to allow concurrent model loads.

Two configuration details are worth treating as first-class “server settings”:

OLLAMA_NUM_PARALLEL increases parallel request processing per model, but required memory scales with OLLAMA_NUM_PARALLEL * OLLAMA_CONTEXT_LENGTH.

OLLAMA_KEEP_ALIVE controls how long models remain loaded (default is 5 minutes). Keeping models loaded reduces cold-start latency, but it also pins memory.

If you are stabilising a single-node service under load, the non-dramatic fixes usually look like:

Lower parallelism and context defaults before you change anything else.

Limit how many models are allowed to remain loaded concurrently.

Consider memory-reduction features like Flash Attention (OLLAMA_FLASH_ATTENTION=1) and lower precision K/V cache types (OLLAMA_KV_CACHE_TYPE) when your bottleneck is memory, not raw compute.

When it is not Ollama: choosing Docker Model Runner instead

Sometimes the “failure” is really a tooling mismatch. If your organisation already standardises on Docker-native artifacts and workflows, Docker Model Runner (DMR) can be a better fit than running Ollama as a long-lived service container.

Docker positions DMR as a way to manage, run, and serve models directly via Docker, pulling from Docker Hub or other OCI registries, and serving OpenAI-compatible and Ollama-compatible APIs.

It also supports multiple inference engines (including llama.cpp, and vLLM on Linux with NVIDIA GPUs), which can matter if you care about throughput characteristics, not just “run one model locally”.

If you want a practical command reference and a deeper comparison angle, see: Docker Model Runner Cheatsheet: Commands & Examples.

Ollama behind a reverse proxy with Caddy or Nginx for HTTPS streaming

Running Ollama behind a reverse proxy is the simplest way to get HTTPS, optional access control, and predictable streaming behaviour.

This post focuses on Caddy and Nginx ingress for the Ollama API, not on client code.

If you already have Python or Go clients talking to Ollama, this post is the missing piece: ingress and transport for the same API.

For how Ollama fits alongside vLLM, Docker Model Runner, LocalAI, and cloud hosting trade-offs, see
LLM Hosting in 2026: Local, Self-Hosted & Cloud Infrastructure Compared.

For request examples and client code, see
Ollama CLI Cheatsheet.

For UI and multi-user layers, see
Open WebUI overview, quickstart and alternatives.

For the bigger picture on self-hosting and data control, see
LLM self-hosting and AI sovereignty.

For a reproducible single-node Ollama service in Docker Compose (persistent volumes, OLLAMA_HOST, NVIDIA GPUs, upgrades), see
Ollama in Docker Compose with GPU and Persistent Model Storage.

Why you should proxy Ollama instead of exposing port 11434

Ollama is designed to run locally first. Out of the box it binds to localhost on port 11434, which is great for a developer workstation and a not-so-subtle hint that the raw port is not meant to be internet-facing.

I treat port 11434 as an internal, high-cost API. If it is reachable from the public internet, anyone who finds it can burn your CPU or GPU time, fill your disk by pulling models, or just keep connections open until something times out. A reverse proxy does not make Ollama safer by magic, but it gives you a place to put the controls that matter at the edge: TLS, authentication, timeouts, rate limits, and logs.

This matters because the local Ollama API does not ship with a built-in authentication layer. If you expose it, you typically add auth at the edge or keep it private and reachable only over a trusted network.

The second reason is UX. Ollama streams responses by default. If the proxy buffers or compresses in the wrong spot, streaming feels broken and UIs look like they are “thinking” with no output.

Minimal architecture and binding strategy

A clean minimum looks like this:

Client (curl, Python, Go, UI)
        |
        | HTTPS (optional Basic Auth or SSO)
        v
Reverse proxy (Caddy or Nginx)
        |
        | HTTP (private LAN, localhost, or Docker network)
        v
Ollama server (ollama serve on 127.0.0.1:11434)

Two practical rules keep this boring in the best way.

First, keep Ollama private and move exposure to the proxy. If Caddy or Nginx runs on the same host, proxy to 127.0.0.1:11434 and do not change Ollama’s bind address. If the proxy runs elsewhere (separate host, separate VM, or a container network), bind Ollama to a private interface, not 0.0.0.0 on the public NIC, and lean on a firewall.

Second, decide early whether browsers will call Ollama directly. If a browser-based tool hits Ollama from a different origin, you may need to deal with CORS. If everything is served from one domain via the proxy (recommended for sanity), you can often avoid CORS entirely and keep Ollama strict.

Reverse proxy configs for streaming and WebSockets

Ollama’s API is regular HTTP, and its streaming is newline-delimited JSON (NDJSON). That means you want a proxy that can do three things well:

  • Do not buffer streaming responses.
  • Do not kill long-running requests just because the model took a while to speak.
  • If a UI uses WebSockets (some do), forward Upgrade cleanly.

You can keep this simple. In many cases, “correct WebSockets handling” is just having a config that is Upgrade-safe even if the upstream does not use WebSockets today.

Caddy Caddyfile example

Caddy is the “less config, more defaults” option. If you put a public domain name in the site address, Caddy will typically obtain and renew certificates automatically.

Minimal reverse proxy, HTTPS, and streaming-friendly settings:

# ollama.example.com A/AAAA -> your proxy host
ollama.example.com {

    # Optional Basic Auth at the edge.
    # Generate a password hash with:
    #   caddy hash-password --algorithm bcrypt
    #
    # basic_auth {
    #   alice $2a$12$REDACTED...
    # }

    reverse_proxy 127.0.0.1:11434 {

        # Some setups prefer pinning the upstream Host header.
        # Ollama's own docs show this pattern for Nginx.
        header_up Host localhost:11434

        # For streaming or chat-like workloads, prefer low latency.
        # NDJSON streaming usually flushes immediately anyway, but this makes it explicit.
        flush_interval -1

        transport http {
            # Avoid upstream gzip negotiation if it interferes with streaming.
            compression off

            # Give Ollama time to load a model and produce the first chunk.
            response_header_timeout 10m
            dial_timeout 10s
        }
    }
}

If you already have an SSO gateway (oauth2-proxy, Authelia, authentik outpost, etc), Caddy has an opinionated forward auth directive. The pattern is “auth first, then proxy”:

ollama.example.com {
    forward_auth 127.0.0.1:4180 {
        uri /oauth2/auth
        # Copy the identity headers your gateway returns, if you need them.
        copy_headers X-Auth-Request-User X-Auth-Request-Email Authorization
    }

    reverse_proxy 127.0.0.1:11434
}

Nginx server block example

Nginx gives you a bit more rope. The upside is that the knobs are explicit, and it has built-in primitives for rate limiting and connection limiting. The footgun is buffering: Nginx buffers proxied responses by default, which is the opposite of what you want for NDJSON streaming.

This example includes:

  • HTTP to HTTPS redirect
  • TLS certificate paths (Certbot style)
  • WebSocket-safe Upgrade forwarding
  • Streaming-friendly proxy_buffering off
  • Longer timeouts than the default 60s
# /etc/nginx/conf.d/ollama.conf

# WebSocket-safe Connection header handling
map $http_upgrade $connection_upgrade {
    default upgrade;
    ""      close;
}

# Optional request rate limiting (IP-based)
# limit_req_zone $binary_remote_addr zone=ollama_rate:10m rate=10r/s;

server {
    listen 80;
    server_name ollama.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name ollama.example.com;

    ssl_certificate     /etc/letsencrypt/live/ollama.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/ollama.example.com/privkey.pem;

    # Optional Basic Auth at the edge.
    # auth_basic "Ollama";
    # auth_basic_user_file /etc/nginx/.htpasswd;

    location / {
        # Optional rate limit
        # limit_req zone=ollama_rate burst=20 nodelay;

        proxy_pass http://127.0.0.1:11434;

        # Match Ollama docs pattern when proxying to localhost.
        proxy_set_header Host localhost:11434;

        # WebSocket Upgrade handling (harmless if unused).
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;

        # Critical for NDJSON streaming.
        proxy_buffering off;

        # Prevent 60s idle timeouts while waiting for tokens.
        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
    }
}

If you want an SSO-style gate in Nginx, the equivalent pattern is auth_request. Nginx sends a subrequest to your auth service, and only proxies to Ollama when auth returns 2xx.

TLS automation and renewal gotchas

For TLS, the operational split is simple.

With Caddy, TLS is usually “part of the reverse proxy”. Automatic HTTPS is one of its flagship features, so certificate issuance and renewal are coupled to keeping Caddy running, having working DNS, and exposing ports 80 and 443.

With Nginx, TLS is usually “a separate ACME client plus Nginx”. The common failure mode is not crypto, it is plumbing:

  • Port 80 not reachable for HTTP-01 challenges.
  • Certificates stored in a container but not persisted.
  • Rate limits when doing repeated fresh installs or test deploys.

A subtle point that matters for long-lived services is that certificate lifetimes are short by design. Treat renewals as a background automation requirement, not an annual calendar event.

Authentication, abuse control, and verification

This is the part that makes an internet-facing LLM endpoint feel professional.

Authentication options, from blunt to elegant

Basic Auth at the proxy is blunt, but surprisingly effective for a private endpoint. It is also easy to apply to both HTTP requests and WebSocket upgrades.

If you want browser-friendly login flows, forward auth and auth_request are the common pattern. Your proxy stays stateless, and an auth gateway owns sessions and MFA. The trade-off is more moving parts.

If you are already running Open WebUI, you can also rely on its app-level authentication and keep Ollama itself private. The proxy then protects Open WebUI, not Ollama directly.

If you do not need public access at all, a network-only approach can be cleaner. For example, Tailscale Serve can expose a local service inside your tailnet without opening inbound ports on your router.

Abuse basics for an expensive API

Ollama is a powerful local API, and its surface goes beyond generation. It has endpoints for chat, embeddings, listing models, and version checks. Treat the whole API as sensitive.

Official API reference (endpoints and streaming): https://docs.ollama.com/api

At the proxy layer, there are three low-effort controls that reduce day-one pain:

  • Rate limiting per IP on generation endpoints.
  • Connection limits to stop a small number of clients holding everything open.
  • Conservative timeouts that match your model and hardware reality, not generic web defaults.

At the Ollama layer, it can also reject overload with 503 and has server-side knobs for queueing. Proxy rate limiting keeps you from getting there as often.

Verification checklist

Use the same checks you would use for any streaming API.

  1. Basic connectivity and TLS

    • curl -sS https://ollama.example.com/api/version
    • curl -sS https://ollama.example.com/api/tags | head
  2. Streaming works end to end (no buffering)

    • curl -N https://ollama.example.com/api/generate -H "Content-Type: application/json" -d '{"model":"mistral","prompt":"Write 10 words only.","stream":true}'

If you are behind Basic Auth:

  • curl -N -u alice:REDACTED https://ollama.example.com/api/generate -H "Content-Type: application/json" -d '{"model":"mistral","prompt":"Write 10 words only.","stream":true}'
  1. Browser UI sanity
    • Load your chat UI and trigger a response.
    • If the UI uses WebSockets, confirm you do not see 400 or 426 errors and the connection stays open during generation.

If the curl output only appears at the end, it is almost always buffering at the proxy. Re-check proxy_buffering off in Nginx, and consider forcing low-latency flushing in Caddy for the Ollama site block.