Web server and application logs routinely store personal data: client IP addresses, email addresses in query strings, user names and session tokens. Under the GDPR an IP address is personal data, so every log line that contains one needs a legal basis, a retention limit and protection. The simplest way to reduce that burden is to not write the full data in the first place. In this tutorial you will configure Nginx on Ubuntu 24.04 to log truncated IP addresses and URLs without query strings, anonymize log files that already exist, and mask emails and IPs in a Python application's logs.
Prerequisites
To follow this tutorial you need:
- A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with a non-root user that has
sudoprivileges. - Nginx installed (
sudo apt install nginx) with at least one site in/etc/nginx/sites-available/. - Optionally, a Python 3 application that uses the standard
loggingmodule, for Step 5.
NoteThis guide covers the technical side. Whether truncated IPs count as anonymous or only pseudonymous data for your use case, and how long you may keep them, is a decision for your data protection officer or legal advisor.
Step 1 - Choosing what to remove
Anonymization means the data can no longer be linked to a person by anyone, including you. Pseudonymization (for example hashing an IP) still allows linking, because the same input always produces the same output and IPv4 addresses are few enough to brute force. For logs, truncation is the most common approach:
| Data | Technique in this guide | Result |
|---|---|---|
| IPv4 address | Replace the last octet with 0 | 203.0.113.45 becomes 203.0.113.0 |
| IPv6 address | Keep only the first 32 bits | 2001:db8:85a3::8a2e:370:7334 becomes 2001:db8:: |
| URL query string | Log $uri instead of $request_uri | /[email protected] becomes /reset |
| HTTP basic auth user | Not logged | - |
| Emails in app logs | Masked by a logging filter | a***@example.com |
A truncated IP still tells you the network and approximate location of a visitor, which is enough for traffic statistics and most debugging.
Step 2 - Truncating IP addresses in Nginx
Nginx cannot change $remote_addr itself, but a map block can build a new variable from it. Files in /etc/nginx/conf.d/ are included inside the http block, so the map is available to every site. Create the file:
sudo nano /etc/nginx/conf.d/anonymize.conf
map $remote_addr $remote_addr_anon {
~^(?P<ip>\d+\.\d+\.\d+)\.\d+$ $ip.0;
~^(?P<ip>[^:]+:[^:]+): $ip::;
default 0.0.0.0;
}
log_format anonymized '$remote_addr_anon - - [$time_local] '
'"$request_method $uri $server_protocol" $status $body_bytes_sent '
'"-" "$http_user_agent" $request_time';
The first regular expression matches IPv4 addresses and keeps the first three octets. The second matches IPv6 addresses and keeps the first two groups. Anything else, such as ::1 or a Unix socket, falls back to 0.0.0.0.
The anonymized format also leaves out three fields of the default combined format that often contain personal data:
$remote_user(the HTTP basic auth user name) is replaced by-.$request_uriis replaced by$uri, which has no query string. Password reset links, email addresses and tracking parameters often travel in the query string.$http_refereris replaced by-, because referrers from other sites can include their query strings.
The field order stays compatible with the combined format, so tools like GoAccess can still parse the log. $request_time is added at the end for performance analysis.
NoteIf Nginx runs behind a load balancer or CDN, configure the
realipmodule (set_real_ip_fromandreal_ip_header) first, so that$remote_addrcontains the visitor's IP and not the proxy's.
Step 3 - Enabling the format for each site
An access_log directive in a server block replaces the one inherited from nginx.conf. Open your site configuration:
sudo nano /etc/nginx/sites-available/your_domain
Add the access_log line inside each server block, including the one that Certbot created for port 443 if you use HTTPS:
server {
server_name your_domain;
root /var/www/your_domain;
access_log /var/log/nginx/your_domain.access.log anonymized;
location / {
try_files $uri $uri/ =404;
}
}
Do the same for the default site if it is enabled, since it receives requests for your server's IP address. Test and reload Nginx:
sudo nginx -t
sudo systemctl reload nginx
Verifying the new log format
Send a request with a query string from your local machine:
curl -s -o /dev/null "http://your_domain/[email protected]"
Then check the last line of the log on the server:
sudo tail -n 1 /var/log/nginx/your_domain.access.log
198.51.100.0 - - [25/Sep/2026:15:02:11 +0000] "GET / HTTP/1.1" 200 615 "-" "curl/8.5.0" 0.000
The last octet of the client IP is 0 and the query string is gone.
What about the error log
Nginx's error log has a fixed format and includes the full client IP (client: 203.0.113.45) on every entry. It cannot be anonymized by configuration. Keep its level at error or higher so it only logs real problems, and give it a short retention. The Nginx package's /etc/logrotate.d/nginx rotates all of /var/log/nginx/*.log daily and keeps 14 files; lower rotate 14 if your policy requires it.
ImportantTools such as Fail2ban need full IP addresses to block attackers. If you run a Fail2ban jail on Nginx logs, keep a second access log with full IPs only for that purpose, rotate it daily with a short
rotatevalue, and document it in your records of processing as a security measure.
Step 4 - Anonymizing existing log files
Logs written before this change still contain full IPs. Instead of an ad hoc one-liner, install a small script that rewrites both plain and compressed rotated files in place. Create it in /usr/local/sbin:
sudo nano /usr/local/sbin/anonymize-nginx-logs
#!/usr/bin/env bash
# Truncate the client IP (first field) in Nginx access logs.
set -euo pipefail
SED_EXPR='s/^([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})\.[0-9]{1,3} /\1.0 /; s/^([0-9a-fA-F]{1,4}:[0-9a-fA-F]{1,4}):[0-9a-fA-F:]* /\1:: /'
for file in "$@"; do
[ -f "$file" ] || continue
tmp="$(mktemp "${file}.XXXXXX")"
case "$file" in
*.gz) zcat -- "$file" | sed -E "$SED_EXPR" | gzip -c > "$tmp" ;;
*) sed -E "$SED_EXPR" -- "$file" > "$tmp" ;;
esac
chown --reference="$file" "$tmp"
chmod --reference="$file" "$tmp"
mv -- "$tmp" "$file"
echo "anonymized: $file"
done
Make it executable:
sudo chmod 0755 /usr/local/sbin/anonymize-nginx-logs
Run it on the rotated access logs. Leave out the active access.log files, which Nginx is still writing to:
sudo /usr/local/sbin/anonymize-nginx-logs /var/log/nginx/*access.log.*
anonymized: /var/log/nginx/access.log.1
anonymized: /var/log/nginx/access.log.2.gz
anonymized: /var/log/nginx/access.log.3.gz
Check that no full IPv4 address remains at the start of any line. The command prints the number of matching lines per file, which should be 0 everywhere:
sudo zgrep -cE '^[0-9]+\.[0-9]+\.[0-9]+\.[1-9][0-9]* ' /var/log/nginx/*access.log.*
Once the new format has been active for a full rotation cycle, all current files are anonymized at write time and this script is no longer needed. If you keep the old access.log (in the combined format) for a transition period, run the script on it after the next rotation.
Step 5 - Masking personal data in application logs
Application logs are harder, because personal data can appear anywhere in a message. The best fix is to not log it: log user IDs instead of emails, and never log request bodies or tokens. As a safety net, a logging filter can mask what slips through. For a Python application, add a filter that runs on every record:
import logging
import re
EMAIL_RE = re.compile(r"\b([A-Za-z0-9])[A-Za-z0-9._%+-]*@([A-Za-z0-9.-]+\.[A-Za-z]{2,})\b")
IPV4_RE = re.compile(r"\b(\d{1,3}\.\d{1,3}\.\d{1,3})\.\d{1,3}\b")
class PiiFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
message = record.getMessage()
message = EMAIL_RE.sub(r"\1***@\2", message)
message = IPV4_RE.sub(r"\1.0", message)
record.msg = message
record.args = None
return True
handler = logging.StreamHandler()
handler.addFilter(PiiFilter())
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s"))
logging.basicConfig(level=logging.INFO, handlers=[handler])
logging.getLogger("app").info("Password reset for %s from %s", "[email protected]", "203.0.113.45")
Save it as pii_filter_demo.py and run it:
python3 pii_filter_demo.py
2026-09-25 15:10:42,118 INFO app Password reset for a***@example.com from 203.0.113.0
The filter formats the message first (getMessage()), so it also catches values passed as arguments. Attach it to the handlers your application actually uses, not only to one logger, so that messages from libraries are covered too.
Step 6 - Limiting retention of what remains
Anonymized logs can usually be kept longer, but logs that still contain personal data (the Nginx error log, /var/log/auth.log with SSH client IPs, any Fail2ban log) should be kept only as long as needed for security. Review the rotate values in /etc/logrotate.d/nginx and /etc/logrotate.d/rsyslog, and test any change with:
sudo logrotate --debug /etc/logrotate.d/nginx
Write the result down: which logs contain personal data, what is truncated, and how many days each log is kept. That record is what an auditor or a data subject request will ask for.
Troubleshooting
nginx: [emerg] unknown log format "anonymized": thelog_formatis defined after it is used or the file inconf.dis not included. Check thatnginx.confcontainsinclude /etc/nginx/conf.d/*.conf;inside thehttpblock, beforesites-enabled.- Every line shows
0.0.0.0:$remote_addrdoes not look like an IP, usually because requests arrive over a Unix socket from a local proxy. Configure therealipmodule so the real client address is available. - Log analysis tools stop parsing the file: they probably expect the
combinedformat with a referrer. Configure the tool with a custom format, or put$http_refererback if you have checked it does not carry personal data for your site.
Conclusion
Nginx now writes access logs with truncated IP addresses and without query strings, old log files have been cleaned up, and your application masks emails and IPs before they reach its logs. Together with short retention for the logs that still need full data, this reduces the amount of personal data you store to what is strictly necessary. As next steps, apply the same approach to any other service that logs client data, add a monthly zgrep check like the one in Step 4 to your review routine, and record these measures in your GDPR records of processing.
