Deployment guide

Presidio

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 locates entities such as names, email addresses, phone numbers, credit card numbers and national identifiers. For each entity it returns the entity type, the exact character offsets and a confidence score. The anonymizer redacts, replaces, masks, hashes or encrypts those spans. Presidio was originally created at Microsoft, is now community-governed under the Data Privacy Stack organisation, and is released under the MIT licence.

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

What you get:

  • 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, which is the only surface reachable from the network.
  • A per-VM API password, generated on first boot into a root-only file.
  • An unauthenticated /healthz endpoint 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 azureuser account.
  • A network security group that allows 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 exhausts 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 SecureAnyCloud 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).
  • Choose Review + create, and then Create.

Step 2 - Deploy from the Azure CLI

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

Then open the API port. Restrict it to your own address range in the network security group:

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

Check that the three services are active:

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 you cannot reach the analyzer or the anonymizer directly from the network. Confirm the listeners:

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

The unauthenticated health probe is safe to expose to a load balancer, because nginx serves it itself and it 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. Read the password from the credentials file:

sudo cat /root/presidio-credentials.txt

Only root can read the file. It 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, so nginx refuses an unauthenticated request:

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. 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 make the result actionable. Slicing the original string with start and end returns exactly the detected value, so you can redact in place without 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. To keep 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 list every entity type this image can detect, run:

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 anonymizer resolved the overlapping URL spans from the previous step automatically, which leaves 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 that you can later pass to /deanonymize. List the operators 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 gateway preserves the standard Presidio paths unchanged, so client libraries work without modification. For example, in Python:

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. The build verifies that licence against the artifact itself rather than taking it from documentation. It checks 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 in 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.

To confirm this on your own VM, submit a marked string and search 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: sudo writes the full argument list of every command it runs into auth.log and the journal. Suppose 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, and it would report a leak caused entirely by your own search. Generating the marker inside the elevated shell keeps it off every command line. The search above therefore 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/sac-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. To follow its progress, run:

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/sac-presidio.htpasswd'
sudo systemctl reload nginx

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

Enabling HTTPS

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

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 you send real text to this API over any untrusted network.
  • Rotate the API password from its generated value if you share it between systems.
  • Keep the VM patched with sudo apt-get update && sudo apt-get upgrade.
  • 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 does the following. It generates the per-VM API password and writes it to /root/presidio-credentials.txt and to the gateway's password file. It starts both engines and brings up nginx. It then proves a real detection and anonymisation round trip before it marks first boot complete at /var/lib/sac/data/presidio-firstboot.done. If that proof fails, the unit fails, so a VM never reports a healthy first boot with a broken language model.

To inspect what it did, run:

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

That sentinel file guards the unit, so it runs once and is skipped on later reboots.

Support

SecureAnyCloud provides 24/7 support for this image. Contact sales@secureanycloud.com.

Deploy on Azure

Launch Presidio on Ubuntu 24.04 LTS on Azure with 24/7 support from SecureAnyCloud.

View on Marketplace

Need Help?

Our support team is available 24/7.

sales@secureanycloud.com

Deploy on Azure