Ollama runs the models, and Open WebUI gives them a ChatGPT-style browser front end with accounts, chat history and document uploads. Run the pair as one Docker Compose project and Ollama’s API, which has no login of its own, never gets a port on the host. Check the CPU first, though: Open WebUI’s current image will not start without SSE4.2, and our first lab VM, on Proxmox’s old kvm64 CPU type, crash-looped twelve times on a NumPy error.
These steps install Open WebUI and Ollama with Docker Compose, create the admin account over an SSH tunnel and put Nginx and Let’s Encrypt in front. We ran them on Ubuntu 26.04 LTS with Docker 29.9.1, Open WebUI v0.12.2 and v0.12.3 and Ollama 0.40.5, and the RHEL-family steps on Rocky Linux 10.2 with SELinux enforcing, in October 2026. Keep both containers in one project unless Ollama lives on a separate GPU box.
Prerequisites
Everything runs on one Linux host. Nginx comes from the distro’s packages, and Open WebUI and Ollama run as two containers in one Compose project. Only Nginx listens on the network, because Open WebUI binds to 127.0.0.1:3000 and Ollama gets no host port at all, so the login page is the only way anyone reaches a model.

We checked that from another machine on the lab LAN once the stack was up. Port 80 answered, while ports 3000 and 11434 refused the connection outright, which is the diagram doing its job. Your server needs these:
- 64-bit Linux that Docker Engine supports: Ubuntu, Debian, Rocky Linux, AlmaLinux
- an x86-64-v2 CPU (SSE4.2 and POPCNT), or any ARM64 CPU
- RAM for the model, plus about 1.2 GB for Open WebUI itself
- about 16 GB of disk for the two images, before any model
- a DNS A record for the hostname, with ports 80 and 443 reachable
- a user with sudo
Check the CPU level before you install anything, because a CPU below x86-64-v2 fails late and with an error that does not mention the CPU at all. On x86 hardware the glibc loader prints the levels it can use.
/lib64/ld-linux-x86-64.so.2 --help | grep x86-64-v
A supported CPU marks x86-64-v2 as (supported, searched), as the Intel i7-6700 under our second lab VM did:
x86-64-v4
x86-64-v3 (supported, searched)
x86-64-v2 (supported, searched)
The kvm64 VM printed the same three lines with no markers at all. That is the machine Open WebUI refused to run on, and the fix is in the troubleshooting section at the end. ARM64 servers do not have this loader path or the problem, since both images ship arm64 builds.
Model size decides the rest of your hardware. A model needs about its download size in RAM plus room for the conversation, so a 3B model at 4-bit quantization (around 2 GB) is comfortable on 8 GB of RAM. An 8B model wants 16 GB on a CPU-only box, or a GPU with 8 GB of VRAM, and every user chatting at the same moment adds their own context on top.
Open WebUI held 1.2 GB before the first chat, and the Ollama container grew from 138 MB to 2.7 GB once llama3.2:3b was in memory. Our VM had 4 vCPUs and 12 GB of RAM, which is a floor for following along, not a recommendation. If you are still picking hardware, our mini PC guide for local AI matches memory size and bandwidth to the models you will run.
Set the domain and email variables
Two values repeat through the guide: the hostname people will type, and the email certbot registers as your Let’s Encrypt account contact. Let’s Encrypt stopped sending expiry emails in June 2025, so that address won’t warn you before a certificate lapses, and the renewal checks further down do that job instead. Export both values once in the shell you will use for the whole setup.
export SITE_DOMAIN="chat.example.com"
export ADMIN_EMAIL="[email protected]"
Use a real hostname whose A record already points at this server, because certbot checks it later. The variables only live in the current shell, so run both lines again if you reconnect or switch to a root shell. Confirm they are set before going on.
echo "${SITE_DOMAIN} ${ADMIN_EMAIL}"
Both values should print on one line. A blank where either one belongs means the export did not take, so run it again before going further.
Install Docker Engine and Compose
Docker Engine with the Compose plugin is the only thing to install, because Open WebUI and Ollama both ship as images. Take it from Docker’s own apt repository rather than the distro’s docker.io package, which trails upstream and does not carry the Compose plugin. The commands below work on Ubuntu and Debian as written, since they read the distro and codename from /etc/os-release. On Rocky Linux or AlmaLinux, skip to the dnf commands right after the Ubuntu ones, and see our Docker Engine guide for Rocky and Alma for more detail.
Add Docker’s signing key first.
sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL "https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg" -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
Docker now documents its repository in the deb822 format. This printf fills in the distro, codename and CPU architecture for you, so there is nothing to edit.
printf 'Types: deb\nURIs: https://download.docker.com/linux/%s\nSuites: %s\nComponents: stable\nArchitectures: %s\nSigned-By: /etc/apt/keyrings/docker.asc\n' "$(. /etc/os-release && echo "$ID")" "$(. /etc/os-release && echo "$VERSION_CODENAME")" "$(dpkg --print-architecture)" | sudo tee /etc/apt/sources.list.d/docker.sources
The file it writes should name your release on the Suites line. On Ubuntu 26.04 that is resolute:
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: resolute
Components: stable
Architectures: amd64
Signed-By: /etc/apt/keyrings/docker.asc
Install the engine, the CLI and both plugins.
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Then check that both the engine and the Compose plugin answer.
docker --version
docker compose version
Two version lines mean you are ready.
Docker version 29.9.1, build dfc520d
Docker Compose version v5.6.0
If the second command says 'compose' is not a docker command instead, the plugin package did not install, and the old standalone docker-compose is not a substitute for it.
Install Docker on Rocky Linux and AlmaLinux
Docker’s RHEL repository serves Rocky Linux and AlmaLinux as they are, so the same four commands from Docker’s RHEL page apply. Read the paragraph after them before you go on, because the engine refused to start the first time on our Rocky Linux 10 VM.
sudo dnf -y install dnf-plugins-core
sudo dnf config-manager --add-repo https://download.docker.com/linux/rhel/docker-ce.repo
sudo dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo systemctl enable --now docker
On our VM the engine refused to start at first. The Docker install pulled in kernel-modules-extra for the newest kernel in /lib/modules, which was not the kernel the VM was running, so dockerd could not load its NAT module and journalctl -u docker ended in a loop of:
dockerd[61514]: iptables v1.8.11 (nf_tables): RULE_APPEND failed (No such file or directory): rule in chain PREROUTING
If uname -r doesn’t match the newest directory in /lib/modules, reboot with sudo systemctl reboot. Ours came back on the new kernel with Docker active, and sudo docker run --rm hello-world printed its greeting, with the same engine and Compose versions as on Ubuntu.
Write the Open WebUI Docker Compose file
The Compose file defines both containers, and a .env file next to it holds the values that change: the image versions, your hostname and a secret key. Keep them in their own directory, because Compose names the project, its network and its volumes after the directory.
mkdir -p ~/open-webui && cd ~/open-webui
Pin both images to their current release rather than the rolling main and latest tags. With a pinned tag, an upgrade only happens when you change the tag, and the Open WebUI docs recommend pinning for anything other people use. These two lines ask GitHub for each project’s latest release.
OWUI_VERSION=$(curl -s https://api.github.com/repos/open-webui/open-webui/releases/latest | grep -o '"tag_name": *"[^"]*"' | cut -d '"' -f 4)
OLLAMA_VERSION=$(curl -s https://api.github.com/repos/ollama/ollama/releases/latest | grep -o '"tag_name": *"[^"]*"' | cut -d '"' -f 4 | sed 's/^v//')
echo "Open WebUI: ${OWUI_VERSION} Ollama: ${OLLAMA_VERSION}"
Both names should print with a version. The Ollama tag loses its leading v because Docker Hub tags that image without one. An empty value usually means you hit GitHub’s limit of 60 anonymous API calls an hour, so wait a few minutes and run the lines again.
Open WebUI: v0.12.2 Ollama: 0.40.5
Now write the .env file. WEBUI_SECRET_KEY signs login sessions and encrypts stored API keys, and Open WebUI generates one inside the container if you do not set it. That key goes away when an upgrade recreates the container, which logs every user out and, with MFA turned on, stops Open WebUI from starting at all.
printf 'OWUI_VERSION=%s\nOLLAMA_VERSION=%s\nSITE_DOMAIN=%s\nWEBUI_SECRET_KEY=%s\n' "${OWUI_VERSION}" "${OLLAMA_VERSION}" "${SITE_DOMAIN}" "$(openssl rand -hex 32)" > .env
chmod 600 .env
Create the Compose file next to it, in the directory you just made.
vim docker-compose.yml
Paste both services. The ${...} values come from .env, so the file itself never changes between installs:
services:
ollama:
image: ollama/ollama:${OLLAMA_VERSION}
container_name: ollama
volumes:
- ollama:/root/.ollama
restart: unless-stopped
open-webui:
image: ghcr.io/open-webui/open-webui:${OWUI_VERSION}
container_name: open-webui
depends_on:
- ollama
ports:
- "127.0.0.1:3000:8080"
environment:
OLLAMA_BASE_URL: http://ollama:11434
WEBUI_SECRET_KEY: ${WEBUI_SECRET_KEY}
WEBUI_URL: https://${SITE_DOMAIN}
CORS_ALLOW_ORIGIN: https://${SITE_DOMAIN}
volumes:
- open-webui:/app/backend/data
restart: unless-stopped
volumes:
ollama:
open-webui:
Four settings in that file do the security work.
127.0.0.1:3000:8080, so Open WebUI is reachable from this host only- no
portsonollama, so the API stays on the Compose network OLLAMA_BASE_URLwith the service nameollamaas the hostnameWEBUI_URLandCORS_ALLOW_ORIGINset to the public HTTPS address
The loopback binding matters more than it looks. Docker writes its own iptables rules for published ports, and Docker’s documentation says plainly that those ports bypass UFW and firewalld, so a bare "3000:8080" would put Open WebUI on every interface whatever your firewall says. CORS_ALLOW_ORIGIN defaults to *, and the Open WebUI docs ask for the real origin behind a proxy because the browser’s WebSocket connection is checked against it. WEBUI_URL is stored in the database on first start, so set it now rather than later.
Let Compose parse the file before you start anything. A typo in a variable name shows up here instead of as an empty value later.
sudo docker compose config --quiet && echo "compose file OK"
You want one line back and nothing else.
compose file OK
Anything else names the offending line, or the variable Compose could not find in .env.
Start Open WebUI and Ollama
One command pulls both images, creates the network and the two volumes, and starts your containers.
sudo docker compose up -d
The first run is mostly download. The Open WebUI image is a 1.63 GB pull and the Ollama image 3.79 GB, because Ollama’s image carries GPU libraries even on a CPU host, and up -d returned after 302 seconds on our link. Open WebUI then fetches its embedding model and runs its database migrations, and its health check turned healthy 91 seconds after the containers started. Check both containers:
sudo docker compose ps
Both should be up, open-webui should say (healthy), and its port column should read 127.0.0.1:3000, not 0.0.0.0:3000. The ollama line shows 11434/tcp with no host address, which means it is not published:
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
ollama ollama/ollama:0.40.5 "/bin/ollama serve" ollama About a minute ago Up About a minute 11434/tcp
open-webui ghcr.io/open-webui/open-webui:v0.12.2 "bash start.sh" open-webui About a minute ago Up About a minute (healthy) 127.0.0.1:3000->8080/tcp
Ask the backend directly before Nginx is in the picture. If you see a 502 later, you then know it’s a proxy problem.
curl -s http://127.0.0.1:3000/health
It answers with one line.
{"status":true}
If open-webui shows Restarting in the status column instead, read the end of its log with sudo docker compose logs open-webui. On a CPU below x86-64-v2 it ends in the NumPy error covered at the end of this guide.
Create the admin account over an SSH tunnel
Create the admin now, while Open WebUI only listens on 127.0.0.1. The first account to sign up becomes the administrator, and once Nginx serves your hostname, anyone who reaches port 80 or 443 could take that slot before you. Certificate Transparency logs publish the hostname the moment a certificate is issued, and an Open WebUI admin can run Python on the server through Tools, so this is a race worth not running. From your workstation, forward the port over SSH with your own user and server address:
ssh -L 3000:127.0.0.1:3000 [email protected]
Leave that session open and browse to http://localhost:3000 on your workstation. Click Get started, then fill in the Create Admin Account form:

The browser console logs WebSocket 403 errors on this address, because CORS_ALLOW_ORIGIN names only the HTTPS hostname, but sign-up is a plain API call and isn’t affected. Ours answered 200 with "role":"admin". Right after that, the admin API reported new sign-ups off and new users defaulting to pending, which can sign in but cannot chat until an admin approves them. Close the tunnel when you are done, since chatting happens over HTTPS later.
If the form isn’t there, and the page offers a sign-in with no Get started, someone else created the first account. Throw the instance away and start clean, which deletes every account and setting in the open-webui volume but keeps your models:
cd ~/open-webui
sudo docker compose down
sudo docker volume rm open-webui_open-webui
sudo docker compose up -d
Then create the admin through the tunnel before you do anything else. Use a real email for it, because that is your login name from then on.
Pull a model and measure CPU speed
Ollama starts with no models, and Open WebUI lists whatever Ollama has, so pull at least one before you open the browser. On a CPU-only box, llama3.2:3b is the sensible first pick: a 2 GB download that still answers coherently. Run the pull inside the ollama container.
sudo docker compose exec ollama ollama pull llama3.2:3b
That took 114 seconds here. We pulled two more to compare, qwen3.5:4b and gemma4:e2b, and listed what Ollama holds:
sudo docker compose exec ollama ollama list
All three show up with their download size, and the same list appears in Open WebUI’s model picker:
NAME ID SIZE MODIFIED
gemma4:e2b b37049369adf 4.6 GB 28 seconds ago
qwen3.5:4b d8b0f5e9760c 3.3 GB 4 minutes ago
llama3.2:3b a80c4f17acd5 2.0 GB 6 minutes ago
Before you open the browser, see what your CPU delivers. The --verbose flag prints timing after the answer.
sudo docker compose exec ollama ollama run llama3.2:3b --verbose "Explain what a reverse proxy does in three sentences."
The eval rate is the number that matters, because it is the speed the reply streams into the chat window. This first run also paid 7.9 seconds to load the model into RAM:
total duration: 19.117123779s
load duration: 7.859547831s
prompt eval count: 36 token(s)
prompt eval duration: 802.168ms
prompt eval rate: 44.88 tokens/s
eval count: 109 token(s)
eval duration: 10.452459s
eval rate: 10.43 tokens/s
Ollama’s log shows which CPU code path it picked: loaded CPU backend from /usr/lib/ollama/libggml-cpu-haswell.so, the AVX2 build. Our terminal showed the same session like this, with the container status above the timing:

One run is a sample, not a benchmark. We called Ollama’s API four times per model on the VM’s four Skylake cores (Intel i7-6700, no GPU) with the same prompt, temperature 0, seed 42 and a 256-token cap, unloading every model first. Thinking was off for qwen3.5:4b and gemma4:e2b ("think": false), and llama3.2:3b has no thinking mode. The first call includes the load, and the generation rate is the average of the three warm calls. All figures are CPU-only:
| Model | Download | Cold load | Generation (warm, CPU only) |
|---|---|---|---|
llama3.2:3b | 2.0 GB | 7.1 s | 11.3 tokens/s |
gemma4:e2b | 4.6 GB | 11.3 s | 9.2 tokens/s |
qwen3.5:4b | 3.3 GB | 11.4 s | 7.6 tokens/s |
If you’re on a CPU box, start with llama3.2:3b. It generates fastest and loads in seven seconds. At 11 tokens a second a 300-word answer streams in over about 35 seconds, which reads fine but drags on long code.
The gemma4:e2b model is the largest download yet the middle performer, because the E in its tag stands for effective parameters: only about 2B of them work on each token. The slowest is qwen3.5:4b at 7.6 tokens a second, the cost of more active parameters on the same four cores. With thinking turned on in the chat, both thinking models write their reasoning first at the same rate, so answers take longer than the table suggests. A GPU moves all three into a different class, and our Ollama, vLLM and llama.cpp throughput tests show how far. For the rest of the model commands (stop, remove, copy, show), keep the Ollama commands cheat sheet open.
Put Nginx and Let’s Encrypt in front
Nginx terminates TLS on port 443 and hands every request to 127.0.0.1:3000, including the WebSocket that streams each reply into the browser. Install it together with certbot and certbot’s Nginx plugin. On Rocky Linux or AlmaLinux, follow the “Nginx and certbot on Rocky Linux and AlmaLinux” subsection further down for the install and firewalld steps, then come back for the certbot command.
sudo apt-get install -y nginx certbot python3-certbot-nginx
Create a site file for Open WebUI.
sudo vim /etc/nginx/sites-available/open-webui
Paste this server block. It listens on port 80 only for now, because certbot adds the 443 listener and the HTTP redirect itself in a moment. SITE_DOMAIN_HERE is a placeholder that the next command swaps for your hostname:
server {
listen 80;
listen [::]:80;
server_name SITE_DOMAIN_HERE;
client_max_body_size 50M;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
}
}
Three parts of that block are there for Open WebUI specifically.
proxy_buffering offandproxy_cache off, so streamed replies pass through unchanged- the
UpgradeandConnectionheaders, which carry the WebSocket - 600 second timeouts, long enough for slow CPU replies
Buffering is the one people miss. Nginx buffers by default and re-chunks the streamed reply, so answers still arrive, but markdown breaks at the chunk edges. You then see stray ** and ## marks in the chat, which the Open WebUI Nginx reference calls the most common cause of garbled output. The client_max_body_size line raises Nginx’s 1 MB upload limit, which otherwise rejects most PDFs you’d attach to a chat.
Put your hostname in, enable the site, drop the default one and test the configuration.
sudo sed -i "s/SITE_DOMAIN_HERE/${SITE_DOMAIN}/" /etc/nginx/sites-available/open-webui
sudo ln -s /etc/nginx/sites-available/open-webui /etc/nginx/sites-enabled/open-webui
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
Both lines should end in ok and successful. Anything else names the file and line to fix, and Nginx keeps serving the old configuration until you reload.
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
Reload Nginx so it picks up the new site.
sudo systemctl reload nginx
Open HTTP and HTTPS before you ask for a certificate, because Let’s Encrypt connects to port 80 to check the name. With UFW, allow SSH first so turning the firewall on does not drop your session.
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
Test a fresh SSH login from a second terminal before you close the first. Our Ubuntu VM ran with exactly these rules from here on, which matters again if you later connect an Ollama that runs on the host.
Now request the certificate. certbot proves you control the name over port 80, writes the certificate under /etc/letsencrypt/live/, adds the 443 listener to the server block and turns port 80 into a redirect.
sudo certbot --nginx -d "${SITE_DOMAIN}" --non-interactive --agree-tos --redirect -m "${ADMIN_EMAIL}"
This is the one step we did not run against Let’s Encrypt, because the lab host has no public DNS name. In its place, the same server block got a 443 listener with a certificate from a private test CA for chat.example.com, and the chat and admin screenshots below came through it over HTTPS. The certbot package also installs a systemd timer that runs twice a day, and certbot renews a certificate once a third of its lifetime is left, which is 30 days on a 90-day Let’s Encrypt certificate. With no expiry emails, check both for yourself once your real certificate exists. First the dry run:
sudo certbot renew --dry-run
The dry run talks to Let’s Encrypt’s staging servers, so it doesn’t count against the production rate limits. Then confirm the timer is scheduled:
systemctl list-timers certbot.timer
A NEXT time within the next day means it is armed. On Rocky Linux and AlmaLinux the unit is called certbot-renew.timer.
Nginx and certbot on Rocky Linux and AlmaLinux
Three things differ on the RHEL family, and we ran each one on Rocky Linux 10 with SELinux enforcing. Nginx comes from AppStream, but certbot and its Nginx plugin are only in EPEL, so add that repository first:
sudo dnf install -y nginx epel-release
sudo dnf install -y certbot python3-certbot-nginx
There is no sites-available directory. Put the same server block in conf.d, where the stock nginx.conf already includes every .conf file:
sudo vim /etc/nginx/conf.d/open-webui.conf
Paste the server block from above unchanged, swap in your hostname, then test and start Nginx.
sudo sed -i "s/SITE_DOMAIN_HERE/${SITE_DOMAIN}/" /etc/nginx/conf.d/open-webui.conf
sudo nginx -t
sudo systemctl enable --now nginx
Our VM image shipped without firewalld, which a standard Rocky install includes, so we added it before opening HTTP and HTTPS:
sudo dnf install -y firewalld
sudo systemctl enable --now firewalld
sudo firewall-cmd --permanent --add-service=http --add-service=https
sudo firewall-cmd --reload
The third difference is SELinux, and it fails quietly. Nginx started, nginx -t passed, and every request still got a 502, because the policy does not let httpd_t open outbound connections and labels port 3000 ntop_port_t. The Nginx error log said:
2026/10/16 12:48:23 [crit] 7504#7504: *1 connect() to 127.0.0.1:3000 failed (13: Permission denied) while connecting to upstream, client: 127.0.0.1, server: chat.example.com, request: "GET / HTTP/1.1", upstream: "http://127.0.0.1:3000/", host: "chat.example.com"
The audit log shows the denial behind it, from sudo ausearch -m avc -ts recent.
type=AVC msg=audit(1792154903.247:768): avc: denied { name_connect } for pid=7504 comm="nginx" dest=3000 scontext=system_u:system_r:httpd_t:s0 tcontext=system_u:object_r:ntop_port_t:s0 tclass=tcp_socket permissive=0
Turn on the boolean that lets web servers make network connections. -P keeps it across reboots, and you leave SELinux enforcing:
sudo setsebool -P httpd_can_network_connect 1
The same request through Nginx then returned 200, and /health answered {"status":true}. From another LAN machine, port 80 answered while port 3000 refused, as on Ubuntu. The certbot command is the same, and EPEL’s certbot enables certbot-renew.timer for you.
Use DNS-01 when port 80 can’t be reached
A home server behind NAT, or one on a private LAN, cannot pass the port 80 check. DNS-01 proves ownership with a TXT record instead, so the server never has to be reachable from the internet. It needs a certbot plugin for your DNS provider, and Ubuntu packages the common ones. On Rocky Linux and AlmaLinux the same plugins come from EPEL as python3-certbot-dns-* packages.
| DNS provider | Package | certbot authenticator |
|---|---|---|
| Cloudflare | python3-certbot-dns-cloudflare | dns-cloudflare |
| Amazon Route 53 | python3-certbot-dns-route53 | dns-route53 |
| DigitalOcean | python3-certbot-dns-digitalocean | dns-digitalocean |
| Google Cloud DNS | python3-certbot-dns-google | dns-google |
| Linode (Akamai) | python3-certbot-dns-linode | dns-linode |
| OVH | python3-certbot-dns-ovh | dns-ovh |
| BIND or any RFC 2136 server | python3-certbot-dns-rfc2136 | dns-rfc2136 |
The worked example uses Cloudflare, so swap in your provider’s package, authenticator and credentials file if you are on something else. Install the plugin and create the credentials file.
sudo apt-get install -y python3-certbot-dns-cloudflare
sudo vim /etc/letsencrypt/cloudflare.ini
Add an API token that can edit DNS for that zone only.
dns_cloudflare_api_token = your-token-here
Lock the file down, then run certbot with the DNS plugin for the check and the Nginx plugin to install the certificate.
sudo chmod 600 /etc/letsencrypt/cloudflare.ini
sudo certbot run -a dns-cloudflare -i nginx --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini -d "${SITE_DOMAIN}" --non-interactive --agree-tos --redirect -m "${ADMIN_EMAIL}"
Every plugin in the table is in Ubuntu 26.04’s archive at the same version as certbot itself, but we created no DNS records for this lab, so the DNS-01 run is untested here. If it fails with an authentication error, the token is missing the Zone DNS Edit permission for that zone. On a private LAN the hostname also has to resolve to the server’s internal address for your clients, through your router’s DNS or a local resolver.
Pick a model and run a chat
Sign in at https://chat.example.com with the admin account from the tunnel step. The chat screen opens on a default model, and the selector lists every model Ollama holds with its parameter count. Nothing needed configuring for that, because OLLAMA_BASE_URL already points at the ollama service:

Pick llama3.2:3b and ask for something with markdown in the answer, which tests the proxy at the same time. We asked for an Nginx location block, and the reply streamed in with its code block and inline code intact, which is the job proxy_buffering off does:

You’ll find the browser feels slower than the CLI numbers suggest. That one message produced three /api/chat requests in Ollama’s log: an 11-second call, the reply itself at 1 minute 25 seconds, and a 17-second call that finished after it. Open WebUI uses the chat model for background work too, and by default it generates a title, tags and follow-up suggestions for each chat. The full reply finished about 85 seconds after we pressed Enter. The next section shows where to point those background tasks.
Lock down sign-ups and connections
Open the user menu (your initials, bottom left) and choose Admin Panel. It opens on the Users tab, where Add User creates accounts for other people. The Settings tab opens a dialog with your personal settings at the top and the admin pages under Admin, grouped as System and AI, and three of those pages are worth a minute before anyone else logs in. Authentication, under System, comes first:

It shows what the API reported: Default User Role is pending and New Sign Ups is off. Leave sign-ups off and add people with Admin Panel > Users > Add User, or switch them on for a day with pending as the role and approve each account as it arrives. JWT Expiration is 4w, so a stolen session token keeps working for four weeks, and a shorter value is worth it if the instance faces the internet.
Connections, under AI, had a surprise. An OpenAI API entry was switched on by default, pointing at https://api.openai.com/v1 with no key, and when the page loaded the Open WebUI log recorded a 500 on /openai/models/0 with Missing bearer authentication in header. If you only run local models, switch OpenAI API off and click Save, which leaves Ollama as the only connection:

The download icon beside http://ollama:11434 opens Ollama’s Manage dialog, which pulls and deletes models from the browser, handy once you stop wanting a shell for every new model. The third page is Interface under System, not the Interface entry in your personal settings, and it controls those background tasks:

Local Task Model defaults to Current Model, so titles and tags run on whichever model the user picked, however large. Set it to llama3.2:3b, the fastest model in our table, or switch Title Generation off on a small box. Values saved in this dialog live in the database and win over environment variables from then on, which is why WEBUI_URL went into the Compose file before the first start.
Connect an Ollama that already runs on the host
If Ollama already runs on this machine from our Ollama install guide, you do not need a second copy in a container. Point Open WebUI at the host instead. Three things stand in the way: a native Ollama listens on 127.0.0.1 only, inside a container 127.0.0.1 is the container itself, and UFW drops traffic from Docker networks to the host. Test the path first from a throwaway container, reading the image tag from .env so it works in a fresh shell:
cd ~/open-webui && OWUI_VERSION=$(grep '^OWUI_VERSION=' .env | cut -d= -f2)
sudo docker run --rm --add-host=host.docker.internal:host-gateway --entrypoint curl ghcr.io/open-webui/open-webui:${OWUI_VERSION} -sS -m 10 http://host.docker.internal:11434/api/version
With UFW active, as set up in the Nginx section, the request hangs until curl gives up. With the firewall off, the same test was refused in 0 ms instead, which is how you tell an Ollama that isn’t listening from a firewall that is dropping packets:
curl: (28) Connection timed out after 10001 milliseconds
Make Ollama listen on all interfaces with a systemd drop-in. A drop-in survives Ollama upgrades because the install script only rewrites the main unit file.
sudo mkdir -p /etc/systemd/system/ollama.service.d
sudo vim /etc/systemd/system/ollama.service.d/override.conf
Add the listen address.
[Service]
Environment="OLLAMA_HOST=0.0.0.0:11434"
Reload systemd, restart Ollama and let only Docker’s address range through UFW to port 11434. Docker draws its bridge networks from 172.16.0.0/12 by default, so that one rule covers docker0 and the Compose network (a custom default-address-pools needs its own range). On firewalld hosts such as Rocky Linux, Docker puts its bridges in the docker zone with target ACCEPT, so no extra rule is needed there:
sudo systemctl daemon-reload
sudo systemctl restart ollama
sudo ufw allow from 172.16.0.0/12 to any port 11434 proto tcp
Skip the UFW rule and nothing changes: after the restart our curl still timed out, because the firewall drops the packet before Ollama sees it. With the rule in place, run the same docker run test again and you get Ollama’s version back:
{"version":"0.40.5"}
Now swap the Compose file for a one-service version. The extra_hosts entry maps host.docker.internal to the host, which Docker only does when you ask. Open the file again:
vim ~/open-webui/docker-compose.yml
Replace its contents with this. The .env file stays exactly as it is, and the ollama service is gone:
services:
open-webui:
image: ghcr.io/open-webui/open-webui:${OWUI_VERSION}
container_name: open-webui
ports:
- "127.0.0.1:3000:8080"
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
OLLAMA_BASE_URL: http://host.docker.internal:11434
WEBUI_SECRET_KEY: ${WEBUI_SECRET_KEY}
WEBUI_URL: https://${SITE_DOMAIN}
CORS_ALLOW_ORIGIN: https://${SITE_DOMAIN}
volumes:
- open-webui:/app/backend/data
restart: unless-stopped
volumes:
open-webui:
Apply it with --remove-orphans, which deletes the old ollama container. Its volume and the models in it stay until you remove them with sudo docker volume rm open-webui_ollama:
cd ~/open-webui && sudo docker compose up -d --remove-orphans
Compose recreates open-webui and removes the orphan.
Container open-webui Recreate
Container ollama Stopping
Container ollama Stopped
Container ollama Removing
Container ollama Removed
Container open-webui Recreated
Container open-webui Starting
Container open-webui Started
Once it reports healthy, the host’s Ollama answers from inside the new container under the name the file maps:
sudo docker compose exec open-webui curl -s http://host.docker.internal:11434/api/version
That returned {"version":"0.40.5"} in our lab, with UFW still active. Keep the firewall on for as long as Ollama listens on 0.0.0.0, because Ollama has no authentication and the same API pulls and deletes models. With UFW off, /api/version and /api/tags answered another machine on our LAN straight away. With UFW on and only the 172.16.0.0/12 rule, the same request from the LAN timed out. That exposure is the main reason we run Ollama inside the Compose project instead. If Ollama runs on a separate GPU server, set OLLAMA_BASE_URL to that server’s address, for example http://10.0.1.60:11434, and open 11434 on it to the Open WebUI host’s IP only.
Add an NVIDIA GPU
Ollama inside Docker uses an NVIDIA GPU once the host has the NVIDIA driver and the NVIDIA Container Toolkit, and nothing changes in Open WebUI itself. Our lab VM has no GPU, so this section follows the Ollama Docker docs and NVIDIA’s toolkit guide without a run of our own. Install the driver first; our guide to NVIDIA drivers and CUDA on Ubuntu covers it, and nvidia-smi must list the card before you go on. If you are still choosing a card, VRAM decides which models fit, and our GPU guide for local LLMs sorts the options by it.
Add NVIDIA’s repository and install the toolkit.
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
curl -fsSL https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list | sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' | sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list
sudo apt-get update
sudo apt-get install -y nvidia-container-toolkit
Register the NVIDIA runtime with Docker and restart the daemon, which briefly stops every container on the host.
sudo nvidia-ctk runtime configure --runtime=docker
sudo systemctl restart docker
Then give the ollama service the GPUs. Open the Compose file again.
vim ~/open-webui/docker-compose.yml
Add this block under ollama, at the same indent as volumes.
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
Recreate the container and look for Ollama’s compute line in its log.
cd ~/open-webui
sudo docker compose up -d
sudo docker compose logs ollama | grep "inference compute"
On our CPU-only VM the line reads library=cpu, and on a working GPU setup it names CUDA and your card instead. If it still says cpu, the container cannot see the GPU, which is nearly always the runtime step above. Open WebUI’s cuda image tag is a separate matter: it only speeds up Open WebUI’s own embedding and speech models, not chat replies, so keep the standard tag unless you lean on document search. If you switched to the host-Ollama file, skip the Compose block entirely: there is no ollama service to give GPUs to, and the native Ollama uses the card once the driver is in.
Back up and upgrade the stack
Everything Open WebUI knows lives in the open-webui volume: accounts, chats, uploads, settings and its document index. Back it up before every upgrade, with Open WebUI stopped so the SQLite database isn’t caught halfway through a write. A throwaway Alpine container tars the volume into the project directory, so run it from there:
cd ~/open-webui
sudo docker compose stop open-webui
sudo docker run --rm -v open-webui_open-webui:/data -v "$PWD":/backup alpine tar czf /backup/open-webui-$(date +%F).tar.gz --exclude=./cache -C /data .
sudo docker compose start open-webui
The --exclude=./cache is what keeps the file small. Our first backup without it was 964 MB, almost all of it the cache directory where Open WebUI keeps the embedding model it downloads from Hugging Face, while webui.db was 756 KB. With the cache left out, the same instance produced a 36 KB archive. The cache also holds Whisper speech models and any images Open WebUI generated, in cache/image/generations, so drop the exclude if you keep generated images. The restore below starts with rm -rf /data/*, which wipes the volume before unpacking, so check the file name first. We ran it into the live volume, and Open WebUI came back healthy in 92 seconds with its user and chat intact, fetching the embedding model again on the way up.
cd ~/open-webui
sudo docker compose stop open-webui
sudo docker run --rm -v open-webui_open-webui:/data -v "$PWD":/backup alpine sh -c "rm -rf /data/* && tar xzf /backup/open-webui-2026-10-16.tar.gz -C /data"
sudo docker compose start open-webui
Change the file name to your own backup’s date. You don’t need to back up the models, since they sit in the ollama volume and ollama pull fetches them again. An upgrade re-runs the version lookup, writes the new tags into .env and recreates whatever changed:
cd ~/open-webui
OWUI_VERSION=$(curl -s https://api.github.com/repos/open-webui/open-webui/releases/latest | grep -o '"tag_name": *"[^"]*"' | cut -d '"' -f 4)
OLLAMA_VERSION=$(curl -s https://api.github.com/repos/ollama/ollama/releases/latest | grep -o '"tag_name": *"[^"]*"' | cut -d '"' -f 4 | sed 's/^v//')
sed -i "s/^OWUI_VERSION=.*/OWUI_VERSION=${OWUI_VERSION}/; s/^OLLAMA_VERSION=.*/OLLAMA_VERSION=${OLLAMA_VERSION}/" .env
sudo docker compose pull
sudo docker compose up -d
Our first run found nothing newer and left both containers running. Later the same day Open WebUI v0.12.3 was out, and the same commands, run after the backup above, wrote the new tag into .env, pulled the image, recreated only open-webui and left ollama running. It was healthy again six minutes after the pull started, with the admin account intact,. Read the release notes before a jump of several versions, because Open WebUI migrates its database on the first start of the new image, and its docs are clear that those migrations are one-way.
Fix Open WebUI startup and connection errors
Two errors cost us time in the lab, and you may hit either one. Both print a message you can search for, and neither points at its real cause.
RuntimeError: NumPy was built with baseline optimizations: (X86_V2) but your machine doesn’t support: (X86_V2)
This is the crash from the top of the page. The container starts, Open WebUI runs its database migrations, and then the NumPy import kills the process, so Docker restarts it and the cycle repeats. The end of the log on our kvm64 VM looked like this:
File "/usr/local/lib/python3.11/site-packages/numpy/_core/multiarray.py", line 11, in <module>
from . import _multiarray_umath, overrides
RuntimeError: NumPy was built with baseline optimizations:
(X86_V2) but your machine doesn't support:
(X86_V2).
NumPy 2.4 raised its x86 baseline to x86-64-v2, and the current Open WebUI image ships NumPy 2.4.6, so it needs SSE4.2 and POPCNT. Almost every x86 CPU sold since 2009 has them, but VMs often do not. Proxmox’s backend default CPU type is kvm64, which is x86-64-v1, and VMs created from the command line or from older templates get it even though the web UI now proposes x86-64-v2-AES. Our VM’s /proc/cpuinfo reported Common KVM processor with no sse4_2 flag. Our amd64v3 guide for Ubuntu explains the x86-64 levels in more depth.
The fix is a better CPU type, set on the Proxmox host. Any type from x86-64-v2-AES up is enough for Open WebUI. The host type is the better pick, because it passes the real CPU through and gives Ollama AVX2, which matters far more for CPU inference than SSE4.2 does. Pick x86-64-v3 instead if you live-migrate between nodes with different CPUs. A reboot inside the guest keeps the old CPU model, so stop and start the VM from Proxmox; here 105 is the VM’s ID:
qm set 105 --cpu host
qm shutdown 105 && qm start 105
We moved the stack to a VM set to host and the same image came up healthy with zero restarts, with the loader check from Prerequisites showing x86-64-v2 (supported, searched). Run that check again after the change, before you start the containers.
http://127.0.0.1:8300 is not an accepted origin
This one appears when you open Open WebUI at an address that isn’t in CORS_ALLOW_ORIGIN, including the localhost:3000 tunnel from the admin step, where it is harmless. We hit it by opening the app through an SSH tunnel to port 3000, landing on local port 8300, instead of through Nginx. The page and login form load normally, but chats never stream, and the browser console shows WebSocket connection to 'ws://127.0.0.1:8300/ws/socket.io/?EIO=4&transport=websocket' failed: Error during WebSocket handshake: Unexpected response code: 403. Open WebUI’s log names the cause:
open-webui | http://127.0.0.1:8300 is not an accepted origin. (further occurrences of this error will be logged with level INFO)
Use the HTTPS name and it goes away. If you need a second way in, list it after the first with a semicolon, for example CORS_ALLOW_ORIGIN: "https://${SITE_DOMAIN};http://localhost:3000", and run sudo docker compose up -d from ~/open-webui to recreate the container. When we added the tunnel origin that way, the WebSocket opened on the next page load and the error stopped. Remove the extra origin when you’re done, so the browser checks stay as tight as the rest of the setup.