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.
Objective
Section titled “Objective”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”.
Architecture
Section titled “Architecture”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).
- Phone connected to Reverse proxyHTTPS 443, home network only
- Laptop connected to Reverse proxyHTTPS 443, home network only
- Reverse proxy connected to Front-endHTTP, private container network
- Front-end connected to EngineHTTP, private container network
- Engine connected to Volumesweights
- Front-end connected to Volumesaccounts, chats, uploads
What this design refuses to do, and why
Section titled “What this design refuses to do, and why”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.
Requirements
Section titled “Requirements”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
New-NetFirewallHyperVRule -Name "HomeChatHttp" -DisplayName "Home chat HTTP" -Direction Inbound -VMCreatorId '{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}' -Protocol TCP -LocalPorts 80New-NetFirewallHyperVRule -Name "HomeChatHttps" -DisplayName "Home chat HTTPS" -Direction Inbound -VMCreatorId '{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}' -Protocol TCP -LocalPorts 443This Windows path is written from that page and has not been validated.
Preflight
Section titled “Preflight”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 version --format '{{.Server.Version}}'docker compose versionsudo docker run --rm --runtime=nvidia --gpus all ubuntu nvidia-smidf -h "$(docker info --format '{{.DockerRootDir}}')"systemctl is-active ollamasudo ss -ltnpH '( sport = :80 or sport = :443 )'command -v opensslcommand -v curlcommand -v python3Output — what you should see
29.x.xDocker 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/python3Pass: 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
ls -l ~/models/unsloth/Qwen3-4B-GGUF/Qwen3-4B-Q4_K_M.ggufsha256sum ~/models/unsloth/Qwen3-4B-GGUF/Qwen3-4B-Q4_K_M.ggufOutput — what you should see
-rw-rw-r-- 1 you you 2497281312 ... /home/you/models/unsloth/Qwen3-4B-GGUF/Qwen3-4B-Q4_K_M.gguff6f851777709861056efcdad3af01da38b31223a3ba26e61a4f8bf3a2195813a /home/you/models/unsloth/Qwen3-4B-GGUF/Qwen3-4B-Q4_K_M.ggufTrack X — AMD Ryzen AI Max+ 395
RunnableTrack X · Ryzen AI Max+
docker version --format '{{.Server.Version}}'docker compose versionls -l /dev/kfd /dev/dri/renderD*df -h "$(docker info --format '{{.DockerRootDir}}')"systemctl is-active ollamasudo ss -ltnpH '( sport = :80 or sport = :443 )'command -v opensslcommand -v curlcommand -v python3Output — what you should see
29.x.xDocker Compose version vX.Y.Zcrw-rw---- 1 root render 2xx, 0 ... /dev/kfdcrw-rw---- 1 root render 226, 128 ... /dev/dri/renderD128Filesystem Size Used Avail Use% Mounted on/dev/nvme0n1p2 xxxG xxxG xxxG xx% /inactive/usr/bin/openssl/usr/bin/curl/usr/bin/python3Pass: 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+
ls -l ~/models/unsloth/Qwen3-4B-GGUF/Qwen3-4B-Q4_K_M.ggufsha256sum ~/models/unsloth/Qwen3-4B-GGUF/Qwen3-4B-Q4_K_M.ggufOutput — what you should see
-rw-rw-r-- 1 you you 2497281312 ... /home/you/models/unsloth/Qwen3-4B-GGUF/Qwen3-4B-Q4_K_M.gguff6f851777709861056efcdad3af01da38b31223a3ba26e61a4f8bf3a2195813a /home/you/models/unsloth/Qwen3-4B-GGUF/Qwen3-4B-Q4_K_M.ggufTrack M — Apple silicon
RunnableTrack M · Apple silicon
sw_vers -productVersionuname -mcurl -sS http://127.0.0.1:11434/api/versionollama --versionuv --versionbrew --versiondf -h ~sudo lsof -nP -iTCP:80 -iTCP:443 -sTCP:LISTENcommand -v opensslcommand -v curlcommand -v python3Output — what you should see
xx.xarm64{"version":"0.33.3"}ollama version is 0.33.3uv 0.12.x (...)Homebrew x.y.zFilesystem Size Used Avail Capacity ... Mounted on/dev/disk3s5 xxxGi xxxGi xxxGi xx% ... /System/Volumes/Data/usr/bin/openssl/usr/bin/curl/usr/bin/python3Pass: 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 version --format '{{.Server.Version}}'docker compose versionsudo docker run --rm --runtime=nvidia --gpus all ubuntu nvidia-smidf -h "$(docker info --format '{{.DockerRootDir}}')"systemctl is-active ollamasudo ss -ltnpH '( sport = :80 or sport = :443 )'command -v opensslcommand -v curlcommand -v python3Output — what you should see
29.x.xDocker 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/python3Pass: 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
ls -l ~/models/unsloth/Qwen3-4B-GGUF/Qwen3-4B-Q4_K_M.ggufsha256sum ~/models/unsloth/Qwen3-4B-GGUF/Qwen3-4B-Q4_K_M.ggufOutput — what you should see
-rw-rw-r-- 1 you you 2497281312 ... /home/you/models/unsloth/Qwen3-4B-GGUF/Qwen3-4B-Q4_K_M.gguff6f851777709861056efcdad3af01da38b31223a3ba26e61a4f8bf3a2195813a /home/you/models/unsloth/Qwen3-4B-GGUF/Qwen3-4B-Q4_K_M.ggufOn 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
ip -4 route show defaultip -4 -brief addr show scope globalOutput — what you should see
default via <router-address> dev <interface> proto dhcp src <LAN_IP> metric xxx<interface> UP <LAN_IP>/24docker0 DOWN 172.17.0.1/16Track X — AMD Ryzen AI Max+ 395
RunnableTrack X · Ryzen AI Max+
ip -4 route show defaultip -4 -brief addr show scope globalOutput — what you should see
default via <router-address> dev <interface> proto dhcp src <LAN_IP> metric xxx<interface> UP <LAN_IP>/24docker0 DOWN 172.17.0.1/16Track M — Apple silicon
RunnableTrack M · Apple silicon
route -n get default | grep interfaceipconfig getifaddr en0Output — 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
ip -4 route show defaultip -4 -brief addr show scope globalOutput — what you should see
default via <router-address> dev <interface> proto dhcp src <LAN_IP> metric xxx<interface> UP <LAN_IP>/24docker0 DOWN 172.17.0.1/16The 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:
- Reserve that address for this machine (often called a DHCP reservation or static lease).
compose.yamlpublishes on a literal address; if the router later hands the machine a different one, the proxy container fails to start with an error ending inbind: cannot assign requested address. - 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
CHAT_HOST=chat.home.arpa # or your <hostname>.local name, if you chose mDNSgetent hosts "$CHAT_HOST" || dscacheutil -q host -a name "$CHAT_HOST"Output — what you should see
<LAN_IP> chat.home.arpaOn 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.
2. Take the files and make your .env
Section titled “2. Take the files and make your .env”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
mkdir -p ~/home-chat/caddyThen save each file into ~/home-chat with the download link under it.
RunnableAll tracks
# 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:RunnableAll tracks
# 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 }}Fragment — not complete on its own
# 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=AdminWEBUI_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-b10868LLAMA_SERVER_IMAGE=# Absolute path of your model library, for example /home/you/modelsMODELS_DIR=# Path of the GGUF file inside MODELS_DIRLLAMA_MODEL_FILE=unsloth/Qwen3-4B-GGUF/Qwen3-4B-Q4_K_M.gguf# The model name the front-end will listLLAMA_MODEL_ALIAS=qwen3-4bThe 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
# 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]RunnableTrack X · Ryzen AI Max+
# 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/driAnd, only if you choose llama-server as the engine (task 3 helps you decide), the override that swaps it in:
RunnableAll tracks
# 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: ""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
cp ~/home-chat/caddy.conf ~/home-chat/caddy/Caddyfilecp ~/home-chat/env-example.txt ~/home-chat/.envchmod 600 ~/home-chat/.envopenssl rand -hex 32openssl rand -base64 18Output — 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
cd ~/home-chatdocker compose config --quiet && echo "configuration OK"docker compose config --imagesdocker compose config | grep host_ipOutput — what you should see
configuration OKollama/ollama:0.33.3ghcr.io/open-webui/open-webui:v0.11.3caddy: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.
3. Read compose.yaml before you run it
Section titled “3. Read compose.yaml before you run it”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 |
4. Start it
Section titled “4. Start it”Track S — NVIDIA DGX Spark
RunnableTrack S · DGX Spark
cd ~/home-chatdocker compose up --detach --wait --wait-timeout 900docker compose psdocker image inspect ollama/ollama:0.33.3 --format '{{.Architecture}}'Output — what you should see
NAME IMAGE ... SERVICE ... STATUS PORTShome-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/tcphome-chat-engine-1 ollama/ollama:0.33.3 ... engine ... Up x minutes (healthy) 11434/tcphome-chat-webui-1 ghcr.io/open-webui/open-webui:v0.11.3 ... webui ... Up x minutes (healthy) 8080/tcparm64On 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+
cd ~/home-chatdocker compose up --detach --wait --wait-timeout 900docker compose psdocker compose logs engine | grep "inference compute"Output — what you should see
NAME IMAGE ... STATUS PORTShome-chat-caddy-1 caddy:2.11.4-alpine ... Up x minutes <LAN_IP>:80->80/tcp, <LAN_IP>:443->443/tcp, 443/udp, 2019/tcphome-chat-engine-1 ollama/ollama:0.33.3-rocm ... Up x minutes (healthy) 11434/tcphome-chat-webui-1 ghcr.io/open-webui/open-webui:v0.11.3 ... Up x minutes (healthy) 8080/tcpengine-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
launchctl setenv OLLAMA_CONTEXT_LENGTH 8192launchctl setenv OLLAMA_NUM_PARALLEL 1launchctl setenv OLLAMA_KEEP_ALIVE 30mThen the front-end’s environment, pinned to the course’s version:
RunnableTrack M · Apple silicon
uv venv --python 3.11 ~/home-chat/venvuv pip install --python ~/home-chat/venv/bin/python open-webui==0.11.3uv pip show --python ~/home-chat/venv/bin/python open-webuiOutput — what you should see
Name: open-webuiVersion: 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
#!/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=8080OLLAMA_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 ;; esacdone
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"RunnableTrack M · Apple silicon
bash ~/home-chat/start-webui-native.shOutput — 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
brew install caddycaddy versionOutput — 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
mkdir -p ~/home-chat/caddy-dataLAN_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
cd ~/home-chatdocker compose up --detach --wait --wait-timeout 900docker compose psdocker compose logs engine | grep "inference compute"Output — what you should see
NAME IMAGE ... STATUS PORTShome-chat-caddy-1 caddy:2.11.4-alpine ... Up x minutes <LAN_IP>:80->80/tcp, <LAN_IP>:443->443/tcp, 443/udp, 2019/tcphome-chat-engine-1 ollama/ollama:0.33.3 ... Up x minutes (healthy) 11434/tcphome-chat-webui-1 ghcr.io/open-webui/open-webui:v0.11.3 ... Up x minutes (healthy) 8080/tcpengine-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.
5. Give the engine a model
Section titled “5. Give the engine a model”Track S — NVIDIA DGX Spark
RunnableTrack S · DGX Spark
docker compose exec engine ollama pull qwen3:4b-q4_K_Mdocker compose exec engine ollama listdocker compose exec engine ollama run qwen3:4b-q4_K_M "Reply with one word: ready"docker compose exec engine ollama psOutput — what you should see
NAME ID SIZE MODIFIEDqwen3:4b-q4_K_M 2bfd38a7daaf 2.6 GB x seconds ago...NAME ID SIZE PROCESSOR CONTEXT UNTILqwen3:4b-q4_K_M 2bfd38a7daaf x.x GB 100% GPU 8192 29 minutes from nowRunnableTrack S · DGX Spark
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 GPUengine-1 | ... model loadedengine-1 | ... listening on http://0.0.0.0:8080"id":"qwen3-4b"Track X — AMD Ryzen AI Max+ 395
RunnableTrack X · Ryzen AI Max+
docker compose exec engine ollama pull qwen3:4b-q4_K_Mdocker compose exec engine ollama listdocker compose exec engine ollama run qwen3:4b-q4_K_M "Reply with one word: ready"docker compose exec engine ollama psOutput — what you should see
NAME ID SIZE MODIFIEDqwen3:4b-q4_K_M 2bfd38a7daaf 2.6 GB x seconds ago...NAME ID SIZE PROCESSOR CONTEXT UNTILqwen3:4b-q4_K_M 2bfd38a7daaf x.x GB 100% GPU 8192 29 minutes from nowRunnableTrack X · Ryzen AI Max+
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 GPUengine-1 | ... model loadedengine-1 | ... listening on http://0.0.0.0:8080"id":"qwen3-4b"Track M — Apple silicon
RunnableTrack M · Apple silicon
ollama pull qwen3:4b-q4_K_Mollama listollama run qwen3:4b-q4_K_M "Reply with one word: ready"ollama psOutput — what you should see
NAME ID SIZE MODIFIEDqwen3:4b-q4_K_M 2bfd38a7daaf 2.6 GB x seconds ago...NAME ID SIZE PROCESSOR CONTEXT UNTILqwen3:4b-q4_K_M 2bfd38a7daaf x.x GB 100% GPU 8192 29 minutes from nowCONTEXT 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
docker compose exec engine ollama pull qwen3:4b-q4_K_Mdocker compose exec engine ollama listdocker compose exec engine ollama run qwen3:4b-q4_K_M "Reply with one word: ready"docker compose exec engine ollama psOutput — what you should see
NAME ID SIZE MODIFIEDqwen3:4b-q4_K_M 2bfd38a7daaf 2.6 GB x seconds ago...NAME ID SIZE PROCESSOR CONTEXT UNTILqwen3:4b-q4_K_M 2bfd38a7daaf x.x GB 100% GPU 8192 29 minutes from nowRunnableTrack N · NVIDIA GPU
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 GPUengine-1 | ... model loadedengine-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).
6. Trust the certificate
Section titled “6. Trust the certificate”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
cd ~/home-chatdocker compose cp caddy:/data/caddy/pki/authorities/local/root.crt ./home-chat-root.crtopenssl x509 -in home-chat-root.crt -noout -subject -enddate -fingerprint -sha256Track X — AMD Ryzen AI Max+ 395
RunnableTrack X · Ryzen AI Max+
cd ~/home-chatdocker compose cp caddy:/data/caddy/pki/authorities/local/root.crt ./home-chat-root.crtopenssl x509 -in home-chat-root.crt -noout -subject -enddate -fingerprint -sha256Track M — Apple silicon
RunnableTrack M · Apple silicon
cd ~/home-chatsudo cp caddy-data/caddy/pki/authorities/local/root.crt ./home-chat-root.crtsudo chown "$(id -un)" ./home-chat-root.crtopenssl x509 -in home-chat-root.crt -noout -subject -enddate -fingerprint -sha256Track N — NVIDIA desktop or laptop
RunnableTrack N · NVIDIA GPU
cd ~/home-chatdocker compose cp caddy:/data/caddy/pki/authorities/local/root.crt ./home-chat-root.crtopenssl x509 -in home-chat-root.crt -noout -subject -enddate -fingerprint -sha256Output — what you should see
subject=CN=Caddy Local Authority - 2026 ECC RootnotAfter=<3600 days after the first start> GMTsha256 Fingerprint=XX:XX:XX:...:XXThe 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
cd ~/home-chatLAN_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
mkdir -p ~/home-chat/trustcp ~/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/trustOn 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
sudo cp home-chat-root.crt /usr/local/share/ca-certificates/home-chat-root.crtsudo update-ca-certificatesOn 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.
- On a laptop whose browser trusts the root, open
https://chat.home.arpa/and sign in with the two values from.env. - Change the password: your name in the corner > Settings > Account > Change Password. Open
WebUI answers
Password updated. Please sign in again.; do so. - Delete the
WEBUI_ADMIN_PASSWORD=line from~/home-chat/.envin an editor, then apply the change:
Track S — NVIDIA DGX Spark
RunnableTrack S · DGX Spark
cd ~/home-chatgrep -c '^WEBUI_ADMIN_PASSWORD=.' .envdocker compose up --detach --waitOutput — what you should see
0Track X — AMD Ryzen AI Max+ 395
RunnableTrack X · Ryzen AI Max+
cd ~/home-chatgrep -c '^WEBUI_ADMIN_PASSWORD=.' .envdocker compose up --detach --waitOutput — what you should see
0Track M — Apple silicon
RunnableTrack M · Apple silicon
grep -c '^WEBUI_ADMIN_PASSWORD=.' ~/home-chat/.envOutput — what you should see
0Then 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
cd ~/home-chatgrep -c '^WEBUI_ADMIN_PASSWORD=.' .envdocker compose up --detach --waitOutput — what you should see
0On the container tracks, up recreates only webui, because only its environment changed.
- 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. - 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. - 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
useraccount. Open Settings > Admin > Models, editqwen3:4b-q4_K_M(orqwen3-4b), set its Visibility to Public, and save. - 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.
- See what
pendingmeans: Add User once more with Rolepending, sign in as that account in a private browser window, and you get the page that saysAccount Activation PendingandContact 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
cd ~/home-chatSTART="$(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"...xRunnableTrack M · Apple silicon
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:xllama-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
cd ~/home-chatSTART="$(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...xThe 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.
8. Use it from a phone
Section titled “8. Use it from a phone”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
cd ~/home-chatdocker compose logs --since 10m caddy | grep -o '"User-Agent": \["[^"]*"\]' | sort | uniq -cdocker compose exec engine ollama psdocker 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 UNTILqwen3:4b-q4_K_M 2bfd38a7daaf x.x GB 100% GPU 8192 29 minutes from nowNAME MEM USAGE / LIMIThome-chat-caddy-1 xx.xMiB / xxxGiBhome-chat-engine-1 x.xxGiB / xxxGiBhome-chat-webui-1 xxxMiB / xxxGiBTrack X — AMD Ryzen AI Max+ 395
RunnableTrack X · Ryzen AI Max+
cd ~/home-chatdocker compose logs --since 10m caddy | grep -o '"User-Agent": \["[^"]*"\]' | sort | uniq -cdocker compose exec engine ollama psdocker 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 UNTILqwen3:4b-q4_K_M 2bfd38a7daaf x.x GB 100% GPU 8192 29 minutes from nowNAME MEM USAGE / LIMIThome-chat-caddy-1 xx.xMiB / xxGiBhome-chat-engine-1 x.xxGiB / xxGiBhome-chat-webui-1 xxxMiB / xxGiBTrack 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
ollama psps -o rss=,command= -p "$(lsof -nP -tiTCP:8080 -sTCP:LISTEN)"Output — what you should see
NAME ID SIZE PROCESSOR CONTEXT UNTILqwen3:4b-q4_K_M 2bfd38a7daaf x.x GB 100% GPU 8192 29 minutes from nowxxxxxx /Users/you/home-chat/venv/bin/python3 /Users/you/home-chat/venv/bin/open-webui serve --host 127.0.0.1 --port 8080The first number from ps is the front-end’s resident memory in kilobytes.
Track N — NVIDIA desktop or laptop
RunnableTrack N · NVIDIA GPU
cd ~/home-chatdocker compose logs --since 10m caddy | grep -o '"User-Agent": \["[^"]*"\]' | sort | uniq -cdocker compose exec engine ollama psdocker 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 UNTILqwen3:4b-q4_K_M 2bfd38a7daaf x.x GB 100% GPU 8192 29 minutes from nowNAME MEM USAGE / LIMIThome-chat-caddy-1 xx.xMiB / xxGiBhome-chat-engine-1 x.xxGiB / xxGiBhome-chat-webui-1 xxxMiB / xxGiBdocker 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
sudo ss -ltnp | grep -E ':(80|443|2019|3000|8080|11434)\s'ip -4 -brief addr show scope globalOutput — 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>/24docker0 DOWN 172.17.0.1/16br-xxxxxxxxxxxx UP 172.18.0.1/16Track X — AMD Ryzen AI Max+ 395
RunnableTrack X · Ryzen AI Max+
sudo ss -ltnp | grep -E ':(80|443|2019|3000|8080|11434)\s'ip -4 -brief addr show scope globalOutput — 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>/24docker0 DOWN 172.17.0.1/16br-xxxxxxxxxxxx UP 172.18.0.1/16Track M — Apple silicon
RunnableTrack M · Apple silicon
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
sudo ss -ltnp | grep -E ':(80|443|2019|3000|8080|11434)\s'ip -4 -brief addr show scope globalOutput — 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>/24docker0 DOWN 172.17.0.1/16br-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
#!/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=443HTTP_PORT=80LABBOOK=""# 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 ;; esacdone
[ -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=0FAILS=0pass() { 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"' EXITset +eOUT="$(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 -eif [ "$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}" fielse 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'/ }" ;; esacfi
# 2. Port 80 must only redirect to HTTPS.set +eOUT="$(curl --silent --max-time 10 --output /dev/null \ --write-out '%{http_code} %{redirect_url}' "http://${HOST}:${HTTP_PORT}/")"RC=$?set -eread -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 +eCODE="$(curl --silent --max-time 10 --cacert "$CACERT" --output /dev/null \ --write-out '%{http_code}' "https://${HOST}:${HTTPS_PORT}/ollama/api/tags")"RC=$?set -eif [ "$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}" ;; esacdone
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 ]RunnableAll tracks
printf 'Service name from task 1 (CHAT_HOST): '; read -r CHAT_HOSTprintf 'Server address from task 1: '; read -r LAN_IPbash probe-lan-exposure.sh --host "$CHAT_HOST" --expect-ip "$LAN_IP" --cacert ~/home-chat-root.crt --labbook labbook.mdOutput — what you should see
Service name from task 1 (CHAT_HOST): chat.home.arpaServer 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 401PASS closed-11434 connection refusedPASS closed-8080 connection refusedPASS closed-3000 connection refusedPASS closed-2019 connection refused8 passed, 0 failedThe 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
import os, shutil, sqlite3, tempfile
live = tempfile.mkdtemp()db = os.path.join(live, "webui.db")con = sqlite3.connect(db) # the running front-endcon.execute("PRAGMA journal_mode=WAL") # what Open WebUI v0.11.3 setscon.execute("PRAGMA wal_autocheckpoint=0") # keep this demo deterministiccon.execute("CREATE TABLE chat (id INTEGER PRIMARY KEY, title TEXT)")con.commit()con.execute("PRAGMA wal_checkpoint(TRUNCATE)") # table now lives in webui.db itselfcon.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 WALprint("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: 3rows in a copy of webui.db: 0rows in a copy of all three: 3files after a clean close: ['webui.db']rows in a copy of webui.db: 3Three 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
#!/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=1LABBOOK=""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 ;; esacdone
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 mountSTAMP="$(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 fifi
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, syscon = 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 fielse 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."Track S — NVIDIA DGX Spark
RunnableTrack S · DGX Spark
cd ~/home-chatbash backup-webui.sh --dest ~/backups --labbook ~/home-chat/labbook.mdTrack X — AMD Ryzen AI Max+ 395
RunnableTrack X · Ryzen AI Max+
cd ~/home-chatbash backup-webui.sh --dest ~/backups --labbook ~/home-chat/labbook.mdTrack M — Apple silicon
RunnableTrack M · Apple silicon
bash ~/home-chat/backup-webui.sh --data-dir ~/home-chat/webui-data --dest ~/backups --labbook ~/home-chat/labbook.mdThe 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
cd ~/home-chatbash backup-webui.sh --dest ~/backups --labbook ~/home-chat/labbook.mdOutput — 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
ARCHIVE="$(ls -1t ~/backups/open-webui-*.tar.gz | head -n 1)"echo "$ARCHIVE"docker volume create webui-restore-testdocker run --rm -v webui-restore-test:/target -v "$(dirname "$ARCHIVE")":/backup:ro alpine:3.24 tar -xzf "/backup/$(basename "$ARCHIVE")" -C /targetdocker 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.3for i in $(seq 1 60); do [ "$(docker inspect --format '{{.State.Health.Status}}' webui-restore-test)" = healthy ] && break; sleep 5; donecurl -sS http://127.0.0.1:3001/healthTrack X — AMD Ryzen AI Max+ 395
RunnableTrack X · Ryzen AI Max+
ARCHIVE="$(ls -1t ~/backups/open-webui-*.tar.gz | head -n 1)"echo "$ARCHIVE"docker volume create webui-restore-testdocker run --rm -v webui-restore-test:/target -v "$(dirname "$ARCHIVE")":/backup:ro alpine:3.24 tar -xzf "/backup/$(basename "$ARCHIVE")" -C /targetdocker 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.3for i in $(seq 1 60); do [ "$(docker inspect --format '{{.State.Health.Status}}' webui-restore-test)" = healthy ] && break; sleep 5; donecurl -sS http://127.0.0.1:3001/healthTrack M — Apple silicon
RunnableTrack M · Apple silicon
ARCHIVE="$(ls -1t ~/backups/open-webui-*.tar.gz | head -n 1)"echo "$ARCHIVE"mkdir -p ~/restore-test/webui-datatar -xzf "$ARCHIVE" -C ~/restore-test/webui-dataDATA_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 3001In 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
ARCHIVE="$(ls -1t ~/backups/open-webui-*.tar.gz | head -n 1)"echo "$ARCHIVE"docker volume create webui-restore-testdocker run --rm -v webui-restore-test:/target -v "$(dirname "$ARCHIVE")":/backup:ro alpine:3.24 tar -xzf "/backup/$(basename "$ARCHIVE")" -C /targetdocker 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.3for i in $(seq 1 60); do [ "$(docker inspect --format '{{.State.Health.Status}}' webui-restore-test)" = healthy ] && break; sleep 5; donecurl -sS http://127.0.0.1:3001/healthOutput — what you should see
/home/you/backups/open-webui-20260913T210000Z.tar.gzwebui-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.
Validation
Section titled “Validation”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 |
Expected outcome
Section titled “Expected outcome”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.
Troubleshooting
Section titled “Troubleshooting”| 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 |
Cleanup
Section titled “Cleanup”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
cd ~/home-chatdocker compose downOn 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.
What you learned
Section titled “What you learned”| 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
Sources for this lesson
59 verified · checked 2026-09-13
- 01Open WebUI Docs — Quick Start§ Docker; Docker Compose; Python (pip/uv); the first accountdocs.openwebui.com/getting-started/quick-start2026-09-08
- 02Open WebUI Docs — Hardening Open WebUI§ Secrets; registration; cookie settings; network architecture; TLS; CORSdocs.openwebui.com/getting-started/advanced-topics/hardening2026-09-13
- 03Open WebUI Docs — Starting With OpenAI-Compatible Serversdocs.openwebui.com/getting-started/quick-start/connect-a-provider/starting-with-openai-compatible2026-09-08
- 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
- 05Open WebUI Docs — Roles§ Role details; headless admin account creationdocs.openwebui.com/features/authentication-access/rbac/roles2026-09-13
- 06Open WebUI Docs — Groups§ Preview Accessdocs.openwebui.com/features/authentication-access/rbac/groups2026-09-13
- 07Open WebUI Docs — Models (Workspace)§ Core configuration; Visibilitydocs.openwebui.com/features/workspace/models2026-09-13
- 08Open WebUI Docs — Task Models§ Turning individual tasks offdocs.openwebui.com/features/administration/task-models2026-09-13
- 09Open WebUI Docs — Llama.cppdocs.openwebui.com/getting-started/quick-start/connect-a-provider/starting-with-llama-cpp2026-09-13
- 10Open WebUI Docs — Backups (community tutorial)§ Files in persistent data storedocs.openwebui.com/tutorials/maintenance/backups2026-09-13
- 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
- 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
- 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
- 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
- 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
- 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
- 17PyPI — onnxruntime 1.26.0 files§ macOS wheels (macosx_14_0_arm64 only)pypi.org/project/onnxruntime/1.26.02026-09-13
- 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
- 19Ollama documentation v0.33.3 — GPU§ AMD, SELinux (container_use_devices)github.com/ollama/ollama/blob/v0.33.3/docs/gpu.mdx2026-09-13
- 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
- 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
- 22Gin v1.10.0 source — logger.go§ defaultLogFormattergithub.com/gin-gonic/gin/blob/v1.10.0/logger.go2026-09-13
- 23setsebool(8) manual page§ -Pman7.org/linux/man-pages/man8/setsebool.8.html2026-09-13
- 24Ollama documentation — Docker§ Nvidia GPU; AMD GPU; Vulkan supportraw.githubusercontent.com/ollama/ollama/main/docs/docker.mdx2026-09-08
- 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
- 26Ollama library — qwen3 tagsollama.com/library/qwen3/tags2026-09-13
- 27llama.cpp — Docker documentation§ Imagesgithub.com/ggml-org/llama.cpp/blob/master/docs/docker.md2026-09-13
- 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
- 29llama.cpp b10868 — llama-context.cpp§ n_ctx_seqgithub.com/ggml-org/llama.cpp/blob/b10868/src/llama-context.cpp2026-09-13
- 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
- 31Hugging Face — unsloth/Qwen3-4B-GGUF file listinghuggingface.co/unsloth/Qwen3-4B-GGUF/tree/main2026-09-13
- 32Caddy documentation — tls directive§ internal (lifetimes; installing the root from a container)caddyserver.com/docs/caddyfile/directives/tls2026-09-08
- 33Caddy documentation — Automatic HTTPS§ Local HTTPS; CA rootcaddyserver.com/docs/automatic-https2026-09-08
- 34Caddy documentation — bind directivecaddyserver.com/docs/caddyfile/directives/bind2026-09-08
- 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
- 36Caddy documentation — Conventions§ Data directorycaddyserver.com/docs/conventions2026-09-08
- 37Caddy documentation — Caddyfile Concepts§ Environment variables; site addressescaddyserver.com/docs/caddyfile/concepts2026-09-08
- 38Caddy documentation — Command Line§ caddy run; caddy trust; caddy untrust (--cert); caddy versioncaddyserver.com/docs/command-line2026-09-13
- 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
- 40Docker Hub — caddy official image§ /data and /config; do not mount the Caddyfile directlyhub.docker.com/_/caddy2026-09-13
- 41Docker documentation — Packet filtering and firewalls§ Docker and ufwdocs.docker.com/engine/network/packet-filtering-firewalls2026-09-08
- 42Docker documentation — Port publishing and mapping§ Publishing ports; direct routing; default bind addressdocs.docker.com/engine/network/port-publishing2026-09-13
- 43Docker documentation — Compose file services reference§ portsdocs.docker.com/reference/compose-file/services2026-09-08
- 44Docker documentation — Interpolationdocs.docker.com/reference/compose-file/interpolation2026-09-13
- 45Docker documentation — Merge Compose filesdocs.docker.com/reference/compose-file/merge2026-09-13
- 46Docker documentation — Pre-defined environment variables in Compose§ COMPOSE_FILEdocs.docker.com/compose/how-tos/environment-variables/envvars2026-09-13
- 47Docker documentation — docker compose up§ --wait; --wait-timeoutdocs.docker.com/reference/cli/docker/compose/up2026-09-13
- 48Docker documentation — docker compose cpdocs.docker.com/reference/cli/docker/compose/cp2026-09-13
- 49Docker documentation — docker compose logs§ --sincedocs.docker.com/reference/cli/docker/compose/logs2026-09-13
- 50Docker documentation — docker compose down§ --volumes; --rmidocs.docker.com/reference/cli/docker/compose/down2026-09-13
- 51Docker documentation — Linux post-installation steps for Docker Engine§ Manage Docker as a non-root userdocs.docker.com/engine/install/linux-postinstall2026-09-13
- 52Docker documentation — GPU support in Docker Composedocs.docker.com/compose/how-tos/gpu-support2026-09-13
- 53NVIDIA Container Toolkit — Running a sample workloaddocs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/sample-workload.html2026-09-13
- 54SQLite — Write-Ahead Logging§ Checkpointing; the last connection closingsqlite.org/wal.html2026-09-13
- 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
- 56Apple Support — Trust manually installed certificate profiles in iOS, iPadOS and visionOSsupport.apple.com/en-us/1023902026-09-13
- 57Android Help — Add & remove certificatessupport.google.com/android/answer/28448322026-09-13
- 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
- 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.