Hi, last month I was reviewing a client staging box that the owner believed was “under a DDoS”. It was not a DDoS. It was one scanner with a grudge and six other bots doing their morning rounds, and the whole story was sitting in a plain text file nobody had opened: /var/log/nginx/access.log. You do not need a SIEM to find out who is hammering your server. You need awk, one small Python script, and ten minutes.
Sources: nginx | GNU awk manual | Python collections docs

Know your log format first
The combined format, the nginx default, puts the same fields in the same columns on almost every server you will ever touch:
| Field | awk position | Meaning |
|---|---|---|
| Remote addr | $1 | Client IP, your primary suspect column |
| Timestamp | $4 | Looks like [02/Nov/2026:13:10:01, note the bracket |
| Request | $6 $7 $8 | Method, path, protocol |
| Status | $9 | 200, 404, that 500 you forgot about |
| Bytes | $10 | Response size, useful for finding scrapers |
One honest warning: every command below assumes combined format and no quotes inside the URI. If your log_format is custom, check a couple of lines before trusting the column numbers.

The five one-liners I run first
This is my whole triage. Who is talking the most:
awk '{print $1}' access.log | sort | uniq -c | sort -rn | head -10 3036 203.0.113.1
3033 203.0.113.6
3014 203.0.113.9
3009 203.0.113.4
3001 203.0.113.2
2998 203.0.113.10
2949 203.0.113.11
2930 203.0.113.8
2919 203.0.113.3
2915 203.0.113.7Top talkers alone are boring, real users cluster at the top. The interesting question is what got rejected and where. This finds the paths that drew 404s and 403s, which is the bot equivalent of a fingerprint:
awk '$9 == "404" || $9 == "403" {print $7}' access.log \
| sort | uniq -c | sort -rn | head -6 1086 /.env
1071 /wp-login.php
1060 /vendor/phpunit/
1056 /cgi-bin/test.cgi
1051 /phpmyadmin/
1040 /xmlrpc.phpThat list is not traffic, it is a vulnerability scan wearing a browser user agent. Next, when did they arrive? A flat 24/7 rate is a bot, a morning ramp is humans:
awk -F'[:[]' '{print $3}' access.log | sort | uniq -c | head -6 1895 00
1869 01
1822 02
1850 03
1815 04
1870 05Just over 1,800 requests every hour, at 3 in the morning, with zero variance. A human does not browse your site that evenly, a cron job does.
Which IP family is probing? Narrow the filter, then count requests per day:
awk '$1 ~ /^45\.148\.10\./ {print substr($4,2,11)}' access.log \
| sort | uniq -c 1448 01/Nov/2026
1422 02/Nov/2026
1464 03/Nov/2026
1340 04/Nov/2026
1424 05/Nov/2026
1447 06/Nov/2026
771 07/Nov/2026About 1,400 hits a day from a single /24. And when your logs rotate, the same one-liners work with zero changes if you stream them:
zcat access.log.*.gz | awk '{print $1}' | sort | uniq -c | sort -rn | headThe 30 line Python script
awk is perfect for questions you have already answered once. Python is better for the ones you keep asking. This is the script I drop on every new server now: no dependencies, standard library only, it answers the three triage questions in a single pass:
#!/usr/bin/env python3
"""Summarise an nginx access log: hits per IP, per status, and the
paths that drew the most 404s. Usage: ./topoffenders.py access.log"""
import sys
from collections import Counter
from http import HTTPStatus
hits, status, dead = Counter(), Counter(), Counter()
with open(sys.argv[1]) as fh:
for line in fh:
parts = line.split('"')
if len(parts) < 3:
continue
meta = parts[2].split()
ip = line.split(None, 1)[0]
hits[ip] += 1
if len(meta) >= 1:
status[meta[0]] += 1
if len(meta) >= 1 and meta[0] == "404":
dead[parts[1].split()[1]] += 1
print(f"total lines parsed: {sum(hits.values())}")
print(f"unique IPs: {len(hits)}")
print("\ntop 10 talkers")
for ip, n in hits.most_common(10):
print(f"{n:>7} {ip}")
print("\nstatus mix")
for code, n in status.most_common():
phrase = HTTPStatus(int(code)).phrase if code.isdigit() else "?"
print(f"{n:>7} {code} {phrase}")
print("\ntop 404 magnets")
for path, n in dead.most_common(5):
print(f"{n:>7} {path}")Run it against the log and you get the whole health check instantly:
$ python3 topoffenders.py access.log
total lines parsed: 42000
unique IPs: 15
top 10 talkers
3036 203.0.113.1
3033 203.0.113.6
3014 203.0.113.9
status mix
27596 200 OK
6567 404 Not Found
3020 500 Internal Server Error
2989 301 Moved Permanently
1828 403 Forbidden
top 404 magnets
852 /.env
848 /phpmyadmin/
845 /wp-login.phpA debugging note if you extend this: HTTPStatus wants an int, not the string you pulled out of the log, so HTTPStatus(code) throws ValueError: 403 is not a valid HTTPStatus even though 403 is obviously a valid status. It is the type, not the number. I hit this exact wall writing the script and int(code) fixed it.
From findings to action
Once an IP earns its place in your 404 magnets list, the quick kill is a firewall rule. Review the list first, then:
awk '$1 ~ /^45\.148\.10\./ {print $1}' access.log \
| sort -u | xargs -n1 sudo ufw deny
sudo ufw status numberedFor something that keeps itself current, fail2ban with the nginx-botsearch jail does this job on a timer and un-bans later, which is usually what you actually want.
Gotchas
| Trap | Reality check |
|---|---|
| Wrong column numbers | Check log_format in /etc/nginx/nginx.conf before trusting $9 |
| Quotes in URIs | A path containing a quote character shifts the fields, the Python version handles it via the quote-split trick |
| CDN traffic | Behind Cloudflare or similar, $1 is the CDN, log the real client IP from a header instead |
| IPv6 visitors | They contain colons, the hour extraction with -F'[:[' still works, plain cut -d: does not |
People ask why I still grep logs when observability stacks exist. Because a log file is the only monitoring source that cannot lie about its own ingestion lag. Thirty seconds with awk on a box I have never seen before tells me more than an hour of clicking someone else’s dashboard. And one honest footnote: the demo log here is synthetic, 42,000 lines generated with realistic scanner behavior, because a production access log is client data and client data stays out of blog posts. The commands and every output are the same ones I run on real logs, only the IPs in them are from TEST-NET.