Artificial Intelligence (AI) Azure

Presidio on Ubuntu 24.04 on Azure User Guide

| Product: Presidio on Ubuntu 24.04 LTS on Azure

Overview

Presidio is an open source toolkit for finding and removing personally identifiable information from free text. It runs as two cooperating services: the analyzer, which locates entities such as names, email addresses, phone numbers, credit card numbers and national identifiers and returns the entity type, the exact character offsets and a confidence score for each, and the anonymizer, which redacts, replaces, masks, hashes or encrypts those spans. Presidio was originally created at Microsoft and is now community governed under the Data Privacy Stack organisation, and is released under the MIT licence.

The cloudimg image installs both services into a Python virtual environment at /opt/presidio, runs each under gunicorn as an unprivileged presidio system account bound to loopback, fronts them with a single nginx gateway on TCP 80 that enforces HTTP Basic authentication on every API path, and generates a unique API password on the first boot of every VM. Backed by 24/7 cloudimg support.

What is included:

  • presidio-analyzer 2.2.363 and presidio-anonymizer 2.2.363, both MIT licensed
  • spaCy 3.8.13 with the en_core_web_lg 3.8.0 language model, MIT licensed and verified against the artifact at build time
  • nginx as an authenticating reverse proxy, the only network reachable surface
  • A per VM API password generated on first boot into a root only file
  • An unauthenticated /healthz endpoint for load balancer probes

Presidio running on the cloudimg Azure image with presidio analyzer, presidio anonymizer and nginx all active, both engines bound to loopback only on ports 5001 and 5002, and the unauthenticated health probe returning HTTP 200

Key facts

Item Value
Platform Ubuntu 24.04 LTS
Default SSH user azureuser
Install root /opt/presidio
Credentials file /root/presidio-credentials.txt
API user admin
Analyzer (loopback) 127.0.0.1:5001
Anonymizer (loopback) 127.0.0.1:5002
Public API TCP 80 via nginx

Prerequisites

  1. An active Azure subscription with permission to create virtual machines.
  2. An SSH key pair for the azureuser account.
  3. A network security group allowing inbound TCP 22 and TCP 80 from your address ranges.
  4. Recommended VM size: Standard_B2ms (2 vCPU, 8 GiB). The analyzer holds the language model in memory, measured at roughly 1 GiB resident. Standard_B2s works for evaluation, but it is a burstable size and sustained analysis will exhaust its CPU credits.

Step 1 — Deploy from the Azure Marketplace

  1. In the Azure Portal choose Create a resource and search for Presidio on Ubuntu 24.04.
  2. Select the cloudimg offering and choose Create.
  3. Pick your subscription, resource group and region.
  4. Set the VM size to Standard_B2ms or larger.
  5. Set the authentication type to SSH public key and the username to azureuser.
  6. On the Networking tab allow inbound SSH (22) and HTTP (80).
  7. Review and create.

Step 2 — Deploy from the Azure CLI

az vm create \
  --resource-group my-resource-group \
  --name my-presidio-vm \
  --image cloudimg:presidio-ubuntu-24-04:default:latest \
  --size Standard_B2ms \
  --admin-username azureuser \
  --generate-ssh-keys \
  --public-ip-sku Standard

Open the API port to your own address range:

az vm open-port --resource-group my-resource-group --name my-presidio-vm --port 80

Step 3 — Connect to your VM

az vm show --resource-group my-resource-group --name my-presidio-vm -d --query publicIps -o tsv
ssh azureuser@<vm-ip>

Step 4 — Confirm the services are running

systemctl is-active presidio-analyzer presidio-anonymizer nginx

Expected output:

active
active
active

Both engines listen on loopback only. nginx is the sole public surface, so neither the analyzer nor the anonymizer can be reached directly from the network:

ss -tln | grep -E '127.0.0.1:500[12]|:80 '

The unauthenticated health probe is safe to expose to a load balancer, because it is served by nginx itself and touches neither engine:

curl -s -o /dev/null -w 'healthz -> HTTP %{http_code}\n' http://127.0.0.1/healthz

Step 5 — Retrieve your API password

Every VM generates its own password on first boot. There is no default login and no shared secret between deployments.

sudo cat /root/presidio-credentials.txt

The file is readable only by root and contains PRESIDIO_URL, PRESIDIO_USERNAME and PRESIDIO_PASSWORD. Store the password in your secret manager and treat it as you would any API key.

The per VM Presidio credentials file shown with ls minus l proving root only 0600 permissions without revealing its contents, and the same analyze request returning HTTP 401 with no credential, HTTP 401 with a wrong password, and HTTP 200 with the per VM password

Step 6 — Confirm authentication is enforced

Every API path requires the credential. An unauthenticated request is refused:

curl -s -o /dev/null -w 'no credential -> HTTP %{http_code}\n' \
  -X POST http://127.0.0.1/analyze \
  -H 'Content-Type: application/json' \
  -d '{"text":"My name is Jane Doe","language":"en"}'

Expected output:

no credential -> HTTP 401

With the per VM password the same request succeeds:

curl -s -o /dev/null -w 'per VM password -> HTTP %{http_code}\n' \
  -u admin:<PRESIDIO_PASSWORD> \
  -X POST http://127.0.0.1/analyze \
  -H 'Content-Type: application/json' \
  -d '{"text":"My name is Jane Doe","language":"en"}'

Expected output:

per VM password -> HTTP 200

Step 7 — Detect personal data

Post text to /analyze. The response is a JSON array in which each entry gives the entity type, the start and end character offsets and a confidence score. The examples below use the reserved example.com domain and the 555-01xx telephone range reserved for fiction, so no real personal data is involved.

curl -s -u admin:<PRESIDIO_PASSWORD> \
  -X POST http://127.0.0.1/analyze \
  -H 'Content-Type: application/json' \
  -d '{"text":"My name is Jane Doe, email jane.doe@example.com, phone 212-555-0143.","language":"en"}'

Expected output:

[{"end":47,"entity_type":"EMAIL_ADDRESS","score":1.0,"start":27},{"end":19,"entity_type":"PERSON","score":0.85,"start":11},{"end":67,"entity_type":"PHONE_NUMBER","score":0.75,"start":55},{"end":34,"entity_type":"URL","score":0.5,"start":27},{"end":47,"entity_type":"URL","score":0.5,"start":36}]

The offsets are what make the result actionable: slicing the original string with start and end returns exactly the detected value, so you can redact in place without re guessing where the entity was. Here start 11 and end 19 select Jane Doe, and 27 to 47 selects the email address.

Note the two lower scoring URL entries. Presidio's recognisers overlap by design, so the domain part of an email address is also reported as a URL with a confidence of 0.5. This is normal and the anonymizer resolves the overlapping spans for you. If you want only high confidence results, pass a threshold:

curl -s -u admin:<PRESIDIO_PASSWORD> \
  -X POST http://127.0.0.1/analyze \
  -H 'Content-Type: application/json' \
  -d '{"text":"My name is Jane Doe, email jane.doe@example.com, phone 212-555-0143.","language":"en","score_threshold":0.6}'

To see every entity type this image can detect:

curl -s -u admin:<PRESIDIO_PASSWORD> http://127.0.0.1/supportedentities

Step 8 — Redact personal data

Pass the analyzer results to /anonymize together with the operator you want. The replace operator substitutes a token, and DEFAULT applies it to every entity type.

TEXT='My name is Jane Doe, email jane.doe@example.com, phone 212-555-0143.'
RESULTS=$(curl -s -u admin:<PRESIDIO_PASSWORD> \
  -X POST http://127.0.0.1/analyze \
  -H 'Content-Type: application/json' \
  -d "{\"text\":\"$TEXT\",\"language\":\"en\"}")
curl -s -u admin:<PRESIDIO_PASSWORD> \
  -X POST http://127.0.0.1/anonymize \
  -H 'Content-Type: application/json' \
  -d "{\"text\":\"$TEXT\",\"analyzer_results\":$RESULTS,\"anonymizers\":{\"DEFAULT\":{\"type\":\"replace\",\"new_value\":\"<REDACTED>\"}}}"

Expected output:

{"items":[{"end":57,"entity_type":"PHONE_NUMBER","operator":"replace","start":47,"text":"<REDACTED>"},{"end":39,"entity_type":"EMAIL_ADDRESS","operator":"replace","start":29,"text":"<REDACTED>"},{"end":21,"entity_type":"PERSON","operator":"replace","start":11,"text":"<REDACTED>"}],"text":"My name is <REDACTED>, email <REDACTED>, phone <REDACTED>."}

The start and end values in items refer to positions in the returned text, not the input, because each replacement changes the length of the string. The overlapping URL spans from the previous step were resolved automatically, leaving three replacements.

Presidio detecting a person, an email address and a telephone number in a synthetic sentence with exact character offsets and confidence scores, then the anonymizer returning the same sentence with each of those spans replaced by a redaction token

Other operators are available. redact removes the span entirely, mask replaces part of it with a repeated character, hash substitutes a digest, and encrypt produces a reversible value you can later pass to /deanonymize. List them with:

curl -s -u admin:<PRESIDIO_PASSWORD> http://127.0.0.1/anonymizers

Step 9 — Call the API from your application

Point any existing Presidio client at the VM and supply the credential. The standard Presidio paths are preserved unchanged by the gateway, so client libraries work without modification.

import requests

BASE = "http://<vm-ip>"
AUTH = ("admin", "<PRESIDIO_PASSWORD>")

text = "Please contact Jane Doe at jane.doe@example.com"

results = requests.post(f"{BASE}/analyze",
                        json={"text": text, "language": "en"},
                        auth=AUTH, timeout=60).json()

clean = requests.post(f"{BASE}/anonymize",
                      json={"text": text,
                            "analyzer_results": results,
                            "anonymizers": {"DEFAULT": {"type": "replace",
                                                        "new_value": "<REDACTED>"}}},
                      auth=AUTH, timeout=60).json()

print(clean["text"])

The language model and its licence

Presidio's own code is MIT licensed, but it cannot detect anything without a natural language model, and models are separately licensed artifacts. This image ships the spaCy model en_core_web_lg 3.8.0, which is MIT licensed. That licence is verified during the build against the artifact itself rather than taken from documentation, from both the model metadata and the licence file inside the model package, and the build fails unless both agree.

The full record is on the image:

cat /opt/presidio/MODEL-PROVENANCE.txt

The shipped component licences read from the artifacts themselves showing presidio analyzer, presidio anonymizer and spaCy all MIT, the en_core_web_lg 3.8.0 model recorded as MIT licensed, and the on disk layout with the virtual environment and the unprivileged presidio service account

Recognisers based on Stanza or transformers models are deliberately not installed, because those pull models whose licences vary per model and are frequently share alike or non commercial.

Log hygiene for submitted text

Presidio's threat model is unusual: callers send exactly the sensitive text they are trying to protect. This image is configured so that submitted text is never written to disk.

  • Access logging is disabled on every API path in the nginx gateway, and nginx never logs request bodies in any case.
  • Both services run gunicorn with access logging sent to /dev/null.
  • The services run at a log level that does not echo analysed text, and Presidio's decision process tracing, which can include matched text, is left disabled.

You can confirm this on your own VM by submitting a marked string and searching the logs for it:

sudo bash -c '
MARKER="ZZPROBE$(openssl rand -hex 4 | tr "a-f" "A-F")ZZ"
PW=$(grep "^PRESIDIO_PASSWORD=" /root/presidio-credentials.txt | cut -d= -f2-)
curl -s -o /dev/null -u "admin:$PW" -X POST http://127.0.0.1/analyze \
  -H "Content-Type: application/json" \
  -d "{\"text\":\"My name is $MARKER Doe\",\"language\":\"en\"}"
sleep 2
grep -rlF "$MARKER" /var/log 2>/dev/null || echo "not present in /var/log"
journalctl -u presidio-analyzer -u presidio-anonymizer -u nginx --no-pager --since "-5 min" | grep -cF "$MARKER" || true
'

Expected output:

not present in /var/log
0

The whole probe runs inside a single sudo bash -c for a reason worth knowing: sudo writes the full argument list of every command it runs into auth.log and the journal. If you generated the marker in your own shell and then ran sudo grep ... ZZPROBE..., that grep would log the marker itself and then find it, reporting a leak caused entirely by your own search. Generating the marker inside the elevated shell keeps it off every command line, so the search above covers all of /var/log with no exclusions and the result means what it says.

Server components

Component Version Purpose
presidio-analyzer 2.2.363 Detects PII entities and returns their offsets
presidio-anonymizer 2.2.363 Redacts, replaces, masks, hashes or encrypts spans
spaCy 3.8.13 Natural language engine
en_core_web_lg 3.8.0 English language model, MIT licensed
nginx distribution Authenticating reverse proxy on TCP 80
gunicorn 26.0.0 WSGI server for both Presidio services

Filesystem layout

Path Purpose
/opt/presidio/venv Python virtual environment holding Presidio, spaCy and the model
/opt/presidio/app The analyzer and anonymizer service applications
/opt/presidio/MODEL-PROVENANCE.txt Shipped components and the licence of each
/var/lib/presidio Writable state and cache for the services
/root/presidio-credentials.txt Per VM API credentials, root only
/etc/nginx/cloudimg-presidio.htpasswd Hashed API password used by the gateway

Managing the services

sudo systemctl restart presidio-analyzer
sudo systemctl restart presidio-anonymizer
sudo systemctl reload nginx

The analyzer loads the language model at startup, so allow up to a minute after a restart before the first request succeeds. Follow its progress with:

sudo journalctl -u presidio-analyzer -n 30 --no-pager

Changing the API password

sudo sh -c 'printf "admin:%s\n" "$(openssl passwd -apr1 NEW_PASSWORD)" > /etc/nginx/cloudimg-presidio.htpasswd'
sudo systemctl reload nginx

Update /root/presidio-credentials.txt to match so the record on the VM stays accurate.

Enabling HTTPS

The gateway serves plain HTTP on port 80 so the image works immediately on a private network. Because callers send sensitive text to this API, terminate TLS before exposing it beyond a trusted network. With a DNS name pointed at the VM:

sudo apt-get update && sudo apt-get install -y certbot python3-certbot-nginx
sudo certbot --nginx -d presidio.example.com

Certbot rewrites the nginx site to listen on 443 and installs a renewal timer. Afterwards, restrict inbound TCP 80 in your network security group.

Troubleshooting

Symptom Diagnosis
HTTP 401 on every call The password is wrong, or the admin user was omitted. Re read /root/presidio-credentials.txt.
HTTP 502 on /analyze The analyzer is not running or is still loading the model. Check systemctl is-active presidio-analyzer.
/analyze returns [] for text that clearly contains PII The language model failed to load. Check sudo journalctl -u presidio-analyzer for an import error.
HTTP 400 from /anonymize Each analyzer_results entry needs entity_type, start and end.
First request after boot is slow Expected. The model is loaded and warmed at startup, which takes tens of seconds.

Security recommendations

  1. Restrict inbound TCP 80 and 22 to known address ranges in your network security group.
  2. Terminate TLS before sending real text to this API over any untrusted network.
  3. Rotate the API password from its generated value if it is shared between systems.
  4. Keep the VM patched with sudo apt-get update && sudo apt-get upgrade.
  5. Treat /root/presidio-credentials.txt as a secret and keep its 0600 permissions.

On startup

On the first boot of every VM, presidio-firstboot.service generates the per VM API password, writes it to /root/presidio-credentials.txt and to the gateway's password file, starts both engines, brings up nginx, and then proves a real detection and anonymisation round trip before marking first boot complete at /var/lib/cloudimg/presidio-firstboot.done. If that proof fails the unit fails, so a VM never reports a healthy first boot with a broken language model.

Inspect what it did with:

sudo journalctl -u presidio-firstboot --no-pager

The unit is guarded by that sentinel file, so it runs once and is skipped on subsequent reboots.

Support

cloudimg provides 24/7 support for this image. Contact support@cloudimg.co.uk.