Chapter 16 of Be Your Own Cloud gives you two answers to “how do I get AI’s help without handing over my configs”: a one-way Sanitiser that needs almost no hardware, and a full local assistant that needs a GPU. Chapter 23 explains when to use which. This page builds both, and fills the gap the chapter leaves on purpose — a front end for Presidio.
Part A: the Sanitiser
The three files
Everything lives in one folder:
mkdir -p /opt/stacks/sanitizer
cd /opt/stacks/sanitizer
The app:
from flask import Flask, request, render_template_string
import re
app = Flask(__name__)
SECRET = r'([A-Za-z0-9_]+_(KEY|TOKEN|SECRET|PASSWORD))\s*=.*'
# Order matters: emails before domains, so an address isn't half-
# redacted.
PATTERNS = [
(r'[\w.-]+@[\w.-]+\.\w+', 'EMAIL'),
(r'([0-9]{1,3}\.){3}[0-9]{1,3}', 'IP'),
(r'\b[\w.-]+\.(com|net|org|io|dev|xyz)\b', 'DOMAIN'),
]
def sanitize(text):
text = re.sub(SECRET, r'\1=REDACTED', text)
for pattern, label in PATTERNS:
# real value -> placeholder, for this one paste only
seen = {}
def number(match):
value = match.group(0)
if value not in seen:
seen[value] = 'REDACTED-%s-%d' % (label, len(seen) + 1)
return seen[value]
text = re.sub(pattern, number, text)
return text
PAGE = '''
<h2>Sanitizer</h2>
<form method="post">
<textarea name="input_text" rows="12" cols="80">
{{ input_text }}</textarea><br>
<button type="submit">Sanitize</button>
</form>
<h3>Result (nothing is saved):</h3>
<textarea rows="12" cols="80" readonly>{{ output_text }}</textarea>
'''
@app.route('/', methods=['GET', 'POST'])
def index():
input_text = ''
output_text = ''
if request.method == 'POST':
input_text = request.form.get('input_text', '')
output_text = sanitize(input_text)
return render_template_string(PAGE, input_text=input_text,
output_text=output_text)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000) The Dockerfile that packages it:
# Dockerfile
FROM python:3.12-slim
WORKDIR /app
RUN pip install flask
COPY app.py .
CMD ["python", "app.py"] And the compose file:
services:
sanitizer:
build: .
container_name: sanitizer
restart: unless-stopped
networks:
- proxy # Chapter 8's shared network -- no host port published
networks:
proxy:
external: true Build and start
This service is built from your files rather than pulled, so the first start builds the image:
docker compose up -d --build
Use --build again whenever you change app.py, or Docker keeps running the old image.
Reach it over HTTPS
It publishes no port, so the proxy is the only way in. In Nginx Proxy Manager:
| Tab | Setting | Value |
|---|---|---|
| Details | Domain Names | sanitize.home.yourdomain.com |
| Details | Scheme | http |
| Details | Forward Hostname / IP | sanitizer |
| Details | Forward Port | 5000 |
| SSL | SSL Certificate | your DNS-challenge certificate from Chapter 8 |
| SSL | Force SSL | on |
Add the matching record in Pi-hole, then open https://sanitize.home.yourdomain.com and paste something like:
ADMIN_TOKEN=abc123
vault at 192.168.1.50 and 192.168.1.50, pihole 192.168.1.51
mail me at [email protected], site vault.home.mydomain.com
You should get back:
ADMIN_TOKEN=REDACTED
vault at REDACTED-IP-1 and REDACTED-IP-1, pihole REDACTED-IP-2
mail me at REDACTED-EMAIL-1, site REDACTED-DOMAIN-1
The same address gets the same number, different addresses get different numbers — the behaviour Chapter 16 describes. That exact output came from running the book’s sanitize() function on that input.
Keeping it patched
The image is built on python:3.12-slim. Rebuilding occasionally picks up the base image’s security fixes:
docker compose build --pull
docker compose up -d
Adding Presidio
The chapter adds Microsoft Presidio for the things regex can’t describe — names, places — and says a front end still needs to sit on top of it. Here’s that front end, as an addition to the Sanitiser you already have.
The compose lines
The book’s lines, pasted under services: after the sanitizer service:
Paste under services:, after the sanitizer service — the indentation is already right.
# Add these to the sanitizer's docker-compose.yml, so all three share
# a network.
# The front end calls http://presidio-analyzer:3000 -- no published
# ports needed.
presidio-analyzer:
image: mcr.microsoft.com/presidio-analyzer:2.2.362
restart: unless-stopped
presidio-anonymizer:
image: mcr.microsoft.com/presidio-anonymizer:2.2.362
restart: unless-stopped
# Presidio is API-first -- a paste-box front end like the Flask
# example above
# still needs to sit on top of it for a comfortable interface. The front end
Add this to app.py, below the sanitize() function. It uses only Python’s standard library, so the Dockerfile doesn’t change:
import json
import urllib.request
def presidio(service, endpoint, payload):
request = urllib.request.Request(
'http://presidio-%s:3000/%s' % (service, endpoint),
data=json.dumps(payload).encode(),
headers={'Content-Type': 'application/json'})
with urllib.request.urlopen(request, timeout=60) as response:
return json.load(response)
def names_and_places(text):
found = presidio('analyzer', 'analyze', {'text': text, 'language': 'en'})
result = presidio('anonymizer', 'anonymize',
{'text': text, 'analyzer_results': found})
return result['text']
Then, in index(), change the line that does the work so the regex pass runs first and Presidio second:
output_text = names_and_places(sanitize(input_text))
Rebuild with docker compose up -d --build. Paste “Ask Alice Smith about 192.168.1.50” and you should get “Ask <PERSON> about REDACTED-IP-1”.
It keeps the book’s promise: nothing is stored. Presidio receives the text, returns positions, and forgets it; the replacement is a plain label like <PERSON>, with no mapping kept anywhere.
Part B: the Full Assistant
Before you start: the GPU
The book’s Ollama compose file runs on the CPU as printed. For an NVIDIA GPU, the host needs the NVIDIA driver and the NVIDIA Container Toolkit first, then Docker needs telling about it:
sudo nvidia-ctk runtime configure --runtime=docker
sudo systemctl restart docker
Then uncomment the deploy: block in the compose file below. The toolkit’s install steps are in NVIDIA’s documentation, linked at the end.
Deploy it
mkdir -p /opt/stacks/ollama
cd /opt/stacks/ollama
services:
ollama:
image: ollama/ollama:0.35.0
container_name: ollama
restart: unless-stopped
volumes:
- ./ollama-data:/root/.ollama
ports:
- '127.0.0.1:11434:11434'
# Uncomment for NVIDIA GPU passthrough:
# deploy:
# resources:
# reservations:
# devices:
# - driver: nvidia
# count: 1
# capabilities: [gpu]
open-webui:
image: ghcr.io/open-webui/open-webui:v0.11.4
container_name: open-webui
restart: unless-stopped
depends_on:
- ollama
environment:
- OLLAMA_BASE_URL=http://ollama:11434
volumes:
- ./webui-data:/app/backend/data
ports:
- '8090:8080' docker compose up -d
docker exec -it ollama ollama pull qwen2.5-coder:7b
# 16GB+ VRAM: try qwen2.5-coder:14b or deepseek-coder-v2:16b instead The 7B model is about 5 GB to download. To see whether it’s running on the GPU:
docker exec ollama ollama ps
The PROCESSOR column says 100% GPU when it is. Anything with CPU in it means the model didn’t fit in the card’s memory, or the GPU isn’t reaching the container.
Open WebUI
Open http://192.168.1.50:8090. The first account created becomes the administrator, so create yours straight away. Then, in Admin Panel → Settings → General, check the setting for new sign-ups — turn them off unless you mean to share it.
To reach it through the proxy, as Chapter 16 suggests:
| Tab | Setting | Value |
|---|---|---|
| Details | Domain Names | ai.home.yourdomain.com |
| Details | Scheme | http |
| Details | Forward Hostname / IP | 192.168.1.50 |
| Details | Forward Port | 8090 |
| Details | Websockets Support | on — responses stream over it |
| SSL | SSL Certificate | your DNS-challenge certificate from Chapter 8 |
| SSL | Force SSL, HTTP/2 Support | on |
Ollama’s own API stays on 127.0.0.1:11434, as the book binds it. Open WebUI reaches it over the compose network by name; nothing else needs to.
Managing models
docker exec ollama ollama list # what's downloaded, and how big
docker exec ollama ollama rm <model> # free the space
Models live in ./ollama-data, inside /opt/stacks, so Chapter 18’s backup picks them up — several gigabytes each, and all of them downloadable again. Whether that’s worth the off-site storage is the same judgment call Chapter 18 describes for media. ./webui-data is the part you can’t download again: your chats and settings.
Official documentation
- Flask
- Microsoft Presidio — the analyzer and anonymizer APIs, and running it in Docker
- Ollama documentation — including Docker
- Open WebUI documentation
- NVIDIA Container Toolkit install guide
Stuck on a step? Get in touch.