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 sudo privileges.
  • 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 logging module, for Step 5.

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:

DataTechnique in this guideResult
IPv4 addressReplace the last octet with 0203.0.113.45 becomes 203.0.113.0
IPv6 addressKeep only the first 32 bits2001:db8:85a3::8a2e:370:7334 becomes 2001:db8::
URL query stringLog $uri instead of $request_uri/[email protected] becomes /reset
HTTP basic auth userNot logged-
Emails in app logsMasked by a logging filtera***@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_uri is replaced by $uri, which has no query string. Password reset links, email addresses and tracking parameters often travel in the query string.
  • $http_referer is 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.

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.

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": the log_format is defined after it is used or the file in conf.d is not included. Check that nginx.conf contains include /etc/nginx/conf.d/*.conf; inside the http block, before sites-enabled.
  • Every line shows 0.0.0.0: $remote_addr does not look like an IP, usually because requests arrive over a Unix socket from a local proxy. Configure the realip module so the real client address is available.
  • Log analysis tools stop parsing the file: they probably expect the combined format with a referrer. Configure the tool with a custom format, or put $http_referer back 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.