Tutorials

The Sanitiser, Presidio, Ollama and Open WebUI: Going Deeper

Chapter 16's two tiers of local AI, built step by step: the Sanitiser behind your proxy, wiring Presidio in so it catches names, and Ollama with Open WebUI on a GPU.

advanced ⏱ 90 min Be Your Own Cloud · Chapter 16 aiprivacyollamapresidio

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:

File /opt/stacks/sanitizer/app.py
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)
Exactly as printed in Be Your Own Cloud, Chapter 16 , p. 70 · all of Chapter 16's code

The Dockerfile that packages it:

File /opt/stacks/sanitizer/Dockerfile
# Dockerfile
FROM python:3.12-slim
WORKDIR /app
RUN pip install flask
COPY app.py .
CMD ["python", "app.py"]
Exactly as printed in Be Your Own Cloud, Chapter 16 , p. 71 · all of Chapter 16's code

And the compose file:

File /opt/stacks/sanitizer/docker-compose.yml
services:
  sanitizer:
    build: .
    container_name: sanitizer
    restart: unless-stopped
    networks:
      - proxy   # Chapter 8's shared network -- no host port published

networks:
  proxy:
    external: true
Exactly as printed in Be Your Own Cloud, Chapter 16 , p. 71 · all of Chapter 16's code

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:

TabSettingValue
DetailsDomain Namessanitize.home.yourdomain.com
DetailsSchemehttp
DetailsForward Hostname / IPsanitizer
DetailsForward Port5000
SSLSSL Certificateyour DNS-challenge certificate from Chapter 8
SSLForce SSLon

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:

Add to /opt/stacks/sanitizer/docker-compose.yml

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.
Exactly as printed in Be Your Own Cloud, Chapter 16 , p. 72 · all of Chapter 16's code

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
File /opt/stacks/ollama/docker-compose.yml
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'
Exactly as printed in Be Your Own Cloud, Chapter 16 , p. 72 · all of Chapter 16's code
Run Terminal, in /opt/stacks/ollama
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
Exactly as printed in Be Your Own Cloud, Chapter 16 , p. 73 · all of Chapter 16's code

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:

TabSettingValue
DetailsDomain Namesai.home.yourdomain.com
DetailsSchemehttp
DetailsForward Hostname / IP192.168.1.50
DetailsForward Port8090
DetailsWebsockets Supporton — responses stream over it
SSLSSL Certificateyour DNS-challenge certificate from Chapter 8
SSLForce SSL, HTTP/2 Supporton

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

Stuck on a step? Get in touch.