At Level 4 Integrator, you are connecting a chat interface, model service, network, identity boundary, and persistent data. If the deployment is exposed or unavailable, colleagues across a lab or company can lose access to their work or expose material entrusted to the service.
2. The model is local, but the open port is public
Your group cannot paste its material into an unapproved cloud assistant. You install a local model and a browser interface on a spare server. It works immediately from your laptop, so a colleague opens the firewall for everyone.
The model may be local, but the service is not private merely because you own the machine. A public registration page, an unauthenticated model port, a weak administrator account, or an untested backup can expose data or stop work for everyone using it. Self-hosting replaces a provider's operational work with your own: patching, certificates, account recovery, capacity, backups, and incident response.
Build the smallest protected stack: Open WebUI as the interface, Ollama as the local model service, persistent storage, and an authenticated HTTPS entry point. Test it with synthetic text from a second device. Keep agents, tools, messaging, and external integrations out of this first deployment.
3. After this you can
- Run a persistent chat interface on infrastructure you control.
- Connect Open WebUI to an Ollama model service without publishing the model port.
- Protect the interface with HTTPS, login, and closed registration.
- Verify access and recovery from a second device.
- Decide which capabilities an always-on assistant must not receive.
4. Prerequisites
T11-L03· Containers you can operate.T12-L04· Secure an AI system.- A Linux host or approved EU VPS, Podman or Docker Compose,
curl,jq, DNS, and permission to configure a reverse proxy and firewall. - Enough disk and memory for one approved local model.
- A named service owner and a second person who can restart or disable the stack.
Use synthetic prompts during this exercise. Do not upload customer records, participant or patient data, unpublished results, credentials, or production documents.
5. The idea in one page
The smallest useful stack has four parts:
colleague browser -> HTTPS gateway -> Open WebUI -> Ollama
|
+-> persistent application data
Only the HTTPS gateway should accept colleague traffic. Open WebUI listens on loopback or a private container network. Ollama stays on the private network and publishes no host port. Open WebUI stores accounts, settings, and chats in a persistent volume. The Ollama volume stores the selected model separately. Back up both when the organisation needs chats and the model available after recovery; otherwise document that the model will be pulled again from its reviewed source.
Treat each boundary as a separate test:
- Network boundary: the Internet, institute network, or VPN can reach
443, not the application or model ports. - Identity boundary: a valid certificate gets a person to a login page, not directly to a chat.
- Model boundary: Open WebUI reaches the selected Ollama model by its exact identifier over the private network.
- Persistence boundary: a container replacement does not erase accounts, settings, chats, or the local model.
- Recovery boundary: a second operator can restore the service without reconstructing it from memory.
Ollama's local API does not require authentication. Publishing port 11434 would expose model access without the login protecting Open WebUI. A local-only binding is therefore a real control, not tidiness.
Self-hosted does not automatically mean offline. Model downloads, updates, search, integrations, telemetry, or an external model endpoint may still use the network. If data must remain on premises, document and enforce outbound rules as well as product settings.
Choose the model path before deployment, because it changes what leaves the boundary:
| Model path | What the assistant sends | What you must operate and verify | Use it when |
|---|---|---|---|
| Local Ollama | No prompt or response needs to leave the host after reviewed images and weights arrive | Model storage, memory headroom, updates, latency, and recovery | Policy or connectivity requires local inference and the measured workload fits |
| Approved EU-hosted API | Prompts, responses, and provider metadata cross an outbound path | Contract, processor and subprocessor path, region, retention, credentials, availability, and usage cost | An approved service meets the data rule and operating local inference is not justified |
| Gateway in front of either | The same data as its selected backend, plus gateway metadata | Routing policy, credentials, logs, failure behaviour, and another service to recover | More than one approved backend genuinely needs one controlled switching point |
Do not add a gateway merely because it might be useful later. Every extra service adds credentials, logs, upgrades, and another way the team can lose access. This worked example deliberately uses local Ollama so the prompt path remains inside the controlled host.
6. The worked example: one protected assistant
Use the same architecture twice. The Lab version runs on institute-controlled hardware. The Company version runs on a rented EU VPS. The controls stay the same; the network and responsibility differ.
Prepare the deployment
Create a deployment record before starting:
Service: Synthetic Team Assistant
Owner: [name]
Restart substitute: [name]
Host class: lab server | EU VPS
External name: [approved DNS name]
Open WebUI release: [pinned release]
Ollama release: [pinned release]
Model identifier: [exact local identifier]
Data volume: open-webui-data
Model volume: ollama-data
Backup owner and location: [approved location]
Review date: [date]
Pin reviewed container releases rather than using latest. Save this definition as compose.yaml in an access-controlled deployment directory:
services:
ollama:
image: ollama/ollama:${OLLAMA_VERSION}
restart: unless-stopped
environment:
OLLAMA_NO_CLOUD: "1"
volumes:
- ollama-data:/root/.ollama
networks: [assistant-private]
open-webui:
image: ghcr.io/open-webui/open-webui:${OPEN_WEBUI_VERSION}
restart: unless-stopped
depends_on: [ollama]
environment:
OLLAMA_BASE_URL: http://ollama:11434
ENABLE_SIGNUP: "false"
DEFAULT_USER_ROLE: pending
JWT_EXPIRES_IN: 24h
ENABLE_PLUGINS: "false"
ENABLE_CODE_EXECUTION: "false"
ENABLE_CODE_INTERPRETER: "false"
ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS: "false"
ENABLE_OPENAI_API_PASSTHROUGH: "false"
ENABLE_DIRECT_CONNECTIONS: "false"
ENABLE_COMMUNITY_SHARING: "false"
WEBUI_SESSION_COOKIE_SECURE: "true"
WEBUI_SESSION_COOKIE_SAME_SITE: strict
CORS_ALLOW_ORIGIN: https://${ASSISTANT_HOST}
ENV: prod
WEBUI_URL: https://${ASSISTANT_HOST}
ports:
- "127.0.0.1:3000:8080"
volumes:
- open-webui-data:/app/backend/data
networks: [assistant-private]
networks:
assistant-private:
volumes:
ollama-data:
name: ollama-data
open-webui-data:
name: open-webui-data
Current configuration check — last verified 4 September 2026. The Open WebUI variable names and account-bootstrap behaviour above match the current Open WebUI hardening guidance, and the Ollama FAQ documents OLLAMA_NO_CLOUD=1 plus the startup confirmation used below. For an empty database, the reviewed Open WebUI release can create the first administrator from WEBUI_ADMIN_EMAIL, WEBUI_ADMIN_PASSWORD, and WEBUI_ADMIN_NAME; after creating that account it writes disabled signup to its configuration. DEFAULT_USER_ROLE=pending is a defence if signup is deliberately reopened. These are product behaviours, not timeless guarantees. Before deployment, confirm that the pinned release documents this startup-admin path, compare every variable with its documentation, inspect startup warnings, and test the effective interface while logged out. Remove any variable the pinned release rejects rather than assuming an ignored setting protects the service.
For this single persistent instance, Open WebUI stores its auto-generated secret key in open-webui-data, so protecting and restoring that volume preserves it. If policy requires an explicit WEBUI_SECRET_KEY, or the service later gains multiple replicas, generate it with the approved secret mechanism and inject the same value into every replica; never put it in Git or a screenshot. If the reverse proxy is another container, remove the host port and connect the proxy to a separate frontend network.
The environment values are deployment inputs, not suggestions for particular releases. Obtain reviewed values from the current primary documentation and your organisation's approval record. An image tag is pinned only when it names the reviewed release rather than a moving value such as latest. Keep the resulting image digest in the deployment record so an operator can see whether a registry has changed what a tag resolves to.
Create an untracked .env with values copied from the reviewed deployment record:
OLLAMA_VERSION=[reviewed pinned release]
OPEN_WEBUI_VERSION=[reviewed pinned release]
ASSISTANT_HOST=[approved DNS name]
MODEL_ID=[approved exact Ollama model identifier including tag]
BACKUP_HELPER_IMAGE=[approved pinned archive-helper image]
Replace every bracketed value before running a command. Do not type a made-up tag merely to make the deployment start. Confirm that .env and any secret file are excluded from Git, then validate the rendered definition without printing it into shared logs:
set -a; . ./.env; set +a
docker compose config --quiet
docker compose pull
docker image inspect ollama/ollama:"$OLLAMA_VERSION" \
--format '{{index .RepoDigests 0}}'
docker image inspect ghcr.io/open-webui/open-webui:"$OPEN_WEBUI_VERSION" \
--format '{{index .RepoDigests 0}}'
docker compose up -d ollama
docker compose ps
docker inspect "$(docker compose ps -q ollama)" \
--format '{{range .Config.Env}}{{println .}}{{end}}' \
| grep -Fx 'OLLAMA_NO_CLOUD=1'
docker compose logs --no-color ollama \
| grep -F 'Ollama cloud disabled: true'
The first line loads only the non-secret deployment inputs used by Compose and the shell. Copy the two reported digests into the deployment record. At this stage, docker compose ps must show Ollama running with no host mapping; Open WebUI deliberately has not started. The final two checks must show the configured local-only value and Ollama's startup confirmation. If the pinned release does not recognise OLLAMA_NO_CLOUD or does not log Ollama cloud disabled: true, stop and resolve the version mismatch; do not claim that cloud features are disabled.
Check the host rather than trusting the Compose file:
ss -lnt
The socket list must not show 0.0.0.0:3000, [::]:3000, 0.0.0.0:11434, or [::]:11434. Open WebUI is not running yet, so no process should answer on loopback port 3000. From a second machine, attempts to connect to host ports 3000 and 11434 must fail. A timeout or refusal is the intended result; an HTTP response is a failed boundary test.
Pull, select, and verify the exact model
Do not stop after starting an empty Ollama service. Pull the one approved model using the exact identifier recorded in MODEL_ID:
set -a; . ./.env; set +a
test -n "$MODEL_ID"
docker compose exec ollama ollama pull "$MODEL_ID"
docker compose exec ollama ollama list
docker compose exec -T ollama ollama run "$MODEL_ID" \
"Reply with exactly: MODEL READY"
The pull must complete, ollama list must contain the exact name and tag from MODEL_ID, and the direct test must return MODEL READY. Record the exact identifier and the displayed model size in the deployment record. If the response differs, investigate before exposing the service; do not silently select another model.
Bootstrap the administrator before the first Open WebUI start. Keep ENABLE_SIGNUP: "false" in the base definition; do not open registration. The reviewed Open WebUI startup-admin path creates an administrator only when its database has no users, then persists disabled signup. Save this temporary, secret-free definition as compose.bootstrap.yaml:
services:
open-webui:
environment:
WEBUI_ADMIN_EMAIL: ${WEBUI_ADMIN_EMAIL:?required for first start}
WEBUI_ADMIN_PASSWORD: ${WEBUI_ADMIN_PASSWORD:?required for first start}
WEBUI_ADMIN_NAME: ${WEBUI_ADMIN_NAME:?required for first start}
Run the bootstrap from a private administrative shell. Retrieve a strong unique password from the approved password manager and enter it only at the hidden prompt:
read -r -p "Administrator email: " WEBUI_ADMIN_EMAIL
read -r -p "Administrator display name: " WEBUI_ADMIN_NAME
read -r -s -p "Administrator password: " WEBUI_ADMIN_PASSWORD
printf '\n'
export WEBUI_ADMIN_EMAIL WEBUI_ADMIN_NAME WEBUI_ADMIN_PASSWORD
docker compose -f compose.yaml -f compose.bootstrap.yaml \
up -d open-webui
unset WEBUI_ADMIN_EMAIL WEBUI_ADMIN_NAME WEBUI_ADMIN_PASSWORD
docker compose ps
attempt=0
until curl --fail --silent http://127.0.0.1:3000/ >/dev/null; do
attempt=$((attempt + 1))
test "$attempt" -lt 60 || exit 1
sleep 2
done
curl --fail --silent --show-error http://127.0.0.1:3000/api/config \
| jq -e '.features.enable_signup == false' >/dev/null
This keeps the credential out of command arguments, shell history, Compose files, and .env. It is temporarily present in the bootstrap container's environment, so allow only authorised host administration during this short window. docker compose ps must now show both services running, no host mapping for Ollama, and only 127.0.0.1:3000 for Open WebUI. The final command must succeed before any login: it observes that signup closed as part of bootstrap rather than relying on the base Compose line alone.
Now open http://127.0.0.1:3000 from the host. If the host has no browser, forward the loopback port from an authorised workstation:
ssh -N -L 3000:127.0.0.1:3000 [approved-admin-user]@[approved-host]
Sign in with the bootstrapped administrator and confirm that the account exists and has administrator access. Do not continue if a first-user sign-up screen appears: the pinned release or startup-admin variables do not match the reviewed behaviour. Immediately remove the bootstrap variables by recreating Open WebUI from the base definition, delete the temporary override, and verify both the container environment and effective public configuration:
docker compose -f compose.yaml up -d --force-recreate open-webui
rm -f compose.bootstrap.yaml
attempt=0
until curl --fail --silent http://127.0.0.1:3000/ >/dev/null; do
attempt=$((attempt + 1))
test "$attempt" -lt 60 || exit 1
sleep 2
done
if docker inspect "$(docker compose ps -q open-webui)" \
--format '{{range .Config.Env}}{{println .}}{{end}}' \
| grep -Eq '^WEBUI_ADMIN_(EMAIL|PASSWORD|NAME)='; then
echo "FAIL: bootstrap credential remains in the container" >&2
exit 1
fi
curl --fail --silent --show-error http://127.0.0.1:3000/api/config \
| jq -e '.features.enable_signup == false' >/dev/null
The negative environment check must produce no credential value, and jq must exit successfully. A missing field, true, or a failed request is a stop condition, not evidence of closed registration. Sign in again after recreation, keep the default new-user role at pending, and create one ordinary synthetic user through the administrator path. Finally, log out and use a clean private browser window to confirm that the sign-in page has no sign-up control. Do not place either credential in .env, a Compose file, shell history, the deployment record, or the exit screenshot.
In Open WebUI, start a new chat and select the exact MODEL_ID value in the model selector. Send Reply with exactly: WEBUI MODEL READY. The response must be WEBUI MODEL READY. Open the model selector again and confirm the selected identifier is unchanged. This proves all three required steps: the approved model was pulled into Ollama, explicitly selected in Open WebUI, and invoked through the same private connection colleagues will use.
Add HTTPS without exposing the backends
Configure an approved Caddy, Nginx, or organisational gateway for the public name. It terminates TLS and forwards only to 127.0.0.1:3000. The following minimal Caddy site is sufficient when Caddy is installed directly on the host and organisational policy permits its automatic certificate flow:
{$ASSISTANT_HOST} {
reverse_proxy 127.0.0.1:3000
}
Place that site at /etc/caddy/Caddyfile. The deployment .env belongs to Compose and the operator shell; the system Caddy service does not read it automatically. Install the same approved hostname in a root-owned service environment file and attach that file to caddy.service with a systemd drop-in:
set -a; . ./.env; set +a
test -n "$ASSISTANT_HOST"
case "$ASSISTANT_HOST" in
*[!A-Za-z0-9.-]* | .* | *..* | *.) exit 1 ;;
esac
printf 'ASSISTANT_HOST=%s\n' "$ASSISTANT_HOST" \
| sudo tee /etc/caddy/assistant.env >/dev/null
sudo chown root:root /etc/caddy/assistant.env
sudo chmod 0600 /etc/caddy/assistant.env
sudo install -d -o root -g root -m 0755 \
/etc/systemd/system/caddy.service.d
sudo tee /etc/systemd/system/caddy.service.d/assistant-host.conf \
>/dev/null <<'EOF'
[Service]
EnvironmentFile=/etc/caddy/assistant.env
EOF
sudo sh -c 'set -a; . /etc/caddy/assistant.env; set +a; \
exec caddy validate --config /etc/caddy/Caddyfile'
sudo systemctl daemon-reload
sudo systemctl restart caddy
sudo systemctl status caddy --no-pager
sudo systemctl show caddy --property=EnvironmentFiles --value \
| grep -F '/etc/caddy/assistant.env'
CADDY_PID="$(systemctl show caddy --property=MainPID --value)"
sudo sh -c 'tr "\0" "\n" < "/proc/$1/environ"' sh "$CADDY_PID" \
| grep -Fx "ASSISTANT_HOST=$ASSISTANT_HOST"
sudo journalctl -u caddy --since "10 minutes ago" --no-pager
The validation command reads the same environment file as the service. Restart, rather than reload, is required after changing a systemd environment value. The two service checks must name /etc/caddy/assistant.env and print the exact approved hostname; the journal must show Caddy serving that hostname without certificate or configuration errors. Whenever the approved name changes, update both the deployment .env and /etc/caddy/assistant.env, repeat validation, run daemon-reload, and restart Caddy.
If the organisation supplies Nginx or a managed gateway instead, implement the same single upstream and use that gateway's validation procedure. Do not run multiple example proxies or publish Open WebUI just to avoid configuring the approved one.
Permit inbound 443 from the approved audience and 80 only when required for certificate issuance or redirection. Keep 3000, 8080, and 11434 closed at the provider and host firewalls. Preserve the approved administrative path before applying rules. Firewall syntax differs by platform, so use the organisation's reviewed rule set rather than pasting an unreviewed command. The observable policy is unambiguous: colleague traffic reaches HTTPS; only local processes reach 127.0.0.1:3000; only containers on assistant-private reach 11434.
From the host, confirm the proxy and application are up after a restart:
docker compose restart
docker compose ps
curl --fail --silent --show-error \
"https://$ASSISTANT_HOST/" >/dev/null
From a second device outside the host, run equivalent approved network checks against both IPv4 and IPv6 when DNS publishes both. A simple test using tools already available on the device is enough:
read -r -p "Approved DNS name: " ASSISTANT_HOST
test -n "$ASSISTANT_HOST"
case "$ASSISTANT_HOST" in
*[!A-Za-z0-9.-]* | .* | *..* | *.) exit 1 ;;
esac
curl --fail --silent --show-error \
"https://${ASSISTANT_HOST}/" >/dev/null
curl --connect-timeout 5 "http://${ASSISTANT_HOST}:3000/"
curl --connect-timeout 5 "http://${ASSISTANT_HOST}:11434/"
At the prompt, paste the exact hostname from the approved deployment record, without https://, a port, or a path. The first command must complete with a valid certificate. The last two commands must fail to connect. Do not use --insecure; bypassing certificate validation would make the test meaningless.
Open the HTTPS address in a clean browser. Certificate warnings fail the check. While logged out, a protected chat must not be visible and no sign-up control should appear. Log in as the ordinary user and run this synthetic prompt:
Rewrite this as one clear internal sentence without adding facts:
The Northstar fixture contains four synthetic records and needs review on Friday.
Compare the response with the sentence. Then log out and confirm that revisiting the chat requires authentication.
Restart both application containers once more and repeat the clean-browser check. Registration settings can be persisted in application data, so the effective setting after restart matters more than the original environment line. Confirm that the ordinary account still exists, the exact model remains selectable, and no registration route is offered.
Lab framing: keep data inside the institute
Run both containers on an institute-controlled server. Restrict the HTTPS gateway to the institute network or VPN and use a certificate trusted by the test device. Disable Ollama cloud capabilities and do not enable Open WebUI search, external providers, tools, or connectors. Network controls must also deny unapproved outbound traffic; a checkbox alone does not prove isolation.
A colleague on an approved second device opens the service, sees valid HTTPS and the login page, cannot register, signs in with the ordinary account, selects the recorded exact model, receives one synthetic response, and logs out. The colleague also confirms that direct connections to ports 3000 and 11434 fail. Record pass or fail as text in the deployment record; this operational evidence is not an additional exit artifact.
For a host with no permitted Internet egress, arrange the reviewed images and model through the institute's approved internal registry or transfer process before the timed exercise. Do not temporarily open unrestricted egress. Record the source and integrity evidence supplied by that process, then run the same local ollama list, direct model response, and WebUI selection checks.
Company framing: the same stack on an EU VPS
Choose an approved VPS region and record the provider entity, server region, disk and snapshot region, support access, backup location, and outbound paths. An EU server does not by itself establish compliance or guarantee that every operator and subprocessor is in the EU.
Deploy the same private services and gateway. Use the provider firewall plus the host firewall. Preserve administrative access before changing firewall rules. The ordinary-user second-device test is identical to the Lab test. If an EU-hosted model API replaces Ollama, draw that outbound data path and stop calling inference local.
Provider snapshots can help rebuild a host, but they do not replace an application-level recovery test. A snapshot may share the same account, region, configuration error, or deletion event as the server. Keep the encrypted application backup in the approved separate failure domain and verify who can retrieve it when the primary operator is absent.
Prove recovery before colleagues depend on it
Choose an approved backup directory outside the deployment directory. The commands below use the reviewed helper image recorded in .env to archive the named volumes without publishing either service. Load the deployment inputs, stop writes, inspect the final state, and create the archives:
set -a; . ./.env; set +a
docker compose stop open-webui ollama
docker run --rm \
-v open-webui-data:/source:ro \
-v "[approved backup directory]:/backup" \
"$BACKUP_HELPER_IMAGE" \
tar -czf /backup/open-webui-data.tgz -C /source .
docker run --rm \
-v ollama-data:/source:ro \
-v "[approved backup directory]:/backup" \
"$BACKUP_HELPER_IMAGE" \
tar -czf /backup/ollama-data.tgz -C /source .
docker compose start ollama open-webui
docker compose ps
The helper reference must be pinned and reviewed like the two service images. Encrypt the archives with the organisation's approved backup mechanism, transfer them outside the server's failure domain, and apply its retention and access rules. Back up the deployment record and proxy configuration too. Back up references that tell an authorised operator how to retrieve secrets, never plaintext secrets in the archive notes.
Restore into fresh, clearly named test volumes. Do not overwrite the live volumes to prove recovery:
docker volume create open-webui-restore-test
docker volume create ollama-restore-test
docker run --rm \
-v open-webui-restore-test:/restore \
-v "[approved backup directory]:/backup:ro" \
"$BACKUP_HELPER_IMAGE" \
tar -xzf /backup/open-webui-data.tgz -C /restore
docker run --rm \
-v ollama-restore-test:/restore \
-v "[approved backup directory]:/backup:ro" \
"$BACKUP_HELPER_IMAGE" \
tar -xzf /backup/ollama-data.tgz -C /restore
Create compose.restore.yaml as a temporary override that redirects the existing service volume mounts:
services:
ollama:
volumes:
- ollama-restore-test:/root/.ollama
open-webui:
ports:
- "127.0.0.1:3001:8080"
volumes:
- open-webui-restore-test:/app/backend/data
volumes:
ollama-restore-test:
external: true
name: ollama-restore-test
open-webui-restore-test:
external: true
name: open-webui-restore-test
Stop the live stack for the short verification window so the override cannot accidentally add mounts to running production containers. Start a separate project and inspect it:
docker compose stop
docker compose -p assistant-restore \
-f compose.yaml -f compose.restore.yaml up -d
docker compose -p assistant-restore \
-f compose.yaml -f compose.restore.yaml ps
Access the restore through an SSH tunnel to 127.0.0.1:3001; do not add a public proxy route. Verify administrator login, ordinary-user login, the earlier synthetic chat, exact model selection and response, and closed registration. Then clean up only the restore project and restore-test volumes, and restart production:
docker compose -p assistant-restore \
-f compose.yaml -f compose.restore.yaml down
docker volume rm open-webui-restore-test ollama-restore-test
docker compose up -d
docker compose ps
Remove the temporary override after recording the result. If any restore check fails, retain the isolated failed volumes for authorised diagnosis rather than declaring the backup valid. Keep production stopped only as long as the approved maintenance window allows.
Record restore time and result in the deployment record. An archive that has never been restored is only a backup attempt.
Keep an always-on assistant inside a written boundary
Hermes and OpenClaw are examples of systems that can add persistent memory, schedules, shell, files, web access, or messaging. Those capabilities change a chat service into an actor. Catalogue them here; do not enable them in this build.
Before any future capability is added, write its allowed sender, task, workspace, tools, network path, credential owner, approval point, audit record, and stop control. Use one row per capability:
| Capability | Default for this build | Evidence required before change |
|---|---|---|
| Read uploaded synthetic text | Allowed for ordinary chat | File limits, retention rule, deletion test |
| Read a host directory or shared drive | Denied | Narrow mount or connector, source permissions, negative access test |
| Use web or internal network | Denied | Destination allowlist, egress log, planted untrusted-instruction test |
| Run code or shell commands | Denied | Isolated disposable runner, resource limits, no host or engine socket |
| Send messages or change records | Denied | Scoped credential, human approval before action, audit and rollback path |
| Persistent memory or schedules | Denied | Write rule, expiry, user visibility, deletion and stop tests |
Test a denied action with synthetic data. If the team cannot explain every field and produce the named evidence, the capability remains off.
The absence of an integration is a useful security property. Do not mount the host filesystem, Docker socket, SSH keys, cloud credentials, shared drives, or messaging tokens into this stack. A language model that can draft text does not need authority to execute text. When a future use case genuinely needs an action, review that action as a separate service change rather than treating it as a chat preference.
7. What goes wrong
The interface is published directly
Symptom: Open WebUI answers on a public high-numbered port or by IP address.
Fix: bind it to loopback or a private network and expose only the authenticated HTTPS gateway.
Ollama is reachable from the network
Symptom: another machine can call port 11434 without passing the WebUI login.
Fix: remove the published port, restrict firewall rules, and retest externally.
Registration remains open
Symptom: a clean browser offers sign-up or a synthetic outsider can create an account.
Fix: disable sign-up in effective persisted settings, restart, remove the test account, and repeat the clean-browser test.
Local is called compliant
Symptom: the deployment decision mentions server ownership but omits administrators, backups, logs, egress, retention, and legal purpose.
Fix: map the complete system boundary and obtain the required organisational review.
The model does not fit the host
Symptom: requests time out, memory pressure restarts services, or colleagues cannot work while the model loads.
Fix: test one approved model with realistic concurrent synthetic requests before rollout; choose a smaller model or different host when needed.
Updates replace recovery
Symptom: production follows a floating image tag and rollback means guessing which database version still works.
Fix: pin releases, review notes, back up, test in staging, and restore after a migration failure.
Only the builder can restart it
Symptom: the service stays down while the builder is unavailable.
Fix: give a substitute the deployment record and observe them stop, start, inspect logs, and restore the synthetic fixture.
8. Do it yourself: deploy and verify in 90 minutes
Minutes 0–15: choose Lab or Company framing, name owners, pin releases, record the model, and draw the four-part path.
Minutes 15–35: validate Compose, pull the pinned images and exact approved model, record image digests, run the direct model verification, and start Open WebUI with persistent volumes and no public model port. Create administrator and ordinary test accounts while access is private. Select and verify the exact model in Open WebUI.
Minutes 35–55: configure DNS, HTTPS, closed registration, and firewall rules. Verify listening ports before and after restart.
Minutes 55–70: use a second device to test certificate, logged-out denial, absent sign-up, ordinary login, one synthetic response, logout, and direct-port denial.
Minutes 70–85: stop writes, back up the application and model data, and restore into fresh test volumes under the separate restore project. Verify login, chat, exact model, and registration state, then remove the temporary restore stack and restart production.
Minutes 85–90: let the substitute owner locate the stop procedure and review the deployment record. Remove temporary access and synthetic accounts that are no longer needed.
9. Exit check
Deliver exactly one artifact: one evidence screenshot from the approved second device showing the valid HTTPS origin, the Open WebUI sign-in page, and no sign-up control.
The screenshot passes only after the exact model pull/select/verify sequence, ordinary-user response, logout denial, direct-port denial, closed-registration restart check, and isolated restore test have passed in the accompanying deployment record. The deployment record is routine operating documentation, not a second submitted artifact. Do not expose real names, private addresses, credentials, cookies, tokens, or chat content in the image.
10. Rule to remember
Self-hosted is not private until it has a lock on it.
11. Further reading & tools
- Taught: Open WebUI quick start (opens in a new tab) — current deployment guidance; pin and review a release.
- Taught: Connect Open WebUI to Ollama (opens in a new tab) — current connection guidance.
- Taught: Open WebUI hardening (opens in a new tab) — authentication, registration, network, and audit controls.
- Taught: Ollama FAQ (opens in a new tab) — current binding, local-only mode, and startup-log verification guidance.
- Taught: Run Caddy as a service (opens in a new tab) — systemd environment-file and reload guidance.
- Catalogued: Hermes security documentation (opens in a new tab) — higher-authority assistant comparison.
- Catalogued: OpenClaw security documentation (opens in a new tab) — always-on agent boundary comparison.
- Taught:
T12-L04· Secure an AI system — security design before exposure. - Catalogued: Tools index — current product references and alternatives.