Presidio on Ubuntu 24.04 on Azure User Guide
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
/healthzendpoint for load balancer probes

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
- An active Azure subscription with permission to create virtual machines.
- An SSH key pair for the
azureuseraccount. - A network security group allowing inbound TCP 22 and TCP 80 from your address ranges.
- 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
- In the Azure Portal choose Create a resource and search for Presidio on Ubuntu 24.04.
- Select the cloudimg offering and choose Create.
- Pick your subscription, resource group and region.
- Set the VM size to Standard_B2ms or larger.
- Set the authentication type to SSH public key and the username to
azureuser. - On the Networking tab allow inbound SSH (22) and HTTP (80).
- 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.

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.

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

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
- Restrict inbound TCP 80 and 22 to known address ranges in your network security group.
- Terminate TLS before sending real text to this API over any untrusted network.
- Rotate the API password from its generated value if it is shared between systems.
- Keep the VM patched with
sudo apt-get update && sudo apt-get upgrade. - Treat
/root/presidio-credentials.txtas a secret and keep its0600permissions.
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.