Self-Host Presidio: Redact PII Before It Ever Reaches Your LLM

Self-Host Presidio: Redact PII Before It Ever Reaches Your LLM

I did the thing you're not supposed to do. A customer emailed us a support transcript, I wanted a quick summary, so I pasted the whole thing into a hosted LLM. Halfway through typing the prompt I noticed it: full name, email, a phone number, and the last four of a card. That transcript was now sitting in someone else's logs, and there was no undo button.

If you're building anything with LLMs, you've had this exact moment, or you're about to. You can't solve it by being more careful, because humans forget. You solve it with a service that sits in front of your model and strips the sensitive stuff out automatically, every single time, before a single token leaves your network. That service is Microsoft Presidio, and you can self-host it on Elestio in a few minutes.

What Presidio actually is

Presidio is an open-source PII detection and anonymization toolkit from Microsoft. It's not a model you fine-tune. It's two small services you put in your data path:

  • Presidio Analyzer: takes text, tells you where the PII is (entity type, position, confidence score).
  • Presidio Anonymizer: takes that text plus the analyzer's findings and rewrites it, redacting, masking, hashing, or encrypting each match.

Both expose plain REST APIs. You send JSON in, you get JSON out. The analyzer does the hard part using a mix of named-entity recognition (spaCy under the hood), regex pattern recognizers, checksum validators (a "credit card" that fails the Luhn check gets a lower score), and context words that boost confidence. Out of the box it knows about PERSON, EMAIL_ADDRESS, PHONE_NUMBER, CREDIT_CARD, US_SSN, IP_ADDRESS, IBAN_CODE, and a couple dozen more.

The important part: Presidio never has to phone home. It runs entirely on your box, which is the whole point when your goal is to not leak data.

Deploy it on Elestio

The analyzer ships a spaCy language model, so give it real memory. A small managed VM with around 4 GB RAM handles the default English model comfortably; check the current sizing and pricing on the Elestio Presidio service page. One-click deploy gets you both the analyzer and anonymizer behind a managed reverse proxy with TLS and backups already wired up, so you're not hand-rolling Docker Compose and a certbot cron at midnight.

Once it's up, you'll have two endpoints. In the examples below I'll call them ANALYZER_URL and ANONYMIZER_URL.

The redaction pipeline

Here's the whole flow in one script. It takes raw text, finds the PII, replaces it, and only then hands the clean version to your LLM.

import requests

ANALYZER_URL = "https://your-analyzer.vm.elestio.app"
ANONYMIZER_URL = "https://your-anonymizer.vm.elestio.app"

def scrub(text: str, language: str = "en") -> str:
    # 1. Find the PII
    findings = requests.post(
        f"{ANALYZER_URL}/analyze",
        json={"text": text, "language": language},
        timeout=10,
    ).json()

    # 2. Redact it
    result = requests.post(
        f"{ANONYMIZER_URL}/anonymize",
        json={
            "text": text,
            "analyzer_results": findings,
            "anonymizers": {
                "DEFAULT": {"type": "replace", "new_value": "<REDACTED>"},
                "EMAIL_ADDRESS": {"type": "mask", "masking_char": "*",
                                  "chars_to_mask": 8, "from_end": False},
            },
        },
        timeout=10,
    ).json()

    return result["text"]


dirty = "Hi, I'm Sarah Chen, card 4111 1111 1111 1111, sarah@acme.io"
clean = scrub(dirty)
print(clean)
# Hi, I'm <REDACTED>, card <REDACTED>, ********me.io

# Now it's safe to send `clean` to your LLM

That's it. The analyzer returns a list of findings, you pass them straight into the anonymizer, and you get back text that's safe to forward. No PII ever reaches your model provider.

Pick the right operator for the job

Redacting to <REDACTED> is fine for logs, but sometimes you need the text to stay usable, or you need to get the original back later. The anonymizer gives you five operators:

Operator What it does Use it when
replace Swaps the value for a placeholder like <PERSON> You want the LLM to know a name was there
mask Hides part of the string with a character You need a hint left, like the last 4 digits
redact Deletes the value entirely The PII adds nothing for the model
hash One-way hash (SHA-256 by default) You need to correlate values without reading them
encrypt Reversible, using a key you hold You need the original back after the LLM replies

That last one is the trick nobody talks about. If your agent needs to actually email Sarah back, redacting her address breaks the workflow. Instead you encrypt it on the way in, let the LLM work on the ciphertext placeholder, then decrypt the model's output on the way out with the same key. The model never sees the real address, and your app still works end to end.

Teach it your own PII

The built-in recognizers won't know your internal customer IDs or a country-specific ID format. Adding one is a regex and a name. You register a custom PatternRecognizer (for example CUSTOMER_ID matching CUST-\d{6}) with the analyzer, and from then on it flags those the same way it flags emails. Start with the built-ins, then add recognizers for the formats that are specific to your business.

Troubleshooting

Names slip through. NER is probabilistic, not a lookup table. Unusual names, all-lowercase text, or short prompts lower the score. Lower the analyzer's score_threshold so borderline matches still get caught, add a context word list, and always pair NER-based entities with strict regex for the high-stakes ones like cards and SSNs.

It flags things that aren't PII. Over-redaction is common with PERSON and LOCATION. Raise the threshold for those specific entity types, or pass an allow_list of known-safe strings in the analyze request.

Latency feels high. The first request after a cold start loads the spaCy model, so it's slow once, then fast. Keep the service warm, batch where you can, and don't spin the container down between requests.

Non-English text misses everything. The default model is English only. Presidio supports multi-language, but you have to load the matching spaCy model and pass the right language code in every request.

The one habit that saves you

Put the scrub step between your app and the model and never call the provider directly again. Make it the only door. Once redaction is a function every prompt has to pass through, "oops I pasted a card number" stops being a thing that can happen, because the human is no longer the safety layer.

Spin up Presidio on the Elestio Presidio page, point your prompts through it, and your data stops leaving the building.

Thanks for reading ❤️ See you in the next one 👋