Skip to content
Level 2 · Local OperatorLabPart 07 · page 5 of 660 minSXMN 8 GB
60Minutes
3Tools
59Sources
All fourTracks
Tools used on this page3

Lab: A Private Chat Service for Your Home Network

Validated on: written from the documentation cited above; not yet validated on hardware on any track. The image digests, tool versions and per-track notes each track was executed with will be recorded here when the validation pass has run this lab on real machines.

Before executing, read the lab execution and evidence guide. Use this lesson's explicit working directories and track setup; keep each server in its own terminal. Record hardware validation as pass, fail or not run, with the evidence requested below.

By the end of this lab you will have a chat service running on one machine in your house that everybody in the house can use from a browser or a phone, over HTTPS, with their own account, and that answers on one address of that machine and on nothing else. You will have proved the last part with a probe run from a second device rather than assumed it, and you will have a backup of the one component whose loss would cost you something, restored into a scratch copy and opened.

This is the first thing in this course other people will depend on, and that changes the question from “how fast is it” to “what can reach it, and what happens when it is not there”.

Three programs, one machine, one open door.

What talks to what, and what is reachable from where

  • clientPhoneOn the house wifi. Speaks HTTPS to one name and trusts one root certificate.
  • clientLaptopSame name, same root certificate. Also runs the exposure probe in task 9.
  • routerReverse proxyCaddy 2.11.4. The only process listening on an address other devices can reach: ports 80 and 443 on LAN_IP.
  • workerFront-endOpen WebUI 0.11.3. Accounts, history, uploads, model permissions. Port 8080 on the private container network only.
  • decodeEngineOllama 0.33.3 on 11434, or llama-server on 8080. No published port at all.
  • storageVolumesengine-models (weights), webui-data (the database), caddy-data (the certificate authority).
Only one arrow crosses the house network. On Track M the front-end and engine are native processes on 127.0.0.1 instead of containers; the shape is the same.

It does not publish the engine. The engine service has no ports: entry, so the only route to it is from another container on the private network. An engine reachable on the network generates whatever anybody asks, with no account, no log and no limit.

It binds the proxy to one address, not to all of them. Compose documents the short port syntax as [HOST:]CONTAINER[/PROTOCOL], where “HOST is [IP:](port | range) (optional). If it is not set, it binds to all network interfaces (0.0.0.0)”, and Docker’s port-publishing page adds that an address-less mapping publishes on “all host addresses (0.0.0.0 and [::])”. What each spelling of the same mapping does:

ports: entry in compose.yaml What listens on the host Who can connect
"443:443" 0.0.0.0:443 and [::]:443 every interface: Wi-Fi, Ethernet, a VPN tunnel, a public IPv6 address
"127.0.0.1:443:443" 127.0.0.1:443 this machine only; the house cannot reach it (Docker’s page warns that releases older than 28.0.0 let hosts on the same switch reach it anyway)
"${LAN_IP}:443:443" <LAN_IP>:443 anything that can route to that one address: the house network

The last row has one documented edge: with IP forwarding enabled, which Docker’s bridge networks use, “the published port can be accessed via other host interfaces using direct routing to the host address”. On a machine with a second network (a VPN, a second card, Part 18’s cluster link), a host on that network that routes to LAN_IP reaches the proxy. With one interface this changes nothing. This course does not add host firewall rules for the other case; if ip -4 -brief addr show scope global lists a second non-Docker interface, the probe in task 9 run from a device on that network is how you check it.

It uses TLS even though the traffic never leaves the building. Without it, every message crosses the Wi-Fi in clear text past devices you did not choose; the stack’s session cookie is Secure, which browsers send only over HTTPS; and with the root installed, a phone can confirm it is talking to your machine and not to something that has claimed the address.

The course lists 60 minutes; the lab has not been timed, and with ten tasks, a certificate installation per device and a restore test, plan on about two hours of attention for a first run, plus the unattended downloads below. The memory floor is 8 GB with a 4B-class model. You also need your router’s settings (a DNS entry and an address reservation, or a task 1 fallback) and a second computer on the house network for task 9.

Pinned versions: Ollama 0.33.3 · verified 2026-09-08, Open WebUI 0.11.3 · verified 2026-09-08, llama.cpp v0.4.0 · verified 2026-09-08 (served from the b10868 container images, the first nightly image build after the course’s pinned build), Docker Engine 28.0.0 or later (checked by the preflight), and uv 0.12.11 · verified 2026-09-08 for Track M. Caddy is pinned by image tag to 2.11.4 in compose.yaml and installed from Homebrew on Track M, where task 4 records the version you got.

Downloads. Compressed sizes from the registries and the Hugging Face file listing on 13 September 2026. Unpacked images take more disk than this; task 4 has you record docker system df afterwards.

Item Track S (arm64) Track X (amd64) Track N (amd64) Track M
ollama/ollama:0.33.3 (or 0.33.3-rocm on X) 2.79 GB 1.44 GB (-rocm) 3.71 GB Ollama app, from the Ollama lesson
ghcr.io/open-webui/open-webui:v0.11.3 1.66 GB 1.82 GB 1.82 GB Python package and dependencies: 263 wheels, 691,901,845 bytes (0.69 GB)
caddy:2.11.4-alpine 0.02 GB 0.02 GB 0.02 GB Homebrew caddy (size not recorded)
alpine:3.24 (backup and restore helper) under 0.01 GB under 0.01 GB under 0.01 GB not used
Model, Ollama path: qwen3:4b-q4_K_M 2.6 GB 2.6 GB 2.6 GB 2.6 GB
llama-server path instead: image 2.19 GB (server-cuda13) 0.31 GB (server-vulkan) 1.88 GB (server-cuda13) or 2.59 GB (server-cuda) not used
llama-server path instead: Qwen3-4B-Q4_K_M.gguf 2,497,281,312 bytes same same not used

The Track M figure sums the PyPI wheels uv 0.12.11 resolved for open-webui==0.11.3 on Python 3.11, macOS 14, Apple silicon. For macOS 13 the resolution fails: the pinned onnxruntime==1.26.0 has macOS wheels only for macOS 14 on arm64.

Unattended download time, as arithmetic from the table: gigabytes × 80 seconds at an example 100 Mbit/s (12.5 MB per second), counting under 0.01 GB as 0.01 and leaving out the preflight’s ubuntu image.

Track Ollama path Minutes at 100 Mbit/s llama-server path, GGUF included Minutes at 100 Mbit/s
S 2.79 + 1.66 + 0.02 + 0.01 + 2.6 = 7.08 GB 566 s, about 9.4 2.19 + 1.66 + 0.02 + 0.01 + 2.50 = 6.38 GB 510 s, about 8.5
X 1.44 + 1.82 + 0.02 + 0.01 + 2.6 = 5.89 GB 471 s, about 7.9 0.31 + 1.82 + 0.02 + 0.01 + 2.50 = 4.66 GB 373 s, about 6.2
N 3.71 + 1.82 + 0.02 + 0.01 + 2.6 = 8.16 GB 653 s, about 10.9 1.88 (or 2.59) + 1.82 + 0.02 + 0.01 + 2.50 = 6.23 (or 6.94) GB 498 (or 555) s, about 8.3 (or 9.3)
M 0.69 + 2.6 = 3.29 GB, plus Homebrew’s caddy 263 s, about 4.4, plus caddy not used not used

Memory. What the engine allocates for this model, as arithmetic from stated inputs: the key-value cache for Qwen3-4B (Apache-2.0, not gated) is 2 × 36 layers × 8 KV heads × 128 head_dim × 2 bytes = 147,456 bytes per token at 16-bit precision (the figure in models.json, read from the model’s config.json), and the GGUF weights are the byte count above. Compute buffers and the front-end’s own memory come on top and are not estimated here; task 8 has you measure them.

Setting KV cache bytes KV cache Plus Q4_K_M weights
context 8,192, one slot (this lab’s .env) 147,456 × 8,192 = 1,207,959,552 1.21 GB 3.71 GB
context 8,192, two slots on Ollama × 2 = 2,415,919,104 2.42 GB 4.91 GB
context 16,384, one slot 2,415,919,104 2.42 GB 4.91 GB
context 32,768, one slot 4,831,838,208 4.83 GB 7.33 GB

The second row is where the two engines differ: the Ollama FAQ states that “Required RAM will scale by OLLAMA_NUM_PARALLEL * OLLAMA_CONTEXT_LENGTH”, while llama-server at b10868 divides --ctx-size across its slots when the slot count is set explicitly, so the same two settings give it two slots of 4,096 tokens in the memory of one slot of 8,192. The reality check after this lab chooses the context number; this lab only makes it a line in a file. In this table GB means 10^9 bytes; card and memory sizes sold as “8 GB” are 8 GiB, which is 8.59 GB.

Track S — NVIDIA DGX Spark

Docker Engine with the Compose plugin and the NVIDIA Container Toolkit, which DGX OS ships; the preflight proves it. Every image this track uses publishes linux/arm64 (registries checked 13 September 2026), and task 4 confirms Docker chose it. With 128 GB of unified memory the table’s interesting column is slots, not fit; start with the 4B model and change it after the reality check.

Track X — AMD Ryzen AI Max+ 395

Docker Engine with the Compose plugin on Linux. The Ollama path uses the ROCm image (an AMD ROCm v7 driver stack on the host, and the two devices the Ollama Docker page passes, /dev/kfd and /dev/dri); the llama-server path uses the Vulkan image, the course’s default on this chip since Part 6. On SELinux systems, run sudo setsebool container_use_devices=1 first, the form Ollama’s GPU page documents; it lasts until the next reboot, and sudo setsebool -P container_use_devices=1 writes it to the policy on disk so that a service that stays running survives one.

A Windows reader on this track boots Linux for this lab: the device passthrough it relies on is the Linux one.

Track M — Apple siliconPartial

Docker Desktop on macOS has no GPU passthrough, so the engine and front-end run natively on 127.0.0.1 and only Caddy listens on the house network.

The engine is the Ollama application from the Ollama lesson, native because the Ollama FAQ states that “GPU acceleration is not available for Docker Desktop in macOS due to the lack of GPU passthrough and emulation”. The front-end runs from a Python 3.11 environment made with uv (Open WebUI supports 3.11 and 3.12 and recommends 3.11 for production), and Caddy comes from Homebrew. The front-end’s pinned dependencies need macOS 14 or later on Apple silicon. On 8 GB the model, the front-end, the browser and macOS share one pool; close what you can before task 8.

Track N — NVIDIA desktop or laptop

Docker Engine with the Compose plugin and the NVIDIA Container Toolkit on x86-64. Memory means video memory: the 3.71 GB row leaves 4.88 GB of an 8 GB card (8 GiB = 8.59 GB, minus 3.71 GB) for compute buffers and anything else on it, which task 5’s PROCESSOR column checks.

Linux is the primary path. On Windows, run the lab in WSL2 with Docker Engine inside the distribution and networkingMode=mirrored under [wsl2] in .wslconfig (Windows 11 22H2 or later), which Microsoft’s WSL networking page documents as letting the house network connect to WSL directly. Of that page’s two Hyper-V firewall changes, use the port-scoped rule, for TCP 80 and 443 only; its -DefaultInboundAction Allow alternative opens every inbound port:

RunnableTrack N · Windows

PowerShell as administrator: allow only 80 and 443 into WSL
New-NetFirewallHyperVRule -Name "HomeChatHttp" -DisplayName "Home chat HTTP" -Direction Inbound -VMCreatorId '{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}' -Protocol TCP -LocalPorts 80
New-NetFirewallHyperVRule -Name "HomeChatHttps" -DisplayName "Home chat HTTPS" -Direction Inbound -VMCreatorId '{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}' -Protocol TCP -LocalPorts 443

This Windows path is written from that page and has not been validated.

Run these on the machine that will host the service, before task 1. Each block says what a pass looks like.

Track S — NVIDIA DGX Spark

RunnableTrack S · DGX Spark

Docker, Compose, the GPU inside a container, disk, competitors, helper tools
docker version --format '{{.Server.Version}}'
docker compose version
sudo docker run --rm --runtime=nvidia --gpus all ubuntu nvidia-smi
df -h "$(docker info --format '{{.DockerRootDir}}')"
systemctl is-active ollama
sudo ss -ltnpH '( sport = :80 or sport = :443 )'
command -v openssl
command -v curl
command -v python3

Output — what you should see

29.x.x
Docker Compose version vX.Y.Z
+-----------------------------------------------------------------------------------------+
| NVIDIA-SMI xxx.xx Driver Version: xxx.xx CUDA Version: 13.x |
...
| 0 NVIDIA GB10 ...
Filesystem Size Used Avail Use% Mounted on
/dev/nvme0n1p2 xxxG xxxG xxxG xx% /
inactive
/usr/bin/openssl
/usr/bin/curl
/usr/bin/python3

Pass: server 28.0.0 or later, a GPU table from inside the container, at least 25 GB available (this lab’s margin for images, model and backups), inactive, nothing from ss, and three paths.

llama-server path only:

RunnableTrack S · DGX Spark

the GGUF for the llama-server path
ls -l ~/models/unsloth/Qwen3-4B-GGUF/Qwen3-4B-Q4_K_M.gguf
sha256sum ~/models/unsloth/Qwen3-4B-GGUF/Qwen3-4B-Q4_K_M.gguf

Output — what you should see

-rw-rw-r-- 1 you you 2497281312 ... /home/you/models/unsloth/Qwen3-4B-GGUF/Qwen3-4B-Q4_K_M.gguf
f6f851777709861056efcdad3af01da38b31223a3ba26e61a4f8bf3a2195813a /home/you/models/unsloth/Qwen3-4B-GGUF/Qwen3-4B-Q4_K_M.gguf

Track X — AMD Ryzen AI Max+ 395

RunnableTrack X · Ryzen AI Max+

Docker, Compose, the GPU device nodes, disk, competitors, helper tools
docker version --format '{{.Server.Version}}'
docker compose version
ls -l /dev/kfd /dev/dri/renderD*
df -h "$(docker info --format '{{.DockerRootDir}}')"
systemctl is-active ollama
sudo ss -ltnpH '( sport = :80 or sport = :443 )'
command -v openssl
command -v curl
command -v python3

Output — what you should see

29.x.x
Docker Compose version vX.Y.Z
crw-rw---- 1 root render 2xx, 0 ... /dev/kfd
crw-rw---- 1 root render 226, 128 ... /dev/dri/renderD128
Filesystem Size Used Avail Use% Mounted on
/dev/nvme0n1p2 xxxG xxxG xxxG xx% /
inactive
/usr/bin/openssl
/usr/bin/curl
/usr/bin/python3

Pass: server 28.0.0 or later, both device nodes, at least 25 GB available, inactive, nothing from ss, three paths. Without /dev/kfd the ROCm driver is not loaded; the Vulkan image of the llama-server path needs only /dev/dri. If /dev/kfd is missing and you take the Vulkan path, delete the - /dev/kfd line from compose-amd.yaml before task 4: Docker refuses to start a container whose listed device does not exist.

llama-server path only:

RunnableTrack X · Ryzen AI Max+

the GGUF for the llama-server path
ls -l ~/models/unsloth/Qwen3-4B-GGUF/Qwen3-4B-Q4_K_M.gguf
sha256sum ~/models/unsloth/Qwen3-4B-GGUF/Qwen3-4B-Q4_K_M.gguf

Output — what you should see

-rw-rw-r-- 1 you you 2497281312 ... /home/you/models/unsloth/Qwen3-4B-GGUF/Qwen3-4B-Q4_K_M.gguf
f6f851777709861056efcdad3af01da38b31223a3ba26e61a4f8bf3a2195813a /home/you/models/unsloth/Qwen3-4B-GGUF/Qwen3-4B-Q4_K_M.gguf

Track M — Apple silicon

RunnableTrack M · Apple silicon

macOS version, Ollama answering, uv, Homebrew, disk, ports, helper tools
sw_vers -productVersion
uname -m
curl -sS http://127.0.0.1:11434/api/version
ollama --version
uv --version
brew --version
df -h ~
sudo lsof -nP -iTCP:80 -iTCP:443 -sTCP:LISTEN
command -v openssl
command -v curl
command -v python3

Output — what you should see

xx.x
arm64
{"version":"0.33.3"}
ollama version is 0.33.3
uv 0.12.x (...)
Homebrew x.y.z
Filesystem Size Used Avail Capacity ... Mounted on
/dev/disk3s5 xxxGi xxxGi xxxGi xx% ... /System/Volumes/Data
/usr/bin/openssl
/usr/bin/curl
/usr/bin/python3

Pass: a first number of 14 or higher and arm64 (the pinned front-end cannot be installed otherwise); Ollama answers on 127.0.0.1 (open the application if curl is refused); uv and brew exist; at least 10 GB available; nothing from lsof, because Caddy needs ports 80 and 443; three paths.

Track N — NVIDIA desktop or laptop

RunnableTrack N · NVIDIA GPU

Docker, Compose, the GPU inside a container, disk, competitors, helper tools
docker version --format '{{.Server.Version}}'
docker compose version
sudo docker run --rm --runtime=nvidia --gpus all ubuntu nvidia-smi
df -h "$(docker info --format '{{.DockerRootDir}}')"
systemctl is-active ollama
sudo ss -ltnpH '( sport = :80 or sport = :443 )'
command -v openssl
command -v curl
command -v python3

Output — what you should see

29.x.x
Docker Compose version vX.Y.Z
+-----------------------------------------------------------------------------------------+
| NVIDIA-SMI xxx.xx Driver Version: xxx.xx CUDA Version: 1x.x |
...
Filesystem Size Used Avail Use% Mounted on
/dev/nvme0n1p2 xxxG xxxG xxxG xx% /
inactive
/usr/bin/openssl
/usr/bin/curl
/usr/bin/python3

Pass: server 28.0.0 or later, a GPU table from inside the container, at least 25 GB available, inactive, nothing from ss, three paths. Note the header’s CUDA Version: 13.x can use the server-cuda13 llama-server image, 12.x needs server-cuda.

llama-server path only:

RunnableTrack N · NVIDIA GPU

the GGUF for the llama-server path
ls -l ~/models/unsloth/Qwen3-4B-GGUF/Qwen3-4B-Q4_K_M.gguf
sha256sum ~/models/unsloth/Qwen3-4B-GGUF/Qwen3-4B-Q4_K_M.gguf

Output — what you should see

-rw-rw-r-- 1 you you 2497281312 ... /home/you/models/unsloth/Qwen3-4B-GGUF/Qwen3-4B-Q4_K_M.gguf
f6f851777709861056efcdad3af01da38b31223a3ba26e61a4f8bf3a2195813a /home/you/models/unsloth/Qwen3-4B-GGUF/Qwen3-4B-Q4_K_M.gguf

On Tracks S, X and N the first line runs without sudo, as every docker compose command in this lab does; permission denied there means your account cannot use Docker without sudo (troubleshooting has the fix). Paths from command -v may differ. active means the Ollama lesson’s system service is running, a second engine competing for the same GPU memory; stop it with sudo systemctl stop ollama (the container has its own model store). A line from ss or lsof names a process already holding port 80 or 443. The GGUF’s size and SHA-256 are the Hub’s listing on 13 September 2026; if the file is missing, fetch it with hf download unsloth/Qwen3-4B-GGUF Qwen3-4B-Q4_K_M.gguf --local-dir ~/models/unsloth/Qwen3-4B-GGUF, the layout of managing a model library.

1. Choose a name, and find the address it should resolve to

Section titled “1. Choose a name, and find the address it should resolve to”

Give the service a name now, because the certificate is issued for it. Use something under home.arpa: RFC 8375 designates that domain “for non-unique use in residential home networks” and states that a name under it “is not globally unique”, which is precisely the property you want. chat.home.arpa is the name this lab assumes.

Find the address of the interface that carries the machine’s default route; that is the address the house network knows it by.

Track S — NVIDIA DGX Spark

RunnableTrack S · DGX Spark

default route, then that interface's address
ip -4 route show default
ip -4 -brief addr show scope global

Output — what you should see

default via <router-address> dev <interface> proto dhcp src <LAN_IP> metric xxx
<interface> UP <LAN_IP>/24
docker0 DOWN 172.17.0.1/16

Track X — AMD Ryzen AI Max+ 395

RunnableTrack X · Ryzen AI Max+

default route, then that interface's address
ip -4 route show default
ip -4 -brief addr show scope global

Output — what you should see

default via <router-address> dev <interface> proto dhcp src <LAN_IP> metric xxx
<interface> UP <LAN_IP>/24
docker0 DOWN 172.17.0.1/16

Track M — Apple silicon

RunnableTrack M · Apple silicon

default route's interface, then its address
route -n get default | grep interface
ipconfig getifaddr en0

Output — what you should see

interface: en0
<LAN_IP>

If the first command names another interface, use that name in the second.

Track N — NVIDIA desktop or laptop

RunnableTrack N · NVIDIA GPU

default route, then that interface's address
ip -4 route show default
ip -4 -brief addr show scope global

Output — what you should see

default via <router-address> dev <interface> proto dhcp src <LAN_IP> metric xxx
<interface> UP <LAN_IP>/24
docker0 DOWN 172.17.0.1/16

The interface names differ between machines; the address after src on the default route is the one you want. docker0 and any br-… interfaces are Docker’s own bridges, never the house network. If the brief listing shows more than one non-Docker interface, write them all down: that is the second-network case from the architecture section, and task 9 compares against this list.

Then, in the router’s settings, do two things:

  1. Reserve that address for this machine (often called a DHCP reservation or static lease). compose.yaml publishes on a literal address; if the router later hands the machine a different one, the proxy container fails to start with an error ending in bind: cannot assign requested address.
  2. Make the name resolve to it, by whichever of these the router supports:
Method Works on Name to put in CHAT_HOST Choose it when
Static DNS entry on the router every device using the router for DNS, phones included chat.home.arpa the router offers local DNS entries; it is the only option that needs no per-device work
The machine’s mDNS name Apple devices; Linux and Windows with mDNS enabled <hostname>.local the router has no DNS entries; check that your Android phone resolves it before relying on it
A hosts-file line on each computer laptops and desktops chat.home.arpa nothing else is possible; phones cannot use it without extra software

Check from a laptop, not the server:

RunnableAll tracks

does the name resolve on another computer?
CHAT_HOST=chat.home.arpa # or your <hostname>.local name, if you chose mDNS
getent hosts "$CHAT_HOST" || dscacheutil -q host -a name "$CHAT_HOST"

Output — what you should see

<LAN_IP> chat.home.arpa

On macOS the second command answers instead, as name: chat.home.arpa and ip_address: <LAN_IP>. No output means the name does not resolve; see the troubleshooting row for a name that does not resolve. Commands on the server read the name from CHAT_HOST in .env (task 2), and the browser addresses on this page are https:// followed by that name. Record in the notebook: interface name, LAN_IP, the resolution method, CHAT_HOST, and whether the router reservation is set.

These files make up the stack. Read each of them before you run anything; between them they decide what is exposed. First create the directory they go in:

RunnableAll tracks

the stack's directory, and the one the Caddyfile goes in
mkdir -p ~/home-chat/caddy

Then save each file into ~/home-chat with the download link under it.

RunnableAll tracks

compose.yaml
# Purpose: a private chat service for one home network - an Ollama engine, an Open WebUI
# front-end and a Caddy reverse proxy that terminates TLS with a locally issued
# certificate. Only Caddy publishes ports, and only on the one address named in
# .env, so nothing on this stack answers on any other interface of the machine.
# Platform: spark, strix, nvidia (Linux with Docker Engine 28 or later and the Compose
# plugin). Track M runs the engine and the front-end natively; see the lab page.
# Minimum memory: 8 GB
# Assumes: beside this file, a .env copied from env-example.txt and filled in, and
# caddy/Caddyfile copied from caddy.conf. COMPOSE_FILE in .env adds the track
# override (compose-nvidia.yaml or compose-amd.yaml) and, optionally,
# compose-llama-server.yaml, so plain `docker compose ...` commands in this
# directory always use the same set of files.
# Without an accelerator override the engine runs on the CPU, which works and is slow.
name: home-chat
services:
engine:
image: ollama/ollama:0.33.3
restart: unless-stopped
environment:
# Inside the container only. There is no ports: key on this service, so the sole
# route to 11434 is from another container on the private network below.
OLLAMA_HOST: "0.0.0.0:11434"
OLLAMA_CONTEXT_LENGTH: "${OLLAMA_CONTEXT_LENGTH:?set OLLAMA_CONTEXT_LENGTH in .env}"
OLLAMA_NUM_PARALLEL: "${OLLAMA_NUM_PARALLEL:?set OLLAMA_NUM_PARALLEL in .env}"
OLLAMA_KEEP_ALIVE: "${OLLAMA_KEEP_ALIVE:?set OLLAMA_KEEP_ALIVE in .env}"
volumes:
- engine-models:/root/.ollama
networks:
- private
healthcheck:
test: ["CMD", "ollama", "ps"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
webui:
image: ghcr.io/open-webui/open-webui:v0.11.3
restart: unless-stopped
depends_on:
- engine
environment:
# Settings marked ConfigVar in the Open WebUI documentation (the connection URLs,
# ENABLE_SIGNUP, DEFAULT_USER_ROLE, WEBUI_URL) are read from here on the FIRST start
# only and then stored in the database. Change them later in Admin Panel > Settings.
OLLAMA_BASE_URL: "http://engine:11434"
# No OpenAI-compatible connection on the Ollama path, so the front-end never tries
# the default https://api.openai.com/v1; and no check for new releases on start.
ENABLE_OPENAI_API: "false"
ENABLE_VERSION_UPDATE_CHECK: "false"
WEBUI_SECRET_KEY: "${WEBUI_SECRET_KEY:?generate WEBUI_SECRET_KEY with openssl rand -hex 32}"
WEBUI_URL: "https://${CHAT_HOST:?set CHAT_HOST in .env}"
CORS_ALLOW_ORIGIN: "https://${CHAT_HOST}"
# Created at start-up when the database has no account yet, before the site
# answers, so nobody else in the house can register first. Remove the password from
# .env once you have signed in and changed it (task 7); empty values do nothing.
WEBUI_ADMIN_EMAIL: "${WEBUI_ADMIN_EMAIL:-}"
WEBUI_ADMIN_PASSWORD: "${WEBUI_ADMIN_PASSWORD:-}"
WEBUI_ADMIN_NAME: "${WEBUI_ADMIN_NAME:-Admin}"
# Nobody can register themselves; the administrator adds people from Admin Panel >
# Users, and any account that does appear some other way has no access until approved.
ENABLE_SIGNUP: "false"
DEFAULT_USER_ROLE: "pending"
# Cookies only over HTTPS, and not sent with cross-site requests (hardening guide).
WEBUI_SESSION_COOKIE_SECURE: "true"
WEBUI_SESSION_COOKIE_SAME_SITE: "strict"
volumes:
# The one volume on this stack that holds state nobody else has a copy of.
- webui-data:/app/backend/data
networks:
- private
caddy:
image: caddy:2.11.4-alpine
restart: unless-stopped
depends_on:
- webui
environment:
CHAT_HOST: "${CHAT_HOST}"
WEBUI_UPSTREAM: "webui:8080"
ports:
# The host address is the whole exposure control on this stack. Without it Compose
# binds 0.0.0.0 (every IPv4 interface) and Docker adds [::] as well. Port 80 only
# answers with a redirect to https://.
- "${LAN_IP:?set LAN_IP in .env to this machine's home-network address}:80:80"
- "${LAN_IP}:443:443"
volumes:
# A directory, not a single file, as the caddy image documentation recommends.
- ./caddy:/etc/caddy:ro
# /data holds the local certificate authority whose root your devices trust.
- caddy-data:/data
- caddy-config:/config
networks:
- private
networks:
private:
driver: bridge
volumes:
engine-models:
webui-data:
caddy-data:
caddy-config:

Download compose.yaml106 lines

RunnableAll tracks

caddy.conf
# Purpose: terminate TLS for one hostname with a certificate issued by Caddy's own local
# certificate authority, and proxy every request to the chat front-end.
# Platform: all (the caddy:2.11.4-alpine image on Tracks S, X and N; Homebrew's caddy
# running natively on Track M)
# Minimum memory: 8 GB
# Assumes: copied to caddy/Caddyfile beside compose.yaml; the container mounts that
# directory at /etc/caddy, and Track M passes the file to
# `caddy run --config`. Three environment variables:
# CHAT_HOST the site name, for example chat.home.arpa
# WEBUI_UPSTREAM the front-end: webui:8080 in Compose, 127.0.0.1:8080 natively
# BIND_ADDR the address to listen on. Set it when Caddy runs on the host
# (Track M). In a container leave it unset: the container's
# 0.0.0.0 is private, and the published port in compose.yaml
# is what restricts the host address.
#
# {$NAME:default} is replaced from the environment before the file is parsed, so an
# unset variable silently becomes the default after the colon. `caddy adapt` shows the
# result: the "listen" address and the "dial" upstream it prints are what will be used.
{$CHAT_HOST:chat.home.arpa} {
# Certificates from Caddy's internal CA named "local". Defaults in the tls directive
# documentation: root valid 3600 days, intermediate 7 days, site certificate 12 hours,
# all renewed by Caddy itself. Devices trust the root, so the root is the only thing
# you ever install on a phone, and the caddy-data volume holding its key is worth
# keeping.
tls internal
bind {$BIND_ADDR:0.0.0.0}
# A negative flush_interval turns off response buffering, so each write is sent to
# the browser at once. The reverse_proxy documentation says Caddy already flushes
# text/event-stream responses and responses of unknown Content-Length immediately,
# which covers this front-end's streamed answers, so the line changes nothing visible
# here; it is kept so that a front-end sending a fixed Content-Length still streams.
# WebSocket upgrades are handled by reverse_proxy without any directive.
reverse_proxy {$WEBUI_UPSTREAM:webui:8080} {
flush_interval -1
}
# One access-log line per request on stderr (docker compose logs caddy), including
# the client address and User-Agent, which is how you see the phone arrive.
log {
output stderr
format console
}
}

Download caddy.conf46 lines

Fragment — not complete on its own

env-example.txt
# Purpose: the settings compose.yaml, its overrides and caddy.conf read. Copy this file to
# .env beside compose.yaml and fill in the lines that are empty. Nothing here is a
# secret except WEBUI_SECRET_KEY, which you generate rather than copy.
# Platform: all (Track M reads CHAT_HOST, LAN_IP, WEBUI_SECRET_KEY and the WEBUI_ADMIN_ lines)
# Minimum memory: 8 GB
# Assumes: `cp env-example.txt .env` and then an editor. Docker Compose reads .env from
# the directory you run it in. Values are literal: no ~, no $HOME, no quotes.
# Which Compose files every `docker compose` command in this directory uses, joined by
# colons. Pick the line for your track and engine; leave the others commented out.
# Track S or N, Ollama:
#COMPOSE_FILE=compose.yaml:compose-nvidia.yaml
# Track X, Ollama:
#COMPOSE_FILE=compose.yaml:compose-amd.yaml
# Track S or N, llama-server:
#COMPOSE_FILE=compose.yaml:compose-nvidia.yaml:compose-llama-server.yaml
# Track X, llama-server:
#COMPOSE_FILE=compose.yaml:compose-amd.yaml:compose-llama-server.yaml
# The name you will type into a browser. Anything under .home.arpa is reserved by RFC 8375
# for names that mean something inside one home network and nothing outside it.
CHAT_HOST=chat.home.arpa
# This machine's address on your home network, and nothing else. Find it with
# `ip -4 -brief addr show scope global` on Linux or `ipconfig getifaddr en0` on macOS.
# Compose refuses to start while this is empty.
LAN_IP=
# Generate with: openssl rand -hex 32
# Signs login tokens. Changing it logs everyone out; losing it is not fatal.
WEBUI_SECRET_KEY=
# The administrator, created on the front-end's first start before it accepts any
# connection. The address is only a login name; nothing is sent to it. Generate a
# temporary password with `openssl rand -base64 18`, change it in the web interface
# after the first sign-in, then delete the WEBUI_ADMIN_PASSWORD line (task 7).
WEBUI_ADMIN_NAME=Admin
WEBUI_ADMIN_EMAIL=
WEBUI_ADMIN_PASSWORD=
# Context length the engine allocates per conversation slot, in tokens. The reality check
# after this lab is how you choose your own number.
OLLAMA_CONTEXT_LENGTH=8192
# Conversations the engine processes at the same time. On Ollama each one gets its own
# OLLAMA_CONTEXT_LENGTH of key-value cache, so 2 costs twice the cache memory of 1.
# On the llama-server path (compose-llama-server.yaml) the context is divided among the
# slots instead, so memory stays the same and each slot gets
# OLLAMA_CONTEXT_LENGTH / OLLAMA_NUM_PARALLEL tokens.
OLLAMA_NUM_PARALLEL=1
# How long a model stays in memory after the last request (Ollama only; llama-server
# keeps its one model loaded for as long as it runs). The Ollama default is 5m.
OLLAMA_KEEP_ALIVE=30m
# llama-server only (compose-llama-server.yaml). Image tags checked on 2026-09-13; the
# b10868 images were built on 2026-09-09. Pick one:
# Track S (arm64, CUDA 13): ghcr.io/ggml-org/llama.cpp:server-cuda13-b10868
# Track N, driver supports CUDA 13: ghcr.io/ggml-org/llama.cpp:server-cuda13-b10868
# Track N, driver supports CUDA 12 only: ghcr.io/ggml-org/llama.cpp:server-cuda-b10868
# Track X, Vulkan (the course default): ghcr.io/ggml-org/llama.cpp:server-vulkan-b10868
LLAMA_SERVER_IMAGE=
# Absolute path of your model library, for example /home/you/models
MODELS_DIR=
# Path of the GGUF file inside MODELS_DIR
LLAMA_MODEL_FILE=unsloth/Qwen3-4B-GGUF/Qwen3-4B-Q4_K_M.gguf
# The model name the front-end will list
LLAMA_MODEL_ALIAS=qwen3-4b

Download env-example.txt68 lines

The accelerator overrides, compose-nvidia.yaml for Tracks S and N and compose-amd.yaml for Track X; each adds the GPU to the engine container and changes nothing else:

RunnableAll tracks

compose-nvidia.yaml
# Purpose: override for compose.yaml that gives the engine container the machine's NVIDIA
# GPUs. Used on Track S (DGX Spark, aarch64) and Track N (desktop or laptop).
# Platform: spark, nvidia
# Minimum memory: 8 GB
# Assumes: the NVIDIA Container Toolkit is installed and `nvidia-ctk runtime configure`
# has been run, so `docker run --rm --gpus all ubuntu nvidia-smi` already works.
#
# Usage: COMPOSE_FILE=compose.yaml:compose-nvidia.yaml in .env, then docker compose up -d
# (the reservation applies to whichever image the engine service uses, so it also
# serves llama-server when compose-llama-server.yaml is listed after it)
services:
engine:
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]

Download compose-nvidia.yaml20 lines

RunnableTrack X · Ryzen AI Max+

compose-amd.yaml
# Purpose: override for compose.yaml that gives the engine container the machine's AMD
# GPU, and swaps in the ROCm build of the Ollama image. Used on Track X.
# Platform: strix
# Minimum memory: 8 GB
# Assumes: an AMD ROCm v7 driver stack on the host, and that the container is allowed to
# open /dev/kfd and /dev/dri. On SELinux systems run
# `sudo setsebool container_use_devices=1` first (add -P to keep it after a
# reboot). Delete the `image:` line to keep the default ollama/ollama image, which
# uses its bundled Vulkan backend instead. Both device nodes must exist: Docker
# refuses to create a container whose listed device is missing ("error gathering
# device information while adding custom device"), so on a host without /dev/kfd
# that takes the Vulkan path, delete the `- /dev/kfd` line below as well.
# The ROCm tag is published for linux/amd64 only, which is what this machine is.
#
# Usage: COMPOSE_FILE=compose.yaml:compose-amd.yaml in .env, then docker compose up -d
# (the same file also passes the devices to llama-server when compose-llama-server.yaml
# is listed after it)
services:
engine:
image: ollama/ollama:0.33.3-rocm
# The two device nodes the Ollama Docker documentation passes for AMD GPUs. The
# container runs as root, so no group_add is needed to open them.
devices:
- /dev/kfd
- /dev/dri

Download compose-amd.yaml26 lines

And, only if you choose llama-server as the engine (task 3 helps you decide), the override that swaps it in:

RunnableAll tracks

compose-llama-server.yaml
# Purpose: override for compose.yaml that replaces the Ollama engine with llama-server from
# the llama.cpp container images, serving one GGUF file from your model library,
# and points the front-end at its OpenAI-compatible API instead of Ollama's.
# Platform: spark, strix, nvidia (list it LAST in COMPOSE_FILE, after the track override,
# so that its image wins and the track override's GPU access still applies)
# Minimum memory: 8 GB
# Assumes: in .env, LLAMA_SERVER_IMAGE names a llama.cpp server image built for this
# machine (server-cuda13-*, server-cuda-*, server-vulkan-* or server-rocm-*),
# MODELS_DIR is the absolute path of the model library from Parts 6 and 7, and
# LLAMA_MODEL_FILE is the GGUF path inside it. Decide on this engine BEFORE the
# front-end's first start: Open WebUI stores its connection settings in its
# database on first start and ignores changed environment values afterwards.
#
# Usage: COMPOSE_FILE=compose.yaml:compose-nvidia.yaml:compose-llama-server.yaml in .env,
# then docker compose up -d
#
# Merge notes (Compose merge rules): image, command and healthcheck test replace the
# base values; environment and volumes are added to them. The Ollama variables and the
# engine-models volume therefore remain on the container, unused by llama-server.
services:
engine:
image: "${LLAMA_SERVER_IMAGE:?set LLAMA_SERVER_IMAGE in .env}"
# The image's entrypoint is /app/llama-server; these are its arguments.
command:
- "--model"
- "/models/${LLAMA_MODEL_FILE:?set LLAMA_MODEL_FILE in .env}"
- "--alias"
- "${LLAMA_MODEL_ALIAS:?set LLAMA_MODEL_ALIAS in .env}"
# One context-length setting for both engines, so the reality check's change to
# OLLAMA_CONTEXT_LENGTH applies whichever engine is running. The two engines agree
# only at one slot: llama-server divides --ctx-size across --parallel slots, while
# Ollama gives every slot the whole OLLAMA_CONTEXT_LENGTH.
- "--ctx-size"
- "${OLLAMA_CONTEXT_LENGTH}"
- "--parallel"
- "${OLLAMA_NUM_PARALLEL}"
- "--host"
- "0.0.0.0"
- "--port"
- "8080"
volumes:
# Read-only: the engine has no business writing to your model library.
- "${MODELS_DIR:?set MODELS_DIR in .env to an absolute path}:/models:ro"
healthcheck:
# The llama.cpp server image's own health check, and the endpoint the server README
# documents as public: 200 once the model is loaded, 503 while it is loading.
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
webui:
environment:
ENABLE_OLLAMA_API: "false"
ENABLE_OPENAI_API: "true"
OPENAI_API_BASE_URL: "http://engine:8080/v1"
# llama-server was started without --api-key, so there is no key to send. It is
# still unreachable from the house: engine has no published port.
OPENAI_API_KEY: ""

Download compose-llama-server.yaml57 lines

Track M skips compose-llama-server.yaml and both accelerator overrides. Put the Caddyfile where the container expects a directory, create .env, and generate the two secrets:

RunnableAll tracks

create the Caddyfile, the settings file and two secrets
cp ~/home-chat/caddy.conf ~/home-chat/caddy/Caddyfile
cp ~/home-chat/env-example.txt ~/home-chat/.env
chmod 600 ~/home-chat/.env
openssl rand -hex 32
openssl rand -base64 18

Output — what you should see

<64 hexadecimal characters>
<24 characters of base64>

Open ~/home-chat/.env in an editor and set these lines. Leave the rest as they are.

Line Value Where it comes from
COMPOSE_FILE= remove the # from exactly one of the four lines your track and engine; Track M skips this line
CHAT_HOST= keep chat.home.arpa, or replace it with your .local name task 1’s resolution method
LAN_IP= the address from task 1 task 1
WEBUI_SECRET_KEY= the 64-character hex string the first openssl line
WEBUI_ADMIN_EMAIL= a login name in e-mail form, for example admin@example.com your choice; nothing is sent to it
WEBUI_ADMIN_PASSWORD= the 24-character base64 string the second openssl line, temporary until task 7
LLAMA_SERVER_IMAGE= llama-server path only: the line for your track in the file’s comment preflight (CUDA Version on Track N)
MODELS_DIR= llama-server path only: the absolute path, for example /home/you/models echo "$HOME/models"

COMPOSE_FILE is a variable Compose reads from .env in the directory you run it in, so every docker compose command in ~/home-chat uses the same files: nobody can recreate the engine without its GPU by forgetting a -f. The hardening page describes WEBUI_SECRET_KEY as the key that signs login tokens; Open WebUI would generate one inside the container, and an explicit value survives a container being recreated.

On Tracks S, X and N, let Compose check the result before anything starts:

RunnableAll tracks

resolve the configuration without starting anything
cd ~/home-chat
docker compose config --quiet && echo "configuration OK"
docker compose config --images
docker compose config | grep host_ip

Output — what you should see

configuration OK
ollama/ollama:0.33.3
ghcr.io/open-webui/open-webui:v0.11.3
caddy:2.11.4-alpine
host_ip: <LAN_IP>
host_ip: <LAN_IP>

Pass: configuration OK, three images in any order including the engine image you intended (ollama/ollama:0.33.3-rocm on Track X with Ollama, a ghcr.io/ggml-org/llama.cpp:server-… tag on the llama-server path), and two host_ip lines carrying your address. An empty value stops here instead of at start-up; with Compose v5.5.1 the message reads required variable LAN_IP is missing a value: set LAN_IP in .env to this machine's home-network address, and the wording may differ in other Compose versions.

Most of the file is plumbing. These lines are the lab:

Line What it does What happens without it
no ports: under engine 11434 (or llama-server’s 8080) exists only on the private network an unauthenticated text generator on your network
"${LAN_IP:?…}:80:80", "${LAN_IP}:443:443" publishes on one address, and refuses to start without one 0.0.0.0 and [::], per the architecture table
OLLAMA_BASE_URL: "http://engine:11434" the front-end reaches the engine by service name localhost inside a container is that container: no models
WEBUI_ADMIN_EMAIL, WEBUI_ADMIN_PASSWORD the administrator is created during start-up, before the site answers whoever opens the site first becomes administrator
ENABLE_SIGNUP: "false", DEFAULT_USER_ROLE: "pending" nobody registers themselves; an account that appears any other way has no access until approved any household device can create accounts
WEBUI_SESSION_COOKIE_SECURE, …_SAME_SITE login cookie sent only over HTTPS, not on cross-site requests the hardening page’s defaults, false and lax
ENABLE_OPENAI_API: "false", ENABLE_VERSION_UPDATE_CHECK: "false" no OpenAI-compatible connection, no release check the pinned source lists models from the default https://api.openai.com/v1 entry and asks api.github.com for the latest release
OLLAMA_CONTEXT_LENGTH, OLLAMA_NUM_PARALLEL the memory table’s inputs, from .env the tool’s defaults, which depend on the machine’s memory (task 5, Track M)
tls internal (Caddyfile) certificates from Caddy’s own CA; no public CA contacted, nothing published to certificate transparency logs Caddy would try to obtain a public certificate for a name that does not exist outside the house
flush_interval -1 (Caddyfile) response buffering off, so each write reaches the browser at once nothing visible for this stack: the reverse_proxy docs say Caddy already flushes text/event-stream and unknown-length responses immediately. The line is kept so that a front-end sending a fixed Content-Length still streams

The Open WebUI settings above have a property that decides the order of this lab. The environment variable reference marks many of them ConfigVar: “After the initial launch, if you restart the container, ConfigVar environment variables will no longer use the external environment variable values. Instead, they will use the internally stored values.” The connection settings, ENABLE_SIGNUP and DEFAULT_USER_ROLE are among them. So the engine you start with is the one the front-end remembers; switching later means editing the connection in the web interface, not in .env. Choose now:

Ollama llama-server
Model comes from Ollama’s library, pulled into the engine-models volume (task 5) a GGUF in your ~/models library, mounted read-only
Which model runs any pulled model, loaded on request, unloaded after OLLAMA_KEEP_ALIVE the one file named in .env, loaded for as long as the container runs
Front-end connection Ollama’s native API OpenAI-compatible, http://engine:8080/v1
Several slots each slot gets the full context; memory multiplies --ctx-size is divided among slots; memory does not grow
Choose it when several models share the machine and some sit idle you already standardised on llama.cpp in Part 6, or want the model resident at all times
Tracks S, X, N, M S, X, N

Track S — NVIDIA DGX Spark

RunnableTrack S · DGX Spark

start the stack and wait for health checks
cd ~/home-chat
docker compose up --detach --wait --wait-timeout 900
docker compose ps
docker image inspect ollama/ollama:0.33.3 --format '{{.Architecture}}'

Output — what you should see

NAME IMAGE ... SERVICE ... STATUS PORTS
home-chat-caddy-1 caddy:2.11.4-alpine ... caddy ... Up x minutes <LAN_IP>:80->80/tcp, <LAN_IP>:443->443/tcp, 443/udp, 2019/tcp
home-chat-engine-1 ollama/ollama:0.33.3 ... engine ... Up x minutes (healthy) 11434/tcp
home-chat-webui-1 ghcr.io/open-webui/open-webui:v0.11.3 ... webui ... Up x minutes (healthy) 8080/tcp
arm64

On the llama-server path, inspect the ghcr.io/ggml-org/llama.cpp:server-cuda13-b10868 image instead; it also reports arm64.

Track X — AMD Ryzen AI Max+ 395

RunnableTrack X · Ryzen AI Max+

start the stack and wait for health checks
cd ~/home-chat
docker compose up --detach --wait --wait-timeout 900
docker compose ps
docker compose logs engine | grep "inference compute"

Output — what you should see

NAME IMAGE ... STATUS PORTS
home-chat-caddy-1 caddy:2.11.4-alpine ... Up x minutes <LAN_IP>:80->80/tcp, <LAN_IP>:443->443/tcp, 443/udp, 2019/tcp
home-chat-engine-1 ollama/ollama:0.33.3-rocm ... Up x minutes (healthy) 11434/tcp
home-chat-webui-1 ghcr.io/open-webui/open-webui:v0.11.3 ... Up x minutes (healthy) 8080/tcp
engine-1 | time=... level=INFO source=types.go:32 msg="inference compute" id=... library=ROCm ... name=... type=iGPU total="xx.x GiB" available="xx.x GiB"

library=ROCm (or library=Vulkan with the default image) means the engine found the GPU; id=cpu library=cpu means it did not, and the Track X troubleshooting row applies.

Track M — Apple siliconPartial

The engine and the front-end run natively on 127.0.0.1; only Caddy listens on the house network.

Three processes, none of them in a container. First, give the Ollama application the same three settings the containers get from .env, using the FAQ’s launchctl method, then quit and reopen Ollama:

RunnableTrack M · Apple silicon

engine settings for the Ollama application
launchctl setenv OLLAMA_CONTEXT_LENGTH 8192
launchctl setenv OLLAMA_NUM_PARALLEL 1
launchctl setenv OLLAMA_KEEP_ALIVE 30m

Then the front-end’s environment, pinned to the course’s version:

RunnableTrack M · Apple silicon

a Python 3.11 environment with Open WebUI 0.11.3
uv venv --python 3.11 ~/home-chat/venv
uv pip install --python ~/home-chat/venv/bin/python open-webui==0.11.3
uv pip show --python ~/home-chat/venv/bin/python open-webui

Output — what you should see

Name: open-webui
Version: 0.11.3
...

open-webui serve listens on 0.0.0.0 unless given --host, which the pinned release’s source shows as the default, so it is started by a script that always passes --host 127.0.0.1 and sets the same variables as compose.yaml. Save it into ~/home-chat and run it in a second terminal:

RunnableTrack M · Apple silicon

start-webui-native.sh
#!/usr/bin/env bash
# Purpose: start Open WebUI natively for the private chat service on Track M - bound to
# 127.0.0.1 only, with the same settings compose.yaml gives the container, and the
# values that matter (CHAT_HOST, WEBUI_SECRET_KEY, first-start admin) read from
# ~/home-chat/.env so they are written down in exactly one place
# Platform: mac (Track M). Also usable on Linux for a front-end outside Docker.
# Minimum memory: 8 GB
# Assumes: ~/home-chat/.env filled in; a Python 3.11 environment at ~/home-chat/venv with
# open-webui==0.11.3 installed (two uv commands on the lab page); the Ollama
# application running and answering on 127.0.0.1:11434; curl on PATH.
# `open-webui serve` listens on 0.0.0.0 unless told otherwise, which is why this
# script always passes --host 127.0.0.1.
#
# Usage: bash start-webui-native.sh [--home DIR] [--port N]
# e.g. bash ~/home-chat/start-webui-native.sh
set -euo pipefail
HOME_CHAT="${HOME}/home-chat"
PORT=8080
OLLAMA_URL="http://127.0.0.1:11434"
usage() { sed -n '2,16p' "$0"; }
while [ $# -gt 0 ]; do
case "$1" in
--home) HOME_CHAT="${2:?--home needs a directory}"; shift 2 ;;
--port) PORT="${2:?--port needs a number}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
done
ENV_FILE="${HOME_CHAT}/.env"
SERVE="${HOME_CHAT}/venv/bin/open-webui"
DATA_DIR="${HOME_CHAT}/webui-data"
die() { echo "$*" >&2; exit 1; }
[ -f "$ENV_FILE" ] || die "No ${ENV_FILE}. Copy env-example.txt there and fill it in (task 2)."
[ -x "$SERVE" ] || die "No ${SERVE}. Create the environment first (task 4, Track M)."
command -v curl >/dev/null 2>&1 || die "curl is not on PATH."
# Read single KEY=value lines without executing the file.
env_value() { sed -n "s/^$1=//p" "$ENV_FILE" | tail -n 1; }
CHAT_HOST="$(env_value CHAT_HOST)"
WEBUI_SECRET_KEY="$(env_value WEBUI_SECRET_KEY)"
WEBUI_ADMIN_EMAIL="$(env_value WEBUI_ADMIN_EMAIL)"
WEBUI_ADMIN_PASSWORD="$(env_value WEBUI_ADMIN_PASSWORD)"
WEBUI_ADMIN_NAME="$(env_value WEBUI_ADMIN_NAME)"
[ -n "$CHAT_HOST" ] || die "CHAT_HOST is empty in ${ENV_FILE}."
[ -n "$WEBUI_SECRET_KEY" ] || die "WEBUI_SECRET_KEY is empty in ${ENV_FILE}; generate it with: openssl rand -hex 32"
curl --silent --fail --max-time 5 "${OLLAMA_URL}/api/version" >/dev/null \
|| die "Ollama is not answering on ${OLLAMA_URL}. Open the Ollama application first."
if command -v lsof >/dev/null 2>&1 && lsof -nP -iTCP:"$PORT" -sTCP:LISTEN >/dev/null 2>&1; then
die "Something is already listening on port ${PORT}: $(lsof -nP -iTCP:"$PORT" -sTCP:LISTEN | tail -n +2)"
fi
mkdir -p "$DATA_DIR"
echo "==> Open WebUI on 127.0.0.1:${PORT}, data in ${DATA_DIR}, engine ${OLLAMA_URL}"
if [ -n "$WEBUI_ADMIN_EMAIL" ] && [ -n "$WEBUI_ADMIN_PASSWORD" ]; then
echo "==> First-start administrator ${WEBUI_ADMIN_EMAIL} will be created if no account exists yet"
fi
exec env \
DATA_DIR="$DATA_DIR" \
OLLAMA_BASE_URL="$OLLAMA_URL" \
ENABLE_OPENAI_API="false" \
WEBUI_SECRET_KEY="$WEBUI_SECRET_KEY" \
WEBUI_URL="https://${CHAT_HOST}" \
CORS_ALLOW_ORIGIN="https://${CHAT_HOST}" \
ENABLE_SIGNUP="false" \
DEFAULT_USER_ROLE="pending" \
WEBUI_ADMIN_EMAIL="$WEBUI_ADMIN_EMAIL" \
WEBUI_ADMIN_PASSWORD="$WEBUI_ADMIN_PASSWORD" \
WEBUI_ADMIN_NAME="${WEBUI_ADMIN_NAME:-Admin}" \
WEBUI_SESSION_COOKIE_SECURE="true" \
WEBUI_SESSION_COOKIE_SAME_SITE="strict" \
ENABLE_VERSION_UPDATE_CHECK="false" \
"$SERVE" serve --host 127.0.0.1 --port "$PORT"

Download start-webui-native.sh84 lines

RunnableTrack M · Apple silicon

terminal 2: the front-end on 127.0.0.1:8080
bash ~/home-chat/start-webui-native.sh

Output — what you should see

==> Open WebUI on 127.0.0.1:8080, data in /Users/you/home-chat/webui-data, engine http://127.0.0.1:11434
==> First-start administrator admin@example.com will be created if no account exists yet
...

When curl -sS http://127.0.0.1:8080/health prints {"status":true}, start the proxy in a third terminal. XDG_DATA_HOME fixes where Caddy keeps its certificate authority whatever home directory sudo gives it; sudo lets it listen on 80 and 443 and add its root to this Mac’s trust store, which it attempts on first use:

RunnableTrack M · Apple silicon

terminal 3: install Caddy and check which version Homebrew gave you
brew install caddy
caddy version

Output — what you should see

v2.x.x h1:...

Record: the Caddy version (the first field); if it is not v2.11.4, the log wording below may differ.

RunnableTrack M · Apple silicon

terminal 3: the proxy, bound to one address
mkdir -p ~/home-chat/caddy-data
LAN_IP="$(sed -n 's/^LAN_IP=//p' ~/home-chat/.env)"
CHAT_HOST="$(sed -n 's/^CHAT_HOST=//p' ~/home-chat/.env)"
sudo env XDG_DATA_HOME="$HOME/home-chat/caddy-data" CHAT_HOST="$CHAT_HOST" BIND_ADDR="$LAN_IP" WEBUI_UPSTREAM=127.0.0.1:8080 "$(command -v caddy)" run --config "$HOME/home-chat/caddy/Caddyfile"

Output — what you should see

... INFO ... serving initial configuration ...
... WARN pki.ca.local installing root certificate (you might be prompted for password) ...
... INFO ... certificate obtained successfully ...
... INFO ... server running ...

Caddy 2.11.4 prints these as plain lines in a terminal (as JSON "msg" fields in a container log), in varying order. Look for the three INFO messages, no ERROR line, and the trust-store line: installing root certificate on the first start, root certificate is already trusted by system later, or failed to install root certificate. bind: address already in use and bind: permission denied have troubleshooting rows.

Track N — NVIDIA desktop or laptop

RunnableTrack N · NVIDIA GPU

start the stack and wait for health checks
cd ~/home-chat
docker compose up --detach --wait --wait-timeout 900
docker compose ps
docker compose logs engine | grep "inference compute"

Output — what you should see

NAME IMAGE ... STATUS PORTS
home-chat-caddy-1 caddy:2.11.4-alpine ... Up x minutes <LAN_IP>:80->80/tcp, <LAN_IP>:443->443/tcp, 443/udp, 2019/tcp
home-chat-engine-1 ollama/ollama:0.33.3 ... Up x minutes (healthy) 11434/tcp
home-chat-webui-1 ghcr.io/open-webui/open-webui:v0.11.3 ... Up x minutes (healthy) 8080/tcp
engine-1 | time=... level=INFO source=types.go:32 msg="inference compute" id=GPU-... library=CUDA ... name=... type=discrete total="x.x GiB" available="x.x GiB"

library=CUDA means the engine found the card; id=cpu library=cpu means it did not, and the container toolkit is the first suspect.

On the llama-server path the inference compute line does not exist; task 5 checks the offload instead. up --wait returns once every container runs and the two with health checks are healthy (900 seconds allows for the first image downloads); a non-zero exit with a timeout message is the failure. In PORTS, <LAN_IP>:443->443/tcp is published, while 11434/tcp and 8080/tcp with no arrow are declared by the image and mapped to nothing. Record docker system df and the digests from docker compose images.

Track S — NVIDIA DGX Spark

RunnableTrack S · DGX Spark

Ollama path: pull the explicit tag, load it, see where it went
docker compose exec engine ollama pull qwen3:4b-q4_K_M
docker compose exec engine ollama list
docker compose exec engine ollama run qwen3:4b-q4_K_M "Reply with one word: ready"
docker compose exec engine ollama ps

Output — what you should see

NAME ID SIZE MODIFIED
qwen3:4b-q4_K_M 2bfd38a7daaf 2.6 GB x seconds ago
...
NAME ID SIZE PROCESSOR CONTEXT UNTIL
qwen3:4b-q4_K_M 2bfd38a7daaf x.x GB 100% GPU 8192 29 minutes from now

RunnableTrack S · DGX Spark

llama-server path: the model it loaded and the offload
docker compose logs engine | grep -E "offloaded|model loaded|listening on"
docker compose exec engine curl -sS http://localhost:8080/v1/models | grep -o '"id":"[^"]*"'

Output — what you should see

engine-1 | load_tensors: offloaded N/N layers to GPU
engine-1 | ... model loaded
engine-1 | ... listening on http://0.0.0.0:8080
"id":"qwen3-4b"

Track X — AMD Ryzen AI Max+ 395

RunnableTrack X · Ryzen AI Max+

Ollama path: pull the explicit tag, load it, see where it went
docker compose exec engine ollama pull qwen3:4b-q4_K_M
docker compose exec engine ollama list
docker compose exec engine ollama run qwen3:4b-q4_K_M "Reply with one word: ready"
docker compose exec engine ollama ps

Output — what you should see

NAME ID SIZE MODIFIED
qwen3:4b-q4_K_M 2bfd38a7daaf 2.6 GB x seconds ago
...
NAME ID SIZE PROCESSOR CONTEXT UNTIL
qwen3:4b-q4_K_M 2bfd38a7daaf x.x GB 100% GPU 8192 29 minutes from now

RunnableTrack X · Ryzen AI Max+

llama-server path: the model it loaded and the offload
docker compose logs engine | grep -E "offloaded|model loaded|listening on"
docker compose exec engine curl -sS http://localhost:8080/v1/models | grep -o '"id":"[^"]*"'

Output — what you should see

engine-1 | load_tensors: offloaded N/N layers to GPU
engine-1 | ... model loaded
engine-1 | ... listening on http://0.0.0.0:8080
"id":"qwen3-4b"

Track M — Apple silicon

RunnableTrack M · Apple silicon

pull the explicit tag, load it, see where it went
ollama pull qwen3:4b-q4_K_M
ollama list
ollama run qwen3:4b-q4_K_M "Reply with one word: ready"
ollama ps

Output — what you should see

NAME ID SIZE MODIFIED
qwen3:4b-q4_K_M 2bfd38a7daaf 2.6 GB x seconds ago
...
NAME ID SIZE PROCESSOR CONTEXT UNTIL
qwen3:4b-q4_K_M 2bfd38a7daaf x.x GB 100% GPU 8192 29 minutes from now

CONTEXT 8192 and UNTIL about 30 minutes out confirm the launchctl settings reached the server. Any other CONTEXT (Ollama’s context-length page defaults to 4k, 32k or 256k depending on memory) or about five minutes means the application was not quit and reopened, or its Settings context-length slider holds another value (the page documents the slider but not which of the two settings wins); check the slider, quit Ollama from the menu bar, reopen it and run ollama ps again, and record which value CONTEXT then shows.

Track N — NVIDIA desktop or laptop

RunnableTrack N · NVIDIA GPU

Ollama path: pull the explicit tag, load it, see where it went
docker compose exec engine ollama pull qwen3:4b-q4_K_M
docker compose exec engine ollama list
docker compose exec engine ollama run qwen3:4b-q4_K_M "Reply with one word: ready"
docker compose exec engine ollama ps

Output — what you should see

NAME ID SIZE MODIFIED
qwen3:4b-q4_K_M 2bfd38a7daaf 2.6 GB x seconds ago
...
NAME ID SIZE PROCESSOR CONTEXT UNTIL
qwen3:4b-q4_K_M 2bfd38a7daaf x.x GB 100% GPU 8192 29 minutes from now

RunnableTrack N · NVIDIA GPU

llama-server path: the model it loaded and the offload
docker compose logs engine | grep -E "offloaded|model loaded|listening on"
docker compose exec engine curl -sS http://localhost:8080/v1/models | grep -o '"id":"[^"]*"'

Output — what you should see

engine-1 | load_tensors: offloaded N/N layers to GPU
engine-1 | ... model loaded
engine-1 | ... listening on http://0.0.0.0:8080
"id":"qwen3-4b"

The tag is explicit because short tags move (the model reference has the model’s other sources): on 13 September 2026 Ollama’s tags page listed qwen3:4b-q4_K_M as 2bfd38a7daaf at 2.6 GB, while qwen3:4b pointed at a different, thinking-only checkpoint. N/N in the llama-server log means both numbers are equal, every layer on the GPU; a first number smaller than the second is a partial offload.

Record: the ID from ollama list (or the GGUF’s SHA-256), and PROCESSOR, CONTEXT and SIZE from ollama ps (or the offload line).

Caddy has issued a certificate chain signed by a certificate authority it created on first start. The tls directive documentation gives the default lifetimes, and exporting the chain from a test run of Caddy 2.11.4 showed the names each level carries:

Level Subject Lifetime Where it lives Installed on devices?
Root CN=Caddy Local Authority - <year> ECC Root 3600 days caddy-data volume, caddy/pki/authorities/local/root.crt yes, once per device
Intermediate CN=Caddy Local Authority - ECC Intermediate 7 days same directory no; Caddy renews it
Site certificate empty subject, DNS:chat.home.arpa in the alternative names 12 hours caddy/certificates/local/chat.home.arpa/ no; Caddy renews it

A device that trusts the root accepts everything the intermediate signs, so a phone needs one installation for ten years, provided the root’s key survives. Deleting caddy-data creates a new root that none of your devices trust, which is why the cleanup puts it in a warning.

Export the root and look at it:

Track S — NVIDIA DGX Spark

RunnableTrack S · DGX Spark

copy the root out of the container and fingerprint it
cd ~/home-chat
docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt ./home-chat-root.crt
openssl x509 -in home-chat-root.crt -noout -subject -enddate -fingerprint -sha256

Track X — AMD Ryzen AI Max+ 395

RunnableTrack X · Ryzen AI Max+

copy the root out of the container and fingerprint it
cd ~/home-chat
docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt ./home-chat-root.crt
openssl x509 -in home-chat-root.crt -noout -subject -enddate -fingerprint -sha256

Track M — Apple silicon

RunnableTrack M · Apple silicon

copy the root out of Caddy's data directory and fingerprint it
cd ~/home-chat
sudo cp caddy-data/caddy/pki/authorities/local/root.crt ./home-chat-root.crt
sudo chown "$(id -un)" ./home-chat-root.crt
openssl x509 -in home-chat-root.crt -noout -subject -enddate -fingerprint -sha256

Track N — NVIDIA desktop or laptop

RunnableTrack N · NVIDIA GPU

copy the root out of the container and fingerprint it
cd ~/home-chat
docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt ./home-chat-root.crt
openssl x509 -in home-chat-root.crt -noout -subject -enddate -fingerprint -sha256

Output — what you should see

subject=CN=Caddy Local Authority - 2026 ECC Root
notAfter=<3600 days after the first start> GMT
sha256 Fingerprint=XX:XX:XX:...:XX

The caddy image sets XDG_DATA_HOME=/data, hence the path. The certificate is not secret; its key, root.key, stays behind. Prove the chain from the server before touching a phone, pinning the name to the address so DNS is not involved:

RunnableAll tracks

HTTPS with and without the root, from the server
cd ~/home-chat
LAN_IP="$(sed -n 's/^LAN_IP=//p' .env)"
CHAT_HOST="$(sed -n 's/^CHAT_HOST=//p' .env)"
curl -sS --resolve "${CHAT_HOST}:443:${LAN_IP}" --cacert home-chat-root.crt "https://${CHAT_HOST}/health"
curl -sS --resolve "${CHAT_HOST}:443:${LAN_IP}" "https://${CHAT_HOST}/health"

Output — what you should see

{"status":true}
curl: (60) SSL certificate OpenSSL verify result: unable to get local issuer certificate (20)
...

The first request verified the chain against the file and reached Open WebUI’s health endpoint through the proxy. The second used only the system’s trust store, which does not know this CA; (60) is curl’s code for an unverified certificate, and the text after it varies between curl versions. On Track M the second request may succeed, if the system store Caddy wrote to is the one that curl consults.

Get home-chat-root.crt onto each device by AirDrop, e-mail to yourself, or a temporary web server that you stop once the phones have it:

RunnableAll tracks

serve the root certificate for a few minutes, then Ctrl-C
mkdir -p ~/home-chat/trust
cp ~/home-chat/home-chat-root.crt ~/home-chat/trust/
python3 -m http.server 8000 --bind "$(sed -n 's/^LAN_IP=//p' ~/home-chat/.env)" --directory ~/home-chat/trust

On each phone open http://chat.home.arpa:8000/home-chat-root.crt, then Ctrl-C. If ufw blocks port 8000, that is the firewall working for a process that is not a container; use e-mail. Installing a certificate and trusting it are separate steps on most systems:

Device Install Separate trust step Source
iPhone, iPad install the downloaded profile Settings > General > About > Certificate Trust Settings, turn on full trust for the Caddy root Apple Support article cited
Android 14 and later Settings > Security & privacy > More security settings > Encryption & credentials > Install a certificate choose the CA certificate type; the menu labels vary by manufacturer, and the cited Google page shows only the Wi-Fi certificate entry Android Help, cited
macOS client double-click the file, add it to the System keychain in Keychain Access, open the certificate, set “When using this certificate” to Always Trust not from a cited page; verify on your macOS version
Windows client double-click the file, Install Certificate place it in Trusted Root Certification Authorities not from a cited page; verify on your Windows version
Linux client the two commands below none; Ubuntu’s page says the file needs the .crt extension Ubuntu Server documentation
Firefox, any system keeps its own certificate store import the file in Firefox’s certificate settings too not from a cited page

RunnableAll tracks

a Linux client: add the root to the system store
sudo cp home-chat-root.crt /usr/local/share/ca-certificates/home-chat-root.crt
sudo update-ca-certificates

On Track M, Caddy ran as root on the server and attempted the system-store installation itself; confirm it by opening https://chat.home.arpa/ in Safari on that Mac and seeing no warning.

Record: the SHA-256 fingerprint, notAfter, and for each device whether trust was installed and whether the browser then showed the site without a warning.

7. Sign in as administrator, then add the household

Section titled “7. Sign in as administrator, then add the household”

The administrator already exists: the pinned source creates the account from WEBUI_ADMIN_EMAIL and WEBUI_ADMIN_PASSWORD during start-up when there are no users, then switches sign-up off, so no first visitor could claim the role. Its sign-up handler refuses every later self-registration with HTTP 403 while sign-up is off.

  1. On a laptop whose browser trusts the root, open https://chat.home.arpa/ and sign in with the two values from .env.
  2. Change the password: your name in the corner > Settings > Account > Change Password. Open WebUI answers Password updated. Please sign in again.; do so.
  3. Delete the WEBUI_ADMIN_PASSWORD= line from ~/home-chat/.env in an editor, then apply the change:

Track S — NVIDIA DGX Spark

RunnableTrack S · DGX Spark

no password left in the file; recreate the front-end
cd ~/home-chat
grep -c '^WEBUI_ADMIN_PASSWORD=.' .env
docker compose up --detach --wait

Output — what you should see

0

Track X — AMD Ryzen AI Max+ 395

RunnableTrack X · Ryzen AI Max+

no password left in the file; recreate the front-end
cd ~/home-chat
grep -c '^WEBUI_ADMIN_PASSWORD=.' .env
docker compose up --detach --wait

Output — what you should see

0

Track M — Apple silicon

RunnableTrack M · Apple silicon

no password left in the file
grep -c '^WEBUI_ADMIN_PASSWORD=.' ~/home-chat/.env

Output — what you should see

0

Then stop the front-end in terminal 2 with Ctrl-C and run bash ~/home-chat/start-webui-native.sh again; its second ==> line no longer appears.

Track N — NVIDIA desktop or laptop

RunnableTrack N · NVIDIA GPU

no password left in the file; recreate the front-end
cd ~/home-chat
grep -c '^WEBUI_ADMIN_PASSWORD=.' .env
docker compose up --detach --wait

Output — what you should see

0

On the container tracks, up recreates only webui, because only its environment changed.

  1. Admin Panel > Users must list exactly one account, with the role admin. A second row you did not create means someone registered before this configuration was in place; see the troubleshooting row.
  2. Add a person: Admin Panel > Users > Add User. The form has Role, Name, Email and Password, and its Role defaults to user; fill in the rest and Save.
  3. Make the model visible to that person. Open WebUI’s model filter in the pinned release says it in a comment: “No DB entry means no access control configured yet; only admins can see unconfigured models.” A model the engine lists but nobody has configured is invisible to every user account. Open Settings > Admin > Models, edit qwen3:4b-q4_K_M (or qwen3-4b), set its Visibility to Public, and save.
  4. Check what that person can see without signing in as them: Admin Panel > Users, hover over their row and click Preview Access. The model must be listed.
  5. See what pending means: Add User once more with Role pending, sign in as that account in a private browser window, and you get the page that says Account Activation Pending and Contact Admin for WebUI Access. Delete that test account afterwards.

Open WebUI also sends the engine requests of its own: the task models page says it writes “chat titles, generate tags, suggest follow-up questions, produce autocomplete ghost text”, by default “on the model the user is chatting with”, and warns that “a reasoning model spends seconds thinking before producing a three-word title”. With one slot, each of those waits in the queue the next person’s question joins. The switches are under Settings > Admin > Interface, Generation:

Admin toggle What it produces Recommendation for a one-slot household engine
Title Generation chat titles keep; titles are how people find old chats
Tags Generation tags on chats turn off unless the household uses tags
Follow Up Generation suggested follow-up questions turn off; one more request after each answer
Autocomplete Generation ghost text; the page says it “fires as users type” turn off

Measure the effect by counting the engine’s chat requests for one question, with the defaults and again after the change. On the Ollama path the answer and every background task are each a POST to /api/chat, and Ollama logs one line per request. Run the block for your engine and ask one question in a new chat when it prompts you.

RunnableAll tracks

container tracks, Ollama path: chat requests for one question
cd ~/home-chat
START="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
printf 'Ask one question in a new chat; once nothing more appears in it, press return: '; read -r _
docker compose logs --since "$START" engine | grep -E 'POST +"/api/chat"'
docker compose logs --since "$START" engine | grep -cE 'POST +"/api/chat"'

Output — what you should see

Ask one question in a new chat; once nothing more appears in it, press return:
engine-1 | [GIN] 2026/09/13 - 21:00:04 | 200 | x.xxxxxxxxxs | 172.18.0.x | POST "/api/chat"
engine-1 | [GIN] 2026/09/13 - 21:00:09 | 200 | x.xxxxxxxxxs | 172.18.0.x | POST "/api/chat"
...
x

RunnableTrack M · Apple silicon

Track M: chat requests for one question, from the Ollama application's log
BEFORE="$(wc -l < ~/.ollama/logs/server.log)"
printf 'Ask one question in a new chat; once nothing more appears in it, press return: '; read -r _
tail -n +"$((BEFORE + 1))" ~/.ollama/logs/server.log | grep -cE 'POST +"/api/chat"'

Output — what you should see

Ask one question in a new chat; once nothing more appears in it, press return:
x

llama-server at b10868 has its request logger commented out, but logs one line per task a slot takes, and each chat completion is one task:

RunnableAll tracks

container tracks, llama-server path: completion tasks for one question
cd ~/home-chat
START="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
printf 'Ask one question in a new chat; once nothing more appears in it, press return: '; read -r _
docker compose logs --since "$START" engine | grep 'processing task'
docker compose logs --since "$START" engine | grep -c 'processing task'

Output — what you should see

Ask one question in a new chat; once nothing more appears in it, press return:
engine-1 | x.xx.xxx.xxx I slot launch_slot_: id 0 | task x | processing task, is_child = 0
...
x

The last line of each block is the count. The [GIN] layout is read from the pinned sources; the slot line was captured from a CPU build of the same week, whose source line matches b10868. Wording may differ in other versions.

Record: the number of accounts and their roles, which models the household account can read (from Preview Access), the background tasks left on, and the request count per question before and after.

Join the phone to the house Wi-Fi, open https://chat.home.arpa/, sign in as the household account, and ask the model something that needs a few sentences. This is the test that matters, because it exercises the name resolution, the certificate, the binding and the proxy at once, from a device that shares none of the server’s configuration.

If the page loads without a warning and the answer appears a few words at a time, the streaming path works through the proxy: the front-end’s streamed responses and its WebSocket connection both pass Caddy, which handles the WebSocket upgrade in reverse_proxy without extra configuration. Then look for the phone in the evidence the server kept:

Track S — NVIDIA DGX Spark

RunnableTrack S · DGX Spark

the phone in the proxy's access log, and the loaded model
cd ~/home-chat
docker compose logs --since 10m caddy | grep -o '"User-Agent": \["[^"]*"\]' | sort | uniq -c
docker compose exec engine ollama ps
docker stats --no-stream --format 'table {{.Name}}\t{{.MemUsage}}'

Output — what you should see

xx "User-Agent": ["Mozilla/5.0 (iPhone; CPU iPhone OS ... like Mac OS X) ..."]
NAME ID SIZE PROCESSOR CONTEXT UNTIL
qwen3:4b-q4_K_M 2bfd38a7daaf x.x GB 100% GPU 8192 29 minutes from now
NAME MEM USAGE / LIMIT
home-chat-caddy-1 xx.xMiB / xxxGiB
home-chat-engine-1 x.xxGiB / xxxGiB
home-chat-webui-1 xxxMiB / xxxGiB

Track X — AMD Ryzen AI Max+ 395

RunnableTrack X · Ryzen AI Max+

the phone in the proxy's access log, and the loaded model
cd ~/home-chat
docker compose logs --since 10m caddy | grep -o '"User-Agent": \["[^"]*"\]' | sort | uniq -c
docker compose exec engine ollama ps
docker stats --no-stream --format 'table {{.Name}}\t{{.MemUsage}}'

Output — what you should see

xx "User-Agent": ["Mozilla/5.0 (Linux; Android ...) ..."]
NAME ID SIZE PROCESSOR CONTEXT UNTIL
qwen3:4b-q4_K_M 2bfd38a7daaf x.x GB 100% GPU 8192 29 minutes from now
NAME MEM USAGE / LIMIT
home-chat-caddy-1 xx.xMiB / xxGiB
home-chat-engine-1 x.xxGiB / xxGiB
home-chat-webui-1 xxxMiB / xxGiB

Track M — Apple silicon

Caddy’s access log is the stream in terminal 3: each request is a line containing "User-Agent": [...], and the phone’s requests carry its browser’s identifier. Then:

RunnableTrack M · Apple silicon

the loaded model, and the front-end's memory
ollama ps
ps -o rss=,command= -p "$(lsof -nP -tiTCP:8080 -sTCP:LISTEN)"

Output — what you should see

NAME ID SIZE PROCESSOR CONTEXT UNTIL
qwen3:4b-q4_K_M 2bfd38a7daaf x.x GB 100% GPU 8192 29 minutes from now
xxxxxx /Users/you/home-chat/venv/bin/python3 /Users/you/home-chat/venv/bin/open-webui serve --host 127.0.0.1 --port 8080

The first number from ps is the front-end’s resident memory in kilobytes.

Track N — NVIDIA desktop or laptop

RunnableTrack N · NVIDIA GPU

the phone in the proxy's access log, and the loaded model
cd ~/home-chat
docker compose logs --since 10m caddy | grep -o '"User-Agent": \["[^"]*"\]' | sort | uniq -c
docker compose exec engine ollama ps
docker stats --no-stream --format 'table {{.Name}}\t{{.MemUsage}}'

Output — what you should see

xx "User-Agent": ["Mozilla/5.0 (Linux; Android ...) ..."]
NAME ID SIZE PROCESSOR CONTEXT UNTIL
qwen3:4b-q4_K_M 2bfd38a7daaf x.x GB 100% GPU 8192 29 minutes from now
NAME MEM USAGE / LIMIT
home-chat-caddy-1 xx.xMiB / xxGiB
home-chat-engine-1 x.xxGiB / xxGiB
home-chat-webui-1 xxxMiB / xxGiB

docker stats reports system memory; the model’s video memory is in nvidia-smi on the host.

On the llama-server path, replace ollama ps with docker compose exec engine curl -sS http://localhost:8080/props | grep -o '"n_ctx":[0-9]*', which prints "n_ctx":8192. Record: the phone’s make and operating system, its browser, whether the page loaded without a warning, whether the answer streamed, PROCESSOR and CONTEXT, and the memory of each container or process.

9. Prove that it is not reachable from anywhere else

Section titled “9. Prove that it is not reachable from anywhere else”

An assumption you have not tested is a hope. First, what the server itself says is listening:

Track S — NVIDIA DGX Spark

RunnableTrack S · DGX Spark

listening sockets on the ports this stack could use
sudo ss -ltnp | grep -E ':(80|443|2019|3000|8080|11434)\s'
ip -4 -brief addr show scope global

Output — what you should see

LISTEN 0 4096 <LAN_IP>:443 0.0.0.0:* users:(("docker-proxy",pid=xxxx,fd=x))
LISTEN 0 4096 <LAN_IP>:80 0.0.0.0:* users:(("docker-proxy",pid=xxxx,fd=x))
<interface> UP <LAN_IP>/24
docker0 DOWN 172.17.0.1/16
br-xxxxxxxxxxxx UP 172.18.0.1/16

Track X — AMD Ryzen AI Max+ 395

RunnableTrack X · Ryzen AI Max+

listening sockets on the ports this stack could use
sudo ss -ltnp | grep -E ':(80|443|2019|3000|8080|11434)\s'
ip -4 -brief addr show scope global

Output — what you should see

LISTEN 0 4096 <LAN_IP>:443 0.0.0.0:* users:(("docker-proxy",pid=xxxx,fd=x))
LISTEN 0 4096 <LAN_IP>:80 0.0.0.0:* users:(("docker-proxy",pid=xxxx,fd=x))
<interface> UP <LAN_IP>/24
docker0 DOWN 172.17.0.1/16
br-xxxxxxxxxxxx UP 172.18.0.1/16

Track M — Apple silicon

RunnableTrack M · Apple silicon

listening sockets on the ports this stack could use
sudo lsof -nP -iTCP -sTCP:LISTEN | grep -E ':(80|443|2019|3000|8080|11434) '

Output — what you should see

ollama xxxx you xu IPv4 0x... 0t0 TCP 127.0.0.1:11434 (LISTEN)
python3.1 xxxx you xu IPv4 0x... 0t0 TCP 127.0.0.1:8080 (LISTEN)
caddy xxxx root xu IPv4 0x... 0t0 TCP <LAN_IP>:443 (LISTEN)
caddy xxxx root xu IPv4 0x... 0t0 TCP <LAN_IP>:80 (LISTEN)
caddy xxxx root xu IPv4 0x... 0t0 TCP 127.0.0.1:2019 (LISTEN)

Process names are truncated by lsof and may differ; the addresses are what matter. Port 2019 is Caddy’s admin API, on loopback by default.

Track N — NVIDIA desktop or laptop

RunnableTrack N · NVIDIA GPU

listening sockets on the ports this stack could use
sudo ss -ltnp | grep -E ':(80|443|2019|3000|8080|11434)\s'
ip -4 -brief addr show scope global

Output — what you should see

LISTEN 0 4096 <LAN_IP>:443 0.0.0.0:* users:(("docker-proxy",pid=xxxx,fd=x))
LISTEN 0 4096 <LAN_IP>:80 0.0.0.0:* users:(("docker-proxy",pid=xxxx,fd=x))
<interface> UP <LAN_IP>/24
docker0 DOWN 172.17.0.1/16
br-xxxxxxxxxxxx UP 172.18.0.1/16
Line you might see Meaning Action
<LAN_IP>:443 and <LAN_IP>:80 only as designed none
0.0.0.0:443, [::]:443 or *:443 a mapping without a host address fix ports: and docker compose up --detach
127.0.0.1:11434 on a container track the native Ollama service is back sudo systemctl stop ollama, or disable it
*:8080 or 0.0.0.0:8080 on Track M open-webui serve started without --host start it with the script
any line for 3000 the Open WebUI quick-start container from the front-ends lesson remove that container

The br-… interface is the private network’s bridge; containers talk to each other across it, and nothing outside the machine has a route to it. Compare the interface list with the one you recorded in task 1.

Second, the view from the house network. Copy home-chat-root.crt into the home directory of a laptop on the Wi-Fi, save the probe script there too, and run it on the laptop; on the server, the closed-port checks would test the server against itself.

RunnableAll tracks

probe-lan-exposure.sh
#!/usr/bin/env bash
# Purpose: from a SECOND computer on the home network, test what the chat service exposes:
# the name resolves to the server's address, HTTPS verifies against the Caddy root
# certificate, port 80 only redirects, the front-end's API refuses requests without
# a login, and the ports the engine and front-end use inside the server are closed
# to the network. Prints PASS or FAIL per check and exits non-zero on any FAIL.
# Platform: all (run it on a Linux, macOS or WSL2 machine that is NOT the server; from the
# server itself the closed-port checks prove nothing)
# Minimum memory: 8 GB (the course's floor for this lab; the probe itself needs almost none)
# Assumes: curl on PATH; the root certificate exported in task 6 copied to this machine;
# CHAT_HOST resolves on this machine the same way it does for the phone.
#
# Usage: bash probe-lan-exposure.sh --host NAME --expect-ip ADDRESS --cacert FILE
# [--https-port N] [--http-port N] [--labbook FILE]
# e.g. bash probe-lan-exposure.sh --host chat.home.arpa --expect-ip "$LAN_IP" \
# --cacert ~/home-chat-root.crt --labbook labbook.md
set -euo pipefail
HOST=""
EXPECT_IP=""
CACERT=""
HTTPS_PORT=443
HTTP_PORT=80
LABBOOK=""
# Ports that must not answer on the server's home-network address: Ollama, Open WebUI and
# llama-server (8080), the Open WebUI quick start's 3000, and Caddy's admin API (2019).
CLOSED_PORTS=(11434 8080 3000 2019)
usage() { sed -n '2,17p' "$0"; }
while [ $# -gt 0 ]; do
case "$1" in
--host) HOST="${2:?--host needs a name}"; shift 2 ;;
--expect-ip) EXPECT_IP="${2:?--expect-ip needs an address}"; shift 2 ;;
--cacert) CACERT="${2:?--cacert needs a file}"; shift 2 ;;
--https-port) HTTPS_PORT="${2:?--https-port needs a number}"; shift 2 ;;
--http-port) HTTP_PORT="${2:?--http-port needs a number}"; shift 2 ;;
--labbook) LABBOOK="${2:?--labbook needs a file}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
done
[ -n "$HOST" ] && [ -n "$EXPECT_IP" ] && [ -n "$CACERT" ] || { usage >&2; exit 2; }
command -v curl >/dev/null 2>&1 || { echo "curl is not on PATH." >&2; exit 1; }
[ -f "$CACERT" ] || { echo "No such certificate file: ${CACERT}" >&2; exit 1; }
PASSES=0
FAILS=0
pass() { PASSES=$((PASSES + 1)); printf 'PASS %-22s %s\n' "$1" "$2"; }
fail() { FAILS=$((FAILS + 1)); printf 'FAIL %-22s %s\n' "$1" "$2"; }
# curl exit codes used below: 0 success, 6 could not resolve host, 7 failed to connect
# (refused), 28 timed out, 35 TLS handshake failed, 60 certificate not verified.
# 1. Name resolution and TLS verification in one request.
BODY_FILE="$(mktemp)"
trap 'rm -f "$BODY_FILE"' EXIT
set +e
OUT="$(curl --silent --show-error --max-time 10 --cacert "$CACERT" \
--output "$BODY_FILE" --write-out '%{http_code} %{remote_ip}' \
"https://${HOST}:${HTTPS_PORT}/health" 2>&1)"
RC=$?
set -e
if [ "$RC" -eq 0 ]; then
read -r CODE IP <<< "$OUT"
if [ "$IP" != "$EXPECT_IP" ]; then
fail "name-resolves" "${HOST} reached ${IP}, expected ${EXPECT_IP}"
else
pass "name-resolves" "${HOST} -> ${IP}"
fi
if [ "$CODE" = "200" ] && grep -q '"status":true' "$BODY_FILE"; then
pass "https-verified" "certificate chains to ${CACERT}; /health says $(cat "$BODY_FILE")"
else
fail "https-verified" "TLS verified but /health returned HTTP ${CODE}"
fi
else
case "$RC" in
6) fail "name-resolves" "curl exit 6: ${HOST} does not resolve on this machine (DNS, task 1)" ;;
7) fail "https-verified" "curl exit 7: nothing accepts connections on ${HOST}:${HTTPS_PORT}" ;;
28) fail "https-verified" "curl exit 28: timed out reaching ${HOST}:${HTTPS_PORT}" ;;
60) fail "https-verified" "curl exit 60: certificate does not chain to ${CACERT} (task 6)" ;;
*) fail "https-verified" "curl exit ${RC}: ${OUT//$'\n'/ }" ;;
esac
fi
# 2. Port 80 must only redirect to HTTPS.
set +e
OUT="$(curl --silent --max-time 10 --output /dev/null \
--write-out '%{http_code} %{redirect_url}' "http://${HOST}:${HTTP_PORT}/")"
RC=$?
set -e
read -r CODE LOCATION _ <<< "${OUT} -"
if [ "$RC" -eq 0 ] && [ "$CODE" = "308" ] && [ "${LOCATION#https://}" != "$LOCATION" ]; then
pass "http-redirects" "HTTP ${CODE} -> ${LOCATION}"
else
fail "http-redirects" "expected HTTP 308 to https://, got curl exit ${RC}, HTTP ${CODE}"
fi
# 3. The front-end's proxy to the engine must demand a login.
set +e
CODE="$(curl --silent --max-time 10 --cacert "$CACERT" --output /dev/null \
--write-out '%{http_code}' "https://${HOST}:${HTTPS_PORT}/ollama/api/tags")"
RC=$?
set -e
if [ "$RC" -eq 0 ] && [ "$CODE" = "401" ]; then
pass "engine-api-needs-login" "/ollama/api/tags without a session: HTTP 401"
else
fail "engine-api-needs-login" "expected HTTP 401, got curl exit ${RC}, HTTP ${CODE}"
fi
# 4. Nothing else may answer on the server's home-network address.
for PORT in "${CLOSED_PORTS[@]}"; do
set +e
curl --silent --max-time 5 --output /dev/null "http://${EXPECT_IP}:${PORT}/"
RC=$?
set -e
case "$RC" in
7) pass "closed-${PORT}" "connection refused" ;;
28) pass "closed-${PORT}" "no answer within 5 s (filtered)" ;;
*) fail "closed-${PORT}" "curl exit ${RC}: something answered on ${EXPECT_IP}:${PORT}" ;;
esac
done
echo "${PASSES} passed, ${FAILS} failed"
if [ -n "$LABBOOK" ]; then
printf '{"lab":"part-07/lab-private-chat-service-on-your-lan","step":"probe","host":"%s","passed":%s,"failed":%s,"taken":"%s"}\n' \
"$HOST" "$PASSES" "$FAILS" "$(date -u +%Y%m%dT%H%M%SZ)" >> "$LABBOOK"
fi
[ "$FAILS" -eq 0 ]

Download probe-lan-exposure.sh133 lines

RunnableAll tracks

from a second computer on the house network
printf 'Service name from task 1 (CHAT_HOST): '; read -r CHAT_HOST
printf 'Server address from task 1: '; read -r LAN_IP
bash probe-lan-exposure.sh --host "$CHAT_HOST" --expect-ip "$LAN_IP" --cacert ~/home-chat-root.crt --labbook labbook.md

Output — what you should see

Service name from task 1 (CHAT_HOST): chat.home.arpa
Server address from task 1: <LAN_IP>
PASS name-resolves chat.home.arpa -> <LAN_IP>
PASS https-verified certificate chains to /home/you/home-chat-root.crt; /health says {"status":true}
PASS http-redirects HTTP 308 -> https://chat.home.arpa/
PASS engine-api-needs-login /ollama/api/tags without a session: HTTP 401
PASS closed-11434 connection refused
PASS closed-8080 connection refused
PASS closed-3000 connection refused
PASS closed-2019 connection refused
8 passed, 0 failed

The PASS lines are the probe’s output from a test against Caddy 2.11.4 with a stand-in upstream, address and path replaced. What each check proves:

Check How What a PASS proves
name-resolves curl’s %{remote_ip} compared with --expect-ip this device resolves the name to the server, not to something else
https-verified --cacert with the exported root, /health must say "status":true the certificate chains to your CA and the proxy reaches the front-end
http-redirects plain HTTP on 80 must return 308 to https:// port 80 carries no content, only the redirect Caddy adds automatically
engine-api-needs-login Open WebUI’s /ollama/api/tags without a session the only path to the engine through the proxy requires an account (401 on both engines, because the route checks the session before anything else)
closed-* curl exit 7 (refused) or 28 (no answer) on the server’s address the engine, front-end, quick-start and admin ports answer nobody on the house network

A FAIL names its curl exit code and the task that fixes it. Record the probe’s JSON line in the notebook (the script appends it with --labbook), the ss or lsof lines, and the list of interfaces.

10. Back up the front-end, and prove the backup is real

Section titled “10. Back up the front-end, and prove the backup is real”

The engine’s weights download again, and a lost certificate authority costs one reinstall per device. The front-end’s accounts, conversations and uploads exist nowhere else. Open WebUI’s backup tutorial lists its data directory:

Path in the data directory Contents
webui.db the SQLite database: accounts, chats, settings, including the ConfigVar values
uploads/ files people attached to chats
vector_db/ the vector store for document retrieval
cache/ cached data
audit.log audit events

The database is the reason the script stops the front-end. The pinned release’s source opens SQLite with PRAGMA journal_mode=WAL unless DATABASE_ENABLE_SQLITE_WAL is false, and in WAL mode a committed transaction is written to webui.db-wal first and copied into webui.db only at a checkpoint. SQLite’s documentation says that “when the last connection to a database closes, that connection does one last checkpoint and then deletes the WAL”. Run this to see what that means for a file copy:

RunnableAll tracks

wal_copy_demo.py: why a copy of a running database can be missing rows
import os, shutil, sqlite3, tempfile
live = tempfile.mkdtemp()
db = os.path.join(live, "webui.db")
con = sqlite3.connect(db) # the running front-end
con.execute("PRAGMA journal_mode=WAL") # what Open WebUI v0.11.3 sets
con.execute("PRAGMA wal_autocheckpoint=0") # keep this demo deterministic
con.execute("CREATE TABLE chat (id INTEGER PRIMARY KEY, title TEXT)")
con.commit()
con.execute("PRAGMA wal_checkpoint(TRUNCATE)") # table now lives in webui.db itself
con.executemany("INSERT INTO chat (title) VALUES (?)", [("a",), ("b",), ("c",)])
con.commit() # committed, but only in webui.db-wal
def count(files):
copy = tempfile.mkdtemp()
for name in files:
shutil.copy(os.path.join(live, name), copy)
return sqlite3.connect(os.path.join(copy, "webui.db")).execute(
"SELECT COUNT(*) FROM chat").fetchone()[0]
print("files while running:", sorted(os.listdir(live)))
print("rows seen by the process: ", con.execute("SELECT COUNT(*) FROM chat").fetchone()[0])
print("rows in a copy of webui.db:", count(["webui.db"]))
print("rows in a copy of all three:", count(["webui.db", "webui.db-wal", "webui.db-shm"]))
con.close() # a clean stop checkpoints the WAL
print("files after a clean close: ", sorted(os.listdir(live)))
print("rows in a copy of webui.db:", count(["webui.db"]))

Output — what you should see

files while running: ['webui.db', 'webui.db-shm', 'webui.db-wal']
rows seen by the process: 3
rows in a copy of webui.db: 0
rows in a copy of all three: 3
files after a clean close: ['webui.db']
rows in a copy of webui.db: 3

Three committed rows, and a copy of the obvious file holds none. Copying all three files worked here only because nothing wrote during the copy, which a live front-end cannot promise; stopping it runs the final checkpoint, and the archive holds one consistent state. Save the script into ~/home-chat:

RunnableAll tracks

backup-webui.sh
#!/usr/bin/env bash
# Purpose: take a consistent, restorable backup of the chat front-end's data - accounts,
# chat history, settings and uploaded files - from either the Docker volume used
# by compose.yaml or a native install's data directory, check that the archive
# reads back and that the SQLite database inside it passes an integrity check,
# and record the result in the lab notebook
# Platform: all (Linux, macOS and WSL2; the volume path needs Docker, the directory path
# does not)
# Minimum memory: 8 GB
# Assumes: for a volume, run from the directory holding compose.yaml and .env (the
# front-end is stopped with `docker compose stop` for the duration, unless
# --no-stop is given); tar on PATH; python3 on PATH for the database check (it
# is skipped, and says so, without it); enough free space in the destination for
# a compressed copy of the data. The front-end's database is SQLite in WAL mode,
# so committed data can sit in webui.db-wal until the process closes the file:
# a copy taken while it runs is a copy you have not shown to be complete.
#
# Usage: bash backup-webui.sh [--dest DIR] [--volume NAME] [--service NAME]
# [--data-dir PATH] [--no-stop] [--labbook FILE]
# e.g. bash backup-webui.sh --dest ~/backups --labbook ~/home-chat/labbook.md
# bash backup-webui.sh --data-dir ~/home-chat/webui-data --dest ~/backups
set -euo pipefail
DEST="${HOME}/backups"
VOLUME="home-chat_webui-data"
DATA_DIR=""
SERVICE="webui"
STOP=1
LABBOOK=""
ALPINE_IMAGE="alpine:3.24"
usage() { sed -n '2,21p' "$0"; }
while [ $# -gt 0 ]; do
case "$1" in
--dest) DEST="${2:?--dest needs a directory}"; shift 2 ;;
--volume) VOLUME="${2:?--volume needs a volume name}"; shift 2 ;;
--data-dir) DATA_DIR="${2:?--data-dir needs a path}"; shift 2 ;;
--service) SERVICE="${2:?--service needs a compose service name}"; shift 2 ;;
--no-stop) STOP=0; shift ;;
--labbook) LABBOOK="${2:?--labbook needs a file}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
done
command -v tar >/dev/null 2>&1 || { echo "tar is not on PATH." >&2; exit 1; }
mkdir -p "$DEST"
DEST="$(cd "$DEST" && pwd)" # docker needs an absolute path for the bind mount
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
ARCHIVE="${DEST}/open-webui-${STAMP}.tar.gz"
WORK="$(mktemp -d)"
STOPPED=0
cleanup() {
rm -rf "$WORK"
if [ "$STOPPED" -eq 1 ]; then
echo "==> Starting ${SERVICE} again"
docker compose start "$SERVICE" >/dev/null
fi
}
trap cleanup EXIT
if [ -n "$DATA_DIR" ]; then
# Native install: an ordinary directory on this machine.
[ -d "$DATA_DIR" ] || { echo "No such directory: ${DATA_DIR}" >&2; exit 1; }
[ -f "${DATA_DIR}/webui.db" ] || { echo "No webui.db in ${DATA_DIR}; is this the front-end's DATA_DIR?" >&2; exit 1; }
if [ "$STOP" -eq 1 ]; then
echo "==> Stop the front-end now (Ctrl-C in its terminal), then press return."
read -r _
fi
echo "==> Archiving ${DATA_DIR}"
# -C DIR . stores entries as ./webui.db and so on, the same layout as the volume path.
tar -czf "$ARCHIVE" -C "$DATA_DIR" .
SOURCE="directory ${DATA_DIR}"
else
command -v docker >/dev/null 2>&1 || { echo "docker is not on PATH." >&2; exit 1; }
docker volume inspect "$VOLUME" >/dev/null 2>&1 || {
echo "No such Docker volume: ${VOLUME}" >&2
echo "List them with: docker volume ls" >&2
exit 1
}
if [ "$STOP" -eq 1 ]; then
[ -f compose.yaml ] || {
echo "No compose.yaml in $(pwd). Run this from the stack's directory (cd ~/home-chat)," >&2
echo "or pass --no-stop and accept an unverified copy of a live database." >&2
exit 1
}
echo "==> Stopping ${SERVICE} so the database is closed while it is copied"
docker compose stop "$SERVICE" >/dev/null
STOPPED=1
fi
echo "==> Archiving volume ${VOLUME}"
# A throwaway container mounts the volume read-only, writes the archive into the
# destination, and hands the file to the user who ran this script (the container
# runs as root, and a root-owned archive is one you cannot delete without sudo).
docker run --rm \
-v "${VOLUME}:/source:ro" \
-v "${DEST}:/backup" \
"$ALPINE_IMAGE" \
sh -c "tar -czf /backup/$(basename "$ARCHIVE") -C /source . && chown $(id -u):$(id -g) /backup/$(basename "$ARCHIVE")"
SOURCE="docker volume ${VOLUME}"
if [ "$STOPPED" -eq 1 ]; then
echo "==> Starting ${SERVICE} again"
docker compose start "$SERVICE" >/dev/null
STOPPED=0
fi
fi
echo "==> Verifying the archive can be read back"
tar -tzf "$ARCHIVE" > "${WORK}/listing"
ENTRIES="$(wc -l < "${WORK}/listing" | tr -d ' ')"
BYTES="$(wc -c < "$ARCHIVE" | tr -d ' ')"
if command -v sha256sum >/dev/null 2>&1; then
SHA256="$(sha256sum "$ARCHIVE" | cut -d ' ' -f 1)"
else
SHA256="$(shasum -a 256 "$ARCHIVE" | cut -d ' ' -f 1)"
fi
# GNU tar, bsdtar and BusyBox tar all store "-C DIR ." entries as ./name; accept a bare
# name as well so an archive made some other way is still checked rather than rejected.
DB_MEMBER="$(grep -E '^(\./)?webui\.db$' "${WORK}/listing" | head -n 1 || true)"
[ -n "$DB_MEMBER" ] || {
echo "The archive has no webui.db at its top level; it is not a front-end backup." >&2
exit 1
}
DB_CHECK="skipped"
USERS="null"
CHATS="null"
if command -v python3 >/dev/null 2>&1; then
echo "==> Checking the database inside the archive"
MEMBERS=("$DB_MEMBER")
for extra in "${DB_MEMBER}-wal" "${DB_MEMBER}-shm"; do
if grep -qxF "$extra" "${WORK}/listing"; then MEMBERS+=("$extra"); fi
done
tar -xzf "$ARCHIVE" -C "$WORK" "${MEMBERS[@]}"
RESULT="$(python3 - "${WORK}/webui.db" <<'PY'
import sqlite3, sys
con = sqlite3.connect(sys.argv[1])
ok = con.execute("PRAGMA integrity_check").fetchone()[0]
users = con.execute('SELECT COUNT(*) FROM "user"').fetchone()[0]
chats = con.execute("SELECT COUNT(*) FROM chat").fetchone()[0]
print(ok, users, chats)
PY
)"
read -r DB_CHECK USERS CHATS <<< "$RESULT"
if [ "$DB_CHECK" != "ok" ]; then
echo "SQLite integrity_check did not return ok: ${RESULT}" >&2
exit 1
fi
else
echo "==> python3 not found; the database check was skipped"
fi
echo " ${ARCHIVE}"
echo " ${ENTRIES} entries, ${BYTES} bytes, sha256 ${SHA256}"
echo " integrity_check=${DB_CHECK} users=${USERS} chats=${CHATS}"
if [ -n "$LABBOOK" ]; then
printf '{"lab":"part-07/lab-private-chat-service-on-your-lan","step":"backup","archive":"%s","source":"%s","entries":%s,"bytes":%s,"sha256":"%s","integrity_check":"%s","users":%s,"chats":%s,"taken":"%s"}\n' \
"$(basename "$ARCHIVE")" "$SOURCE" "$ENTRIES" "$BYTES" "$SHA256" "$DB_CHECK" "$USERS" "$CHATS" "$STAMP" >> "$LABBOOK"
echo " recorded in ${LABBOOK}"
fi
echo "==> Done. Restore it into a scratch volume (task 10 on the lab page) before you rely on it."

Download backup-webui.sh167 lines

Track S — NVIDIA DGX Spark

RunnableTrack S · DGX Spark

take a backup and record it
cd ~/home-chat
bash backup-webui.sh --dest ~/backups --labbook ~/home-chat/labbook.md

Track X — AMD Ryzen AI Max+ 395

RunnableTrack X · Ryzen AI Max+

take a backup and record it
cd ~/home-chat
bash backup-webui.sh --dest ~/backups --labbook ~/home-chat/labbook.md

Track M — Apple silicon

RunnableTrack M · Apple silicon

take a backup and record it
bash ~/home-chat/backup-webui.sh --data-dir ~/home-chat/webui-data --dest ~/backups --labbook ~/home-chat/labbook.md

The script asks you to stop the front-end (Ctrl-C in terminal 2) and press return; start it again with the script once the archive is written.

Track N — NVIDIA desktop or laptop

RunnableTrack N · NVIDIA GPU

take a backup and record it
cd ~/home-chat
bash backup-webui.sh --dest ~/backups --labbook ~/home-chat/labbook.md

Output — what you should see

==> Stopping webui so the database is closed while it is copied
==> Archiving volume home-chat_webui-data
==> Starting webui again
==> Verifying the archive can be read back
==> Checking the database inside the archive
/home/you/backups/open-webui-20260913T210000Z.tar.gz
xx entries, xxxxxxx bytes, sha256 <64 hexadecimal characters>
integrity_check=ok users=x chats=x
recorded in /home/you/home-chat/labbook.md
==> Done. Restore it into a scratch volume (task 10 on the lab page) before you rely on it.

The front-end is down only for the archiving step. integrity_check=ok is SQLite’s own check run on the copy inside the archive, and users and chats are row counts from that copy; they must match what Admin Panel > Users and the chat sidebar show.

A backup you have never restored is a belief. Restore this one into a scratch copy and open it with a second, throwaway front-end bound to loopback:

Track S — NVIDIA DGX Spark

RunnableTrack S · DGX Spark

restore into a scratch volume and start a throwaway front-end on it
ARCHIVE="$(ls -1t ~/backups/open-webui-*.tar.gz | head -n 1)"
echo "$ARCHIVE"
docker volume create webui-restore-test
docker run --rm -v webui-restore-test:/target -v "$(dirname "$ARCHIVE")":/backup:ro alpine:3.24 tar -xzf "/backup/$(basename "$ARCHIVE")" -C /target
docker run --detach --name webui-restore-test -p 127.0.0.1:3001:8080 -v webui-restore-test:/app/backend/data -e WEBUI_SECRET_KEY="$(openssl rand -hex 32)" ghcr.io/open-webui/open-webui:v0.11.3
for i in $(seq 1 60); do [ "$(docker inspect --format '{{.State.Health.Status}}' webui-restore-test)" = healthy ] && break; sleep 5; done
curl -sS http://127.0.0.1:3001/health

Track X — AMD Ryzen AI Max+ 395

RunnableTrack X · Ryzen AI Max+

restore into a scratch volume and start a throwaway front-end on it
ARCHIVE="$(ls -1t ~/backups/open-webui-*.tar.gz | head -n 1)"
echo "$ARCHIVE"
docker volume create webui-restore-test
docker run --rm -v webui-restore-test:/target -v "$(dirname "$ARCHIVE")":/backup:ro alpine:3.24 tar -xzf "/backup/$(basename "$ARCHIVE")" -C /target
docker run --detach --name webui-restore-test -p 127.0.0.1:3001:8080 -v webui-restore-test:/app/backend/data -e WEBUI_SECRET_KEY="$(openssl rand -hex 32)" ghcr.io/open-webui/open-webui:v0.11.3
for i in $(seq 1 60); do [ "$(docker inspect --format '{{.State.Health.Status}}' webui-restore-test)" = healthy ] && break; sleep 5; done
curl -sS http://127.0.0.1:3001/health

Track M — Apple silicon

RunnableTrack M · Apple silicon

restore into a scratch directory and start a throwaway front-end on it
ARCHIVE="$(ls -1t ~/backups/open-webui-*.tar.gz | head -n 1)"
echo "$ARCHIVE"
mkdir -p ~/restore-test/webui-data
tar -xzf "$ARCHIVE" -C ~/restore-test/webui-data
DATA_DIR="$HOME/restore-test/webui-data" WEBUI_SECRET_KEY="$(openssl rand -hex 32)" ~/home-chat/venv/bin/open-webui serve --host 127.0.0.1 --port 3001

In another terminal, curl -sS http://127.0.0.1:3001/health once the log settles.

Track N — NVIDIA desktop or laptop

RunnableTrack N · NVIDIA GPU

restore into a scratch volume and start a throwaway front-end on it
ARCHIVE="$(ls -1t ~/backups/open-webui-*.tar.gz | head -n 1)"
echo "$ARCHIVE"
docker volume create webui-restore-test
docker run --rm -v webui-restore-test:/target -v "$(dirname "$ARCHIVE")":/backup:ro alpine:3.24 tar -xzf "/backup/$(basename "$ARCHIVE")" -C /target
docker run --detach --name webui-restore-test -p 127.0.0.1:3001:8080 -v webui-restore-test:/app/backend/data -e WEBUI_SECRET_KEY="$(openssl rand -hex 32)" ghcr.io/open-webui/open-webui:v0.11.3
for i in $(seq 1 60); do [ "$(docker inspect --format '{{.State.Health.Status}}' webui-restore-test)" = healthy ] && break; sleep 5; done
curl -sS http://127.0.0.1:3001/health

Output — what you should see

/home/you/backups/open-webui-20260913T210000Z.tar.gz
webui-restore-test
<64 hexadecimal characters: the container ID>
{"status":true}

The wait gives the container up to five minutes to report healthy. If curl then fails, the restored copy did not start; the troubleshooting row for a restore that never becomes healthy says where to look.

Open http://127.0.0.1:3001 in a browser on the server; on a headless machine, forward the port from your laptop with ssh -N -L 3001:127.0.0.1:3001 <user>@<server> and open the same address there. Sign in with the administrator’s new password. The chats and the Admin Panel

Users list must match the counts the backup printed. On the container tracks the throwaway front-end cannot reach an engine (the restored settings point at http://engine:11434, which exists only inside the stack), so an empty model list is expected; this test is about the data.

Record: the archive name, bytes, SHA-256, integrity_check, users and chats; the date of this restore test; and whether the counts in the restored front-end matched.

What the archive leaves out is deliberate:

Item In this backup? Cost of losing it Decision
webui-data (accounts, chats, uploads, settings) yes exists nowhere else the archive, on a second device
caddy-data (the local CA and its root.key) no one root reinstallation on every device (task 6) accept it, or copy caddy/pki/authorities/local/ separately and keep that copy as private as a password
.env (WEBUI_SECRET_KEY among the rest) no everybody signs in again keep a copy outside the machine
engine-models (the pulled model) no a 2.6 GB download download again

The rule for a service that stays running: back up after changing accounts and at least weekly; keep the last four archives on a device other than the server, since a copy on the same disk dies with the disk; and repeat the restore test after every Open WebUI upgrade, because the pinned source migrates the database at start-up. Avoid --no-stop: it copies the database while the front-end may be writing, and the WAL demonstration shows what such a copy can miss.

Complete the access and restore tests from a second device

Section titled “Complete the access and restore tests from a second device”

Keep a checklist for the browser-to-front-end connection and another for front-end-to-engine traffic. The first user registration, certificate trust and model loading are separate checkpoints. Finish each before involving another household user so a login problem is not confused with an empty model library or an unavailable backend.

From another LAN device, test the intended HTTPS address, an unauthenticated visit and any direct backend port the configuration is meant to hide. Keep the expected allow/deny outcome with each probe. A successful authenticated conversation proves only the allowed path; the denied probes establish whether the front door can be bypassed.

For the backup check, restore into the separate test location described in the lesson and verify a known account and conversation through the restored instance. Ensure the original instance is not answering that test. Retain certificate trust instructions, configuration with secrets removed, the backup manifest and the restore result. After cleanup, confirm that temporary restore ports are no longer reachable while the intended service remains available if you elected to keep it running.

You are done when every row passes.

Criterion Command or place Pass
three services healthy (or three processes on Track M) docker compose ps caddy Up, engine and webui Up (healthy), no Restarting
the model runs where you expect docker compose exec engine ollama ps, or the offload log line 100% GPU, CONTEXT 8192; or offloaded N/N layers
proxy bound to one address sudo ss -ltnp (or sudo lsof on Track M) only <LAN_IP>:80 and <LAN_IP>:443 for this stack
exposure from the house network probe-lan-exposure.sh on a second computer 8 passed, 0 failed
certificate trusted on a phone open https://chat.home.arpa/ on it no warning
household account works sign in on the phone as the user account the model is listed and the answer streams
no password left in .env grep -c '^WEBUI_ADMIN_PASSWORD=.' ~/home-chat/.env 0
exactly the accounts you created Admin Panel > Users the administrator and the household accounts only
backup is restorable backup-webui.sh, then the restore test integrity_check=ok, and the restored front-end shows the same chats
notebook complete grep -c '"step":"backup"' ~/home-chat/labbook.md on the server, grep -c '"step":"probe"' labbook.md on the laptop at least 1 each

A service, not a demonstration. Somebody in your house who has never heard of a context window opens a bookmark, signs in, and gets an answer from a machine in the next room over a connection their phone has verified. The conversation is processed and stored on that machine, and the front-end is configured not to call OpenAI’s API or check for releases. The reality check that follows changes the context length.

Symptom Cause Fix
required variable LAN_IP is missing a value (or another variable) .env missing, in another directory, or the line is empty run Compose in ~/home-chat; fill the line; docker compose config --quiet
proxy container exits with bind: cannot assign requested address LAN_IP is not an address this machine has now; usually the router gave it a new one set the router reservation (task 1), correct LAN_IP, docker compose up --detach
permission denied while trying to connect to the docker API at unix:///var/run/docker.sock (Docker 29.8.0 wording; other versions differ after permission denied) your account is not in the docker group, so Docker works only with sudo Docker’s post-installation page: sudo usermod -aG docker $USER, then log out and back in; run the preflight again
bind: address already in use another web server holds 80 or 443 the preflight ss line (or lsof on Track M) names it; stop it
Track M: Caddy logs bind: permission denied for 80 or 443 Caddy was started without sudo run the task 4 terminal 3 command as written, including sudo
the browser cannot find chat.home.arpa, or the probe says curl exit 6 DNS, not TLS: the router entry is missing, or the device uses another resolver (a phone’s private-DNS setting bypasses the router) fix the router entry; turn private DNS off on the house network, or use the .local name
certificate warning remains on iOS after installing the trust toggle is a separate step Settings > General > About > Certificate Trust Settings
probe says curl exit 60, or devices that were fine start warning the root changed: caddy-data was deleted or recreated, so Caddy made a new CA export the new root (task 6) and reinstall it on every device; keep the volume from now on
the administrator sees the model, a household account sees none the model has no access configuration, so only admins see it Settings > Admin > Models, set Visibility (task 7, step 6); confirm with Preview Access
nobody sees any model the engine has no model, or the stored connection URL is wrong docker compose exec engine ollama list; Settings > Admin > Connections must show http://engine:11434 (or http://engine:8080/v1)
you switched to compose-llama-server.yaml but the front-end still talks to Ollama connection settings are ConfigVar values stored on first start Settings > Admin > Connections: add the OpenAI connection http://engine:8080/v1 with no key and switch Ollama off; or start over with a fresh webui-data after a backup
sign-in loops, or chat requests fail CORS_ALLOW_ORIGIN does not match the name in the address bar, or the site was opened over plain HTTP where the Secure cookie is never sent open https:// plus exactly CHAT_HOST; if you changed the name, fix .env and docker compose up --detach
Admin Panel > Users lists an account you did not create someone registered while sign-up was open, for example on a run before ENABLE_SIGNUP was set delete it; if its role was admin, treat the instance as untrusted: back up what you need and start with a fresh volume
answers arrive all at once after a pause a proxy other than Caddy (for example nginx with proxy_buffering on), or a CDN, is buffering with nginx, proxy buffering off; remove the CDN from a house-only service
a second person’s question waits until the first answer ends one slot, plus background tasks in the same queue turn off background tasks (task 7); raise OLLAMA_NUM_PARALLEL only after pricing it in the memory table
the first message after a quiet period is slow the model was unloaded after OLLAMA_KEEP_ALIVE raise it in .env, docker compose up --detach, and accept the memory held continuously
Track X: library=cpu in the engine log, 100% CPU in ollama ps the container cannot use the GPU confirm the ROCm v7 stack on the host; confirm /dev/kfd and /dev/dri exist; sudo setsebool container_use_devices=1 on SELinux (-P to keep it after a reboot); then try the default image (Vulkan) by deleting the image: line in compose-amd.yaml, and the - /dev/kfd line too if that device does not exist; record which step changed it
Track X: error gathering device information while adding custom device "/dev/kfd": no such file or directory compose-amd.yaml lists /dev/kfd, and this host has no such device (the ROCm driver is not loaded) on the Ollama -rocm path, install the host ROCm v7 stack (Requirements, Track X) until /dev/kfd exists; on the Vulkan path, delete the - /dev/kfd line from compose-amd.yaml and docker compose up --detach --wait again
llama-server engine keeps restarting the model path does not exist inside the container docker compose logs engine; check MODELS_DIR is absolute and LLAMA_MODEL_FILE is relative to it
Track N on Windows: other devices cannot connect WSL2 is in NAT mode, or the Hyper-V firewall blocks inbound connections mirrored networking, and the two New-NetFirewallHyperVRule lines for TCP 80 and 443 from the Track N requirements
Track M: uv pip install open-webui==0.11.3 fails to resolve onnxruntime==1.26.0 macOS older than 14, or an Intel Mac: that release publishes macOS wheels only for macOS 14 on arm64 update to macOS 14 or later on Apple silicon; there is no other Track M path for this pinned front-end
Track M: the probe fails closed-8080 the front-end listens on every interface stop it and start it with start-webui-native.sh, which passes --host 127.0.0.1
backup-webui.sh says No compose.yaml in … run from another directory, so it cannot stop the service cd ~/home-chat first
the restore test’s curl fails after the wait, or webui-restore-test never becomes healthy the restored container exited or keeps failing its health check (a damaged archive, or an archive with the wrong layout) docker logs webui-restore-test; check the archive lists ./webui.db with tar -tzf "$ARCHIVE"
everything works on the server and nothing works elsewhere published on 127.0.0.1, or on the wrong address check LAN_IP, then sudo ss -ltnp

Nothing here needs undoing; the point is a service that stays, and the reality check uses it. To stop it and keep every account, chat and the certificate authority:

RunnableAll tracks

stop the service, keeping all data (container tracks)
cd ~/home-chat
docker compose down

On Track M, press Ctrl-C in the front-end’s and Caddy’s terminals; the Ollama application can keep running.

After a permanent removal, also remove the root certificate from every other device you installed it on (on Android, User credentials under Encryption & credentials, per the cited help page; on iOS, the installed profile), delete the router’s DNS entry and address reservation, and restart the native Ollama service if you stopped it in the preflight (sudo systemctl start ollama). Keep ~/backups and ~/home-chat/labbook.md.

Objective The observation that proved it Recorded
an engine behind a front-end in containers docker compose ps healthy; ollama ps or the offload line image digests, PROCESSOR, CONTEXT, memory per container
bound to the house network and nothing wider ss showing only <LAN_IP>:80 and :443; the probe’s closed-port checks from a second device the ss lines, interface list, probe JSON line
a reverse proxy with a local certificate curl --cacert succeeding and plain curl failing with 60; no warning on the phone root fingerprint and expiry, per-device trust
user accounts one administrator created before the site answered; a user account that sees the model only after its visibility was set; the pending page accounts and roles, Preview Access result, background tasks
testing from a phone the phone’s User-Agent in Caddy’s log and an answer that streamed device, browser, streaming yes or no
backup of the front-end’s data integrity_check=ok on the archived database, and a throwaway front-end showing the same chats archive, bytes, SHA-256, row counts, restore date

The pattern under all six rows is the same one the course applies to speed: state what should be true, find the command whose output would contradict it, and keep the output.

Check your understanding

Question 1. You add a ufw rule denying port 443 from everywhere, but the service is still reachable from another machine. Why?
Show the answer and why

Answer: Docker publishes ports by rules in the nat table, so traffic to a published port is diverted before it reaches the chains ufw uses; the host address in the port mapping is the control that applies

Docker's own documentation states this. A host firewall still filters ordinary processes, which is why the temporary python3 web server in task 6 can be blocked by ufw while the container on 443 is not. For containers, the address in the mapping and a probe from a second device are what you can rely on.

Question 2. Which of these compose.yaml lines exposes the chat service on every interface of the machine, including a VPN tunnel and IPv6?
Show the answer and why

Answer: - "443:443"

Without a host address, Compose binds 0.0.0.0 and Docker also publishes on [::]. The loopback form is too narrow for a household service (the house cannot reach it), and the missing ports: key on the engine is the design, not a bug. Only the LAN_IP form matches "the house and nothing wider", and even that leaves the documented direct-routing case on a machine with a second network.

Question 3. With OLLAMA_CONTEXT_LENGTH=8192 and OLLAMA_NUM_PARALLEL=1, the Qwen3-4B key-value cache is 147,456 bytes per token. You set OLLAMA_NUM_PARALLEL=2 so two people can chat at once. What happens to the cache, and what would the same change do on llama-server with --ctx-size 8192?
Show the answer and why

Answer: Ollama: it doubles, from about 1.21 GB to 2.42 GB. llama-server: the total stays about 1.21 GB and each slot gets 4,096 tokens

The Ollama FAQ states that required memory scales with OLLAMA_NUM_PARALLEL times OLLAMA_CONTEXT_LENGTH: 147,456 × 8,192 × 2 = 2,415,919,104 bytes. llama-server at b10868 divides --ctx-size among explicitly configured slots, so the memory holds and each conversation gets half the window. Same two numbers, two different trade-offs, which is why the lab records both settings.

Question 4. Which of these are genuine reasons to use TLS on a service that never leaves your home network? Select all that apply.
Show the answer and why

Answer: Messages otherwise cross the Wi-Fi in clear text, and a home network contains devices you did not choose, The stack's session cookie is marked Secure, and browsers send such cookies only over HTTPS, A trusted certificate lets a device confirm it is talking to your machine and not to something else claiming the address

Confidentiality, a working login and identity all survive the objection that "it is only my house". The fourth is a category error, included because this is exactly the kind of claim that attaches itself to security features.

Question 5. The backup script stops the front-end before archiving its data directory. In WAL mode, what could a copy of webui.db taken while the front-end runs be missing?
Show the answer and why

Answer: Committed transactions that are still in webui.db-wal and have not been checkpointed into webui.db

In WAL mode commits go to the -wal file and reach the main file at a checkpoint; SQLite runs a final checkpoint when the last connection closes. The demonstration in task 10 shows three committed rows present in the process and absent from a copy of webui.db alone. Stopping the service is the simplest way to get that final checkpoint, and the integrity check on the archived copy confirms the result.

Question 6. A household member's phone cannot resolve chat.home.arpa, although a laptop on the same Wi-Fi can. What is the most likely cause?
Show the answer and why

Answer: The phone uses a private DNS resolver instead of the router, so a name only the router knows never resolves

A certificate problem produces a warning after the name resolves and the connection opens; a resolution failure never gets that far, which is how you tell the two apart (the probe reports them as curl exit 6 and exit 60). RFC 8375 designates home.arpa for locally significant names, and a resolver that is not your router has never heard of yours.

Sources for this lesson

59 verified · checked 2026-09-13

  1. 01Open WebUI Docs — Quick Start§ Docker; Docker Compose; Python (pip/uv); the first accountdocs.openwebui.com/getting-started/quick-start2026-09-08
  2. 02Open WebUI Docs — Hardening Open WebUI§ Secrets; registration; cookie settings; network architecture; TLS; CORSdocs.openwebui.com/getting-started/advanced-topics/hardening2026-09-13
  3. 03Open WebUI Docs — Starting With OpenAI-Compatible Serversdocs.openwebui.com/getting-started/quick-start/connect-a-provider/starting-with-openai-compatible2026-09-08
  4. 04Open WebUI Docs — Environment Variable Configuration§ ConfigVar environment variables; ENABLE_SIGNUP; WEBUI_ADMIN_EMAIL; DEFAULT_USER_ROLE; OLLAMA_BASE_URL; ENABLE_OPENAI_API; WEBUI_SECRET_KEY; CORS_ALLOW_ORIGIN; ENABLE_VERSION_UPDATE_CHECKdocs.openwebui.com/reference/env-configuration2026-09-13
  5. 05Open WebUI Docs — Roles§ Role details; headless admin account creationdocs.openwebui.com/features/authentication-access/rbac/roles2026-09-13
  6. 06Open WebUI Docs — Groups§ Preview Accessdocs.openwebui.com/features/authentication-access/rbac/groups2026-09-13
  7. 07Open WebUI Docs — Models (Workspace)§ Core configuration; Visibilitydocs.openwebui.com/features/workspace/models2026-09-13
  8. 08Open WebUI Docs — Task Models§ Turning individual tasks offdocs.openwebui.com/features/administration/task-models2026-09-13
  9. 09Open WebUI Docs — Llama.cppdocs.openwebui.com/getting-started/quick-start/connect-a-provider/starting-with-llama-cpp2026-09-13
  10. 10Open WebUI Docs — Backups (community tutorial)§ Files in persistent data storedocs.openwebui.com/tutorials/maintenance/backups2026-09-13
  11. 11Open WebUI v0.11.3 source — authentication router§ signup; add_usergithub.com/open-webui/open-webui/blob/v0.11.3/backend/open_webui/routers/auths.py2026-09-13
  12. 12Open WebUI v0.11.3 source — command-line entry point§ serve (host and port defaults)github.com/open-webui/open-webui/blob/v0.11.3/backend/open_webui/__init__.py2026-09-13
  13. 13Open WebUI v0.11.3 source — database connection§ DATABASE_ENABLE_SQLITE_WAL, journal_modegithub.com/open-webui/open-webui/blob/v0.11.3/backend/open_webui/internal/db.py2026-09-13
  14. 14Open WebUI v0.11.3 source — model filtering§ get_filtered_modelsgithub.com/open-webui/open-webui/blob/v0.11.3/backend/open_webui/utils/models.py2026-09-13
  15. 15Open WebUI v0.11.3 source — configuration§ run_migrations (Alembic at start-up)github.com/open-webui/open-webui/blob/v0.11.3/backend/open_webui/config.py2026-09-13
  16. 16Open WebUI v0.11.3 source — pyproject.toml§ dependencies (onnxruntime==1.26.0); requires-pythongithub.com/open-webui/open-webui/blob/v0.11.3/pyproject.toml2026-09-13
  17. 17PyPI — onnxruntime 1.26.0 files§ macOS wheels (macosx_14_0_arm64 only)pypi.org/project/onnxruntime/1.26.02026-09-13
  18. 18Ollama documentation v0.33.3 — Context length§ defaults by memory; App slider; ollama psgithub.com/ollama/ollama/blob/v0.33.3/docs/context-length.mdx2026-09-13
  19. 19Ollama documentation v0.33.3 — GPU§ AMD, SELinux (container_use_devices)github.com/ollama/ollama/blob/v0.33.3/docs/gpu.mdx2026-09-13
  20. 20Ollama documentation v0.33.3 — Troubleshooting§ server logs on Mac (~/.ollama/logs/server.log)github.com/ollama/ollama/blob/v0.33.3/docs/troubleshooting.mdx2026-09-13
  21. 21Ollama v0.33.3 source — server/routes.go and go.mod§ gin.Default() request logger; gin v1.10.0github.com/ollama/ollama/blob/v0.33.3/server/routes.go2026-09-13
  22. 22Gin v1.10.0 source — logger.go§ defaultLogFormattergithub.com/gin-gonic/gin/blob/v1.10.0/logger.go2026-09-13
  23. 23setsebool(8) manual page§ -Pman7.org/linux/man-pages/man8/setsebool.8.html2026-09-13
  24. 24Ollama documentation — Docker§ Nvidia GPU; AMD GPU; Vulkan supportraw.githubusercontent.com/ollama/ollama/main/docs/docker.mdx2026-09-08
  25. 25Ollama documentation — FAQ§ How can I expose Ollama on my network; setting environment variables on Mac; keep alive; context window size; concurrencyraw.githubusercontent.com/ollama/ollama/main/docs/faq.mdx2026-09-08
  26. 26Ollama library — qwen3 tagsollama.com/library/qwen3/tags2026-09-13
  27. 27llama.cpp — Docker documentation§ Imagesgithub.com/ggml-org/llama.cpp/blob/master/docs/docker.md2026-09-13
  28. 28llama.cpp b10868 — llama-server README§ --ctx-size; --parallel; --kv-unified; --alias; GET /health; GET /v1/modelsgithub.com/ggml-org/llama.cpp/blob/b10868/tools/server/README.md2026-09-13
  29. 29llama.cpp b10868 — llama-context.cpp§ n_ctx_seqgithub.com/ggml-org/llama.cpp/blob/b10868/src/llama-context.cpp2026-09-13
  30. 30llama.cpp b10868 — tools/server/server-context.cpp and server-http.cpp§ launch_slot_with_task "processing task" log line; per-request logger disabled in server-http.cppgithub.com/ggml-org/llama.cpp/blob/b10868/tools/server/server-context.cpp2026-09-13
  31. 31Hugging Face — unsloth/Qwen3-4B-GGUF file listinghuggingface.co/unsloth/Qwen3-4B-GGUF/tree/main2026-09-13
  32. 32Caddy documentation — tls directive§ internal (lifetimes; installing the root from a container)caddyserver.com/docs/caddyfile/directives/tls2026-09-08
  33. 33Caddy documentation — Automatic HTTPS§ Local HTTPS; CA rootcaddyserver.com/docs/automatic-https2026-09-08
  34. 34Caddy documentation — bind directivecaddyserver.com/docs/caddyfile/directives/bind2026-09-08
  35. 35Caddy documentation — reverse_proxy directive§ Streaming; flush_interval (ignored for text/event-stream and unknown Content-Length)caddyserver.com/docs/caddyfile/directives/reverse_proxy2026-09-13
  36. 36Caddy documentation — Conventions§ Data directorycaddyserver.com/docs/conventions2026-09-08
  37. 37Caddy documentation — Caddyfile Concepts§ Environment variables; site addressescaddyserver.com/docs/caddyfile/concepts2026-09-08
  38. 38Caddy documentation — Command Line§ caddy run; caddy trust; caddy untrust (--cert); caddy versioncaddyserver.com/docs/command-line2026-09-13
  39. 39Caddy v2.11.4 source — logging.go and modules/caddypki/ca.go§ console encoder in an interactive terminal, JSON otherwise; root installation log messages (ca.go, pki.go)github.com/caddyserver/caddy/blob/v2.11.4/logging.go2026-09-13
  40. 40Docker Hub — caddy official image§ /data and /config; do not mount the Caddyfile directlyhub.docker.com/_/caddy2026-09-13
  41. 41Docker documentation — Packet filtering and firewalls§ Docker and ufwdocs.docker.com/engine/network/packet-filtering-firewalls2026-09-08
  42. 42Docker documentation — Port publishing and mapping§ Publishing ports; direct routing; default bind addressdocs.docker.com/engine/network/port-publishing2026-09-13
  43. 43Docker documentation — Compose file services reference§ portsdocs.docker.com/reference/compose-file/services2026-09-08
  44. 44Docker documentation — Interpolationdocs.docker.com/reference/compose-file/interpolation2026-09-13
  45. 45Docker documentation — Merge Compose filesdocs.docker.com/reference/compose-file/merge2026-09-13
  46. 46Docker documentation — Pre-defined environment variables in Compose§ COMPOSE_FILEdocs.docker.com/compose/how-tos/environment-variables/envvars2026-09-13
  47. 47Docker documentation — docker compose up§ --wait; --wait-timeoutdocs.docker.com/reference/cli/docker/compose/up2026-09-13
  48. 48Docker documentation — docker compose cpdocs.docker.com/reference/cli/docker/compose/cp2026-09-13
  49. 49Docker documentation — docker compose logs§ --sincedocs.docker.com/reference/cli/docker/compose/logs2026-09-13
  50. 50Docker documentation — docker compose down§ --volumes; --rmidocs.docker.com/reference/cli/docker/compose/down2026-09-13
  51. 51Docker documentation — Linux post-installation steps for Docker Engine§ Manage Docker as a non-root userdocs.docker.com/engine/install/linux-postinstall2026-09-13
  52. 52Docker documentation — GPU support in Docker Composedocs.docker.com/compose/how-tos/gpu-support2026-09-13
  53. 53NVIDIA Container Toolkit — Running a sample workloaddocs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/sample-workload.html2026-09-13
  54. 54SQLite — Write-Ahead Logging§ Checkpointing; the last connection closingsqlite.org/wal.html2026-09-13
  55. 55Microsoft Learn — Accessing network applications with WSL§ Mirrored mode networking; Hyper-V firewall (New-NetFirewallHyperVRule)learn.microsoft.com/en-us/windows/wsl/networking2026-09-13
  56. 56Apple Support — Trust manually installed certificate profiles in iOS, iPadOS and visionOSsupport.apple.com/en-us/1023902026-09-13
  57. 57Android Help — Add & remove certificatessupport.google.com/android/answer/28448322026-09-13
  58. 58Ubuntu Server documentation — Install a root CA certificate in the trust store§ Install a PEM-format certificate; Uninstall a PEM-format certificate (update-ca-certificates --fresh)documentation.ubuntu.com/server/how-to/security/install-a-root-ca-certificate-in-the-trust-store2026-09-13
  59. 59RFC 8375: Special-Use Domain 'home.arpa.'rfc-editor.org/rfc/rfc8375.html2026-09-08

Every technical claim on this page was checked against the official documentation of the tool, vendor or model publisher on the date shown, at the version pinned for the course. Where the course disagrees with folklore, the source is how you can tell which one to trust.