The EU General Data Protection Regulation (GDPR) requires anyone who processes personal data of people in the EU to protect it with "appropriate technical and organisational measures" (Article 32), keep it no longer than necessary, and be able to answer access and deletion requests. Much of that work happens on the servers that store the data. In this tutorial you will apply the most important technical measures on an Ubuntu 24.04 server running a web application: map where personal data lives, stop logging full IP addresses, enforce retention periods, encrypt data in transit and in backups, restrict and audit access, and prepare for data subject requests and breaches.

Prerequisites

To follow this guide you need:

  • A server running Ubuntu 24.04 LTS that hosts a web application with personal data, for example a CubePath VPS.
  • A non-root user with sudo privileges and SSH key access.
  • Nginx as the web server and MySQL or MariaDB as the database. The ideas apply to other stacks; only the commands change.
  • A domain name pointing to the server if you still need to set up HTTPS.
  • A workstation with GnuPG installed, to create the backup encryption key in Step 5.

What GDPR expects from a server

Most of the GDPR principles translate into concrete server controls:

GDPR principle or articleWhat it means on the server
Data minimisation (Art. 5.1.c)Do not collect or log personal data you do not need, such as full IP addresses in access logs.
Storage limitation (Art. 5.1.e)Delete logs, backups and records automatically when their retention period ends.
Integrity and confidentiality (Art. 5.1.f, Art. 32)Encrypt data in transit and at rest, restrict access, patch systems.
Accountability (Art. 5.2, Art. 30)Keep a record of processing activities and evidence of the controls.
Data subject rights (Art. 15 to 20)Be able to find, export and delete one person's data.
Breach notification (Art. 33)Detect breaches and report them to the supervisory authority within 72 hours.

The steps below implement each row.

Step 1 - Mapping where personal data lives

You cannot protect data you do not know about. Personal data is any information about an identifiable person: names, emails, phone numbers, IP addresses, account IDs and anything linked to them. On a typical web server it ends up in the database, web server and application logs, the system journal, uploads and backups.

List the services listening on the network, since each one may receive personal data:

sudo ss -tlnp
State  Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0      511          0.0.0.0:443       0.0.0.0:*     users:(("nginx",pid=1021,fd=6))
LISTEN 0      151        127.0.0.1:3306      0.0.0.0:*     users:(("mysqld",pid=880,fd=23))
LISTEN 0      4096         0.0.0.0:22        0.0.0.0:*     users:(("sshd",pid=702,fd=3))

Find the database columns that probably hold personal data by searching the schema for typical names. Run it from the MySQL shell with sudo mysql:

SELECT table_schema, table_name, column_name
FROM information_schema.columns
WHERE table_schema NOT IN ('mysql', 'sys', 'information_schema', 'performance_schema')
  AND column_name REGEXP 'email|phone|name|address|birth|ip|iban|tax';

Check which log files are largest and therefore likely to hold personal data such as IP addresses or emails:

sudo du -sh /var/log/* | sort -rh | head

Record what you find in your record of processing activities (Article 30). For each system, write down the categories of data, the purpose, who can access it, where it is stored, whether it leaves the EU, and how long it is kept. A table in your internal wiki is enough:

SystemDataPurposeLocationRetention
MySQL shop.customersName, email, addressOrders and invoicingThis serverContract + legal period
Nginx access logsAnonymized IP, user agentSecurity and troubleshooting/var/log/nginx14 days
Database backupsFull databaseRecovery/var/backups/db + offsite30 days

Step 2 - Anonymizing IP addresses in Nginx logs

IP addresses are personal data. For most troubleshooting you only need the network, not the exact host. You can make Nginx zero the last octet of IPv4 addresses and keep only the first two groups of IPv6 addresses before writing them to disk.

Create a configuration file for the anonymized log format:

sudo nano /etc/nginx/conf.d/anonymize-ip.conf

Add the following content:

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 - $remote_user [$time_local] '
                      '"$request" $status $body_bytes_sent '
                      '"$http_referer" "$http_user_agent"';

access_log /var/log/nginx/access.log anonymized;

The default access_log line in the main configuration would keep writing full addresses, so disable it. Open the main file:

sudo nano /etc/nginx/nginx.conf

Find the line access_log /var/log/nginx/access.log; inside the http block and comment it out:

        # access_log /var/log/nginx/access.log;

If any of your server blocks in /etc/nginx/sites-available/ define their own access_log, add anonymized at the end of those lines too. Test and reload Nginx:

sudo nginx -t
sudo systemctl reload nginx
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

Make a request to the site and check the last log line:

sudo tail -n 1 /var/log/nginx/access.log
203.0.113.0 - - [25/Sep/2026:10:41:12 +0000] "GET / HTTP/2.0" 200 5120 "-" "curl/8.5.0"

The client address now ends in .0. Old log files still contain full addresses until they rotate out in the next step.

Step 3 - Enforcing retention periods for logs

Keeping logs forever violates storage limitation. Set a retention period for each log source and let the system delete old data automatically.

Nginx logs on Ubuntu are rotated by logrotate. Check the current policy:

cat /etc/logrotate.d/nginx
/var/log/nginx/*.log {
	daily
	missingok
	rotate 14
	compress
	delaycompress
	notifempty
	create 0640 www-data adm
	...
}

daily with rotate 14 keeps two weeks of logs. Change the rotate value if your documented retention is different, and apply the same review to your application's log files.

The systemd journal also stores personal data, such as SSH login IPs and application output. By default it is limited only by size. Add a time limit with a drop-in file:

sudo mkdir -p /etc/systemd/journald.conf.d
sudo nano /etc/systemd/journald.conf.d/retention.conf
[Journal]
MaxRetentionSec=30day

Restart journald and confirm the journal's disk usage:

sudo systemctl restart systemd-journald
journalctl --disk-usage
Archived and active journals take 184.0M in the file system.

Step 4 - Encrypting data in transit

All traffic that carries personal data must be encrypted. For a public site, obtain a free Let's Encrypt certificate with Certbot. Replace your_domain with your domain:

sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d your_domain -d www.your_domain

Certbot configures HTTPS, redirects HTTP to HTTPS and installs a timer for renewal. Check that the renewal works:

sudo certbot renew --dry-run

Internal traffic counts too. Keep the database bound to 127.0.0.1 as shown in Step 1, or, if the application runs on another server, connect over a private network or require TLS on the database connection. Never expose port 3306 to the Internet.

Step 5 - Encrypting backups

Backups are full copies of your personal data and are often stored off the server, so they must be encrypted. Asymmetric encryption with GnuPG is a good fit: the server only holds the public key and can encrypt backups but never decrypt them; the private key stays offline.

On your workstation, create a key pair dedicated to backups and export the public key:

gpg --quick-generate-key "Backups <backup@your_domain>" default default never
gpg --export --armor backup@your_domain > backup-pub.asc

Store the private key in a password manager or offline medium. Without it, the backups cannot be restored.

Copy backup-pub.asc to the server and import it into root's keyring, since the backup job runs as root:

sudo gpg --import backup-pub.asc
gpg: key 7C0D2B44E91A3F58: public key "Backups <backup@your_domain>" imported
gpg: Total number processed: 1
gpg:               imported: 1

Create the backup directory with restricted permissions and run a test backup. Replace your_database with your database name:

sudo install -d -m 700 /var/backups/db
sudo sh -c 'mysqldump --single-transaction your_database | gzip | gpg --batch --encrypt --recipient backup@your_domain --trust-model always -o /var/backups/db/your_database-$(date +%F).sql.gz.gpg'

Confirm that the file is encrypted and that the server itself cannot read it:

sudo gpg --decrypt /var/backups/db/your_database-*.sql.gz.gpg > /dev/null
gpg: encrypted with cv25519 key, ID 5B1E0C7A93D2F810, created 2026-09-25
      "Backups <backup@your_domain>"
gpg: decryption failed: No secret key

The error is the expected result: only the private key on your workstation can restore the backup.

Schedule the backup and the deletion of backups older than 30 days with a cron file:

sudo nano /etc/cron.d/db-backup
SHELL=/bin/sh
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
15 2 * * * root mysqldump --single-transaction your_database | gzip | gpg --batch --encrypt --recipient backup@your_domain --trust-model always -o /var/backups/db/your_database-$(date +\%F).sql.gz.gpg
45 2 * * * root find /var/backups/db -type f -name '*.sql.gz.gpg' -mtime +30 -delete

The % signs are escaped because cron treats an unescaped % as a newline. Apply the same retention to any offsite copy.

For data on the server's own disks, consider encryption at rest with LUKS on the volume that holds the database and uploads. It protects the data if a disk is removed or a volume snapshot leaks.

Step 6 - Restricting and auditing access

Only people who need personal data should be able to reach it, and you should be able to show who did.

List accounts that can log in and members of the sudo group:

getent passwd | awk -F: '$7 !~ /(nologin|false)$/ {print $1}'
getent group sudo
root
sync
sammy
deploy
sudo:x:27:sammy

Remove accounts that belong to former staff with sudo deluser --remove-home username, and make sure every administrator has a personal account. Shared accounts make auditing impossible.

Disable SSH password logins so that only key holders can connect. Create a drop-in file:

sudo nano /etc/ssh/sshd_config.d/10-hardening.conf
PasswordAuthentication no
PermitRootLogin no
KbdInteractiveAuthentication no

Validate the configuration and restart SSH. Keep your current session open until you have confirmed a new login works:

sudo sshd -t
sudo systemctl restart ssh

Next, use the Linux audit system to record access to the files that hold personal data, such as database dumps and data exports. Install auditd:

sudo apt install auditd

Create a rules file:

sudo nano /etc/audit/rules.d/personal-data.rules
-w /var/backups/db -p rwa -k personal_data
-w /srv/exports -p rwa -k personal_data
-w /etc/mysql -p wa -k db_config

Each line watches a path (-w) for reads, writes or attribute changes (-p rwa) and tags the events with a key (-k). Adjust the paths to your system; a watched path must exist. Load the rules and list them:

sudo mkdir -p /srv/exports
sudo augenrules --load
sudo auditctl -l
-w /var/backups/db -p rwa -k personal_data
-w /srv/exports -p rwa -k personal_data
-w /etc/mysql -p wa -k db_config

Read a backup file as a test, then search the audit log by key:

sudo head -c 16 /var/backups/db/your_database-*.sql.gz.gpg > /dev/null
sudo ausearch -k personal_data -i | tail -n 5

The output shows the time, the user (auid is the user who originally logged in, even after sudo) and the command that touched the file.

Step 7 - Handling access and erasure requests

People can ask for a copy of their data (Articles 15 and 20) or for its deletion (Article 17). You usually have one month to respond, so prepare the queries in advance using the columns you mapped in Step 1.

To export one person's records as JSON lines, adapt this query to your schema. Replace your_database, the table and columns, and the email address:

sudo mysql -N your_database -e "SELECT JSON_OBJECT('id', id, 'name', name, 'email', email, 'created_at', created_at) FROM customers WHERE email = '[email protected]'" > /srv/exports/request-1234.json

Deliver the file through a secure channel and delete it afterwards, since the export directory is audited and should not accumulate copies.

For erasure, delete or anonymize the records in the application database, in a transaction, and document the request. Two details matter on servers:

  • Backups: you normally do not edit old backups. Instead, document that deleted data remains in encrypted backups until they expire (30 days in this example) and make sure a restore does not bring deleted records back into production.
  • Secure deletion of files: shred does not reliably erase data on SSDs, NVMe drives or journaling and copy-on-write file systems, because the storage writes to new blocks. Rely on encryption instead: data on an encrypted volume or in encrypted backups is unreadable once the key or the files are gone.

Step 8 - Preparing for a data breach

Under Article 33, you must notify your supervisory authority within 72 hours of becoming aware of a personal data breach, unless it is unlikely to result in a risk to people. That is only possible if you detect breaches quickly and keep enough evidence to assess them.

Keep the system patched automatically:

sudo apt install unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades

Review successful and failed logins regularly:

last -n 10
sudo journalctl -u ssh --since "24 hours ago" | grep -E "Accepted|Failed"

Write a short incident runbook before you need it: who decides whether an incident is a breach, who contacts the authority, and what to collect. On the server, the first actions are to preserve evidence rather than wipe it:

  1. Take a snapshot or image of the affected server if your provider supports it.
  2. Save the relevant logs with sudo journalctl --since "2026-09-24" > incident-journal.txt and sudo ausearch -k personal_data -i > incident-audit.txt, adjusting the date.
  3. Isolate the server (firewall rules, rotated credentials) before rebuilding it.

Conclusion

You mapped the personal data on your server, stopped Nginx from storing full IP addresses, set retention limits for logs and backups, encrypted traffic and backups, restricted SSH, audited access to personal data files and prepared for data subject requests and breaches. These controls give you concrete evidence for your record of processing activities and your Article 32 documentation. As next steps, encrypt the data volume at rest with LUKS, review your processors' data processing agreements (hosting, email, analytics), and schedule a quarterly review of accounts, retention settings and audit logs.