
Intro #
IP Traffic Analysis helps you understand where your website traffic comes from and who is accessing your services. Raw web server access logs record the client IP address, request time, requested URL, status code, response size, referrer, and user agent. However, they do not normally include geolocation details such as the visitor’s country, city, coordinates, network owner, or proxy classification.
This tutorial creates one root-owned shell script that will:
- Read one or more active or rotated Apache/Nginx access logs.
- Extract the first field from each conventional combined-format entry.
- Validate IPv4 and IPv6 addresses and remove non-public addresses.
- Count requests per IP before deduplicating the lookup list.
- Split lookups into batches of at most 1,000 addresses.
- Call the IP2Location.io Bulk API with bearer authentication.
- Generate traffic-details.csv and traffic-data.json.
- Generate an HTML dashboard with summary cards and ranked tables.
- Plot geolocated IP addresses on a clustered Leaflet map.
Sending only unique addresses is important. An IP that appears 20,000 times in the log consumes one lookup rather than 20,000 lookups; its request count is reattached after the API response is received.
Privacy warning: The resulting files contain client IP addresses and approximate geolocation information. Do not publish the report openly. A later section shows how to protect it with HTTP basic authentication.
Prerequisites #
You will need:
- A Debian 13 server.
- Root access or an account permitted to use sudo.
- An Apache or Nginx access log.
- An IP2Location.io paid plan that includes the Bulk API.
- Your IP2Location.io API key.
- Outbound HTTPS access to
bulk.ip2location.io.
The Bulk API accepts newline-separated or JSON-encoded IP lists in the POST body, supports up to 1,000 IPv4/IPv6 addresses per request, and permits authentication through either a URL parameter or bearer token. This tutorial uses a bearer token so the key is not placed in the request URL. For more details on handling multiple IP addresses efficiently, see our guide on Batch Processing 101: Handling Bulk IP Lookups Efficiently
1. Install the required packages #
Update Debian’s package index and install the tools used by the script:
sudo apt update
sudo apt install -y curl jq python3
2. Locate the Apache or Nginx access log #
Nginx #
The conventional Nginx access-log path on Debian is /var/log/nginx/access.log.
sudo ls -lh /var/log/nginx/
sudo head -n 5 /var/log/nginx/access.log
Apache #
The conventional Apache access-log path on Debian is /var/log/apache2/access.log.
sudo ls -lh /var/log/apache2/
sudo head -n 5 /var/log/apache2/access.log
In a conventional combined log format, the client address is the first whitespace-delimited field. The script relies on that position.
Reverse proxies and CDNs: If the server is behind Cloudflare, another CDN, a load balancer, or a reverse proxy, the first field may contain the proxy address rather than the visitor address. Configure Apache or Nginx to restore the real client address only from trusted proxy networks. Do not blindly trust a client-supplied X-Forwarded-For header.
3. Create the traffic analysis script #
Create a root-owned script under /usr/local/sbin:
sudo nano /usr/local/sbin/analyze-ip-traffic.sh
Paste the following complete script. Replace YOUR_API_KEY near the beginning with the real key from your IP2Location.io account.
#!/usr/bin/env bash
set -Eeuo pipefail
# Store your IP2Location.io API key here.
API_KEY="YOUR_API_KEY"
# Directory served by Apache or Nginx.
REPORT_DIR="/var/www/html/ip-traffic-report"
# The API returns only the fields used by this report.
API_FIELDS="country_code,country_name,region_name,city_name,latitude,longitude,asn,as,isp,usage_type,is_proxy,proxy.is_vpn,proxy.is_tor,proxy.is_data_center,proxy.is_web_crawler,proxy.is_ai_crawler"
usage() {
cat <<'EOF'
Usage:
analyze-ip-traffic.sh LOG_FILE [LOG_FILE ...]
Examples:
analyze-ip-traffic.sh /var/log/nginx/access.log
analyze-ip-traffic.sh /var/log/apache2/access.log
analyze-ip-traffic.sh /var/log/nginx/access.log.1.gz
EOF
}
fail() {
printf 'Error: %s\n' "$*" >&2
exit 1
}
for command_name in curl jq python3 split gzip; do
command -v "$command_name" >/dev/null 2>&1 || \
fail "$command_name is required."
done
[[ -n "$API_KEY" && "$API_KEY" != "YOUR_API_KEY" ]] || \
fail "Replace YOUR_API_KEY near the top of the script."
(( $# > 0 )) || {
usage
exit 1
}
for log_file in "$@"; do
[[ -r "$log_file" ]] || fail "Cannot read log file: $log_file"
done
umask 022
mkdir -p "$REPORT_DIR"
WORK_DIR="$(mktemp -d)"
trap 'rm -rf "$WORK_DIR"' EXIT
SNAPSHOT="$WORK_DIR/access.log"
ALL_PUBLIC_IPS="$WORK_DIR/all-public-ips.txt"
COUNTS_TSV="$WORK_DIR/ip-counts.tsv"
UNIQUE_IPS="$WORK_DIR/unique-ips.txt"
COUNTS_JSON="$WORK_DIR/ip-counts.json"
GEO_JSON="$WORK_DIR/geolocation.json"
RECORDS_JSON="$WORK_DIR/records.json"
CURL_CONFIG="$WORK_DIR/curl.conf"
REPORT_TMP="$REPORT_DIR/.index.html.tmp.$$"
# Keep the key out of the curl command line and process list.
printf 'header = "Authorization: Bearer %s"\n' "$API_KEY" > "$CURL_CONFIG"
chmod 600 "$CURL_CONFIG"
printf 'Reading %d log file(s)...\n' "$#"
for log_file in "$@"; do
case "$log_file" in
*.gz)
gzip -cd -- "$log_file"
;;
*)
cat -- "$log_file"
;;
esac
# Prevent two files from joining on one line when a file has no final newline.
printf '\n'
done > "$SNAPSHOT"
TOTAL_LOG_LINES="$(awk 'NF { count++ } END { print count + 0 }' "$SNAPSHOT")"
# Apache and Nginx combined logs normally place the client address first.
# Python validates both IPv4 and IPv6 and keeps only globally routable IPs.
awk 'NF { print $1 }' "$SNAPSHOT" |
python3 -c '
import ipaddress
import sys
for line in sys.stdin:
candidate = line.strip().strip("[]").split(",", 1)[0].strip()
try:
address = ipaddress.ip_address(candidate)
except ValueError:
continue
if address.is_global:
print(address.compressed)
' > "$ALL_PUBLIC_IPS"
PUBLIC_REQUESTS="$(wc -l < "$ALL_PUBLIC_IPS" | tr -d ' ')"
(( PUBLIC_REQUESTS > 0 )) || \
fail "No globally routable IPv4 or IPv6 addresses were found."
LC_ALL=C sort "$ALL_PUBLIC_IPS" |
uniq -c |
awk '{ print $2 "\t" $1 }' > "$COUNTS_TSV"
cut -f1 "$COUNTS_TSV" > "$UNIQUE_IPS"
UNIQUE_IP_COUNT="$(wc -l < "$UNIQUE_IPS" | tr -d ' ')"
jq -Rn '
[
inputs
| select(length > 0)
| split("\t")
| { (.[0]): (.[1] | tonumber) }
]
| add
' < "$COUNTS_TSV" > "$COUNTS_JSON"
printf 'Looking up %s unique public IP address(es)...\n' "$UNIQUE_IP_COUNT"
# The Bulk API accepts no more than 1,000 addresses in one request.
split -l 1000 -d -a 4 "$UNIQUE_IPS" "$WORK_DIR/batch_"
response_number=0
for batch_file in "$WORK_DIR"/batch_*; do
[[ -e "$batch_file" ]] || continue
response_number=$((response_number + 1))
response_file="$WORK_DIR/response_${response_number}.json"
batch_size="$(wc -l < "$batch_file" | tr -d ' ')"
printf ' Sending batch %d (%s IPs)...\n' \
"$response_number" \
"$batch_size"
if ! curl \
--config "$CURL_CONFIG" \
--silent \
--show-error \
--fail-with-body \
--retry 3 \
--retry-delay 2 \
--retry-all-errors \
--connect-timeout 10 \
--max-time 120 \
--request POST \
--data-binary @"$batch_file" \
"https://bulk.ip2location.io/?format=json&fields=${API_FIELDS}" \
--output "$response_file"; then
if [[ -s "$response_file" ]]; then
cat "$response_file" >&2
printf '\n' >&2
fi
fail "The Bulk API request failed for batch $response_number."
fi
jq -e 'type == "object"' "$response_file" >/dev/null || \
fail "The API returned invalid JSON for batch $response_number."
if jq -e 'has("error")' "$response_file" >/dev/null; then
jq '.error' "$response_file" >&2
fail "The API returned an error for batch $response_number."
fi
done
# Merge all API response objects.
jq -s '
reduce .[] as $response
({}; . * $response)
' "$WORK_DIR"/response_*.json > "$GEO_JSON"
# Convert the response object to an array and restore request counts.
jq --slurpfile counts "$COUNTS_JSON" '
to_entries
| map(
. as $entry
| $entry.value
+ {
ip: $entry.key,
request_count: ($counts[0][$entry.key] // 0)
}
)
' "$GEO_JSON" > "$RECORDS_JSON"
cp "$RECORDS_JSON" "$REPORT_DIR/traffic-data.json"
# Generate a detailed CSV file.
jq -r '
(
[
"IP address",
"Requests",
"Country code",
"Country",
"Region",
"City",
"Latitude",
"Longitude",
"ASN",
"AS",
"ISP",
"Usage type",
"Is proxy",
"VPN",
"Tor",
"Data center",
"Web crawler",
"AI crawler"
]
| @csv
),
(
.[]
| [
.ip,
.request_count,
(.country_code // ""),
(.country_name // ""),
(.region_name // ""),
(.city_name // ""),
(.latitude // ""),
(.longitude // ""),
(.asn // ""),
(.as // ""),
(.isp // ""),
(.usage_type // ""),
(.is_proxy // false),
(.proxy.is_vpn // false),
(.proxy.is_tor // false),
(.proxy.is_data_center // false),
(.proxy.is_web_crawler // false),
(.proxy.is_ai_crawler // false)
]
| @csv
)
' "$RECORDS_JSON" > "$REPORT_DIR/traffic-details.csv"
COUNTRY_COUNT="$(
jq '[.[].country_code // empty] | unique | length' "$RECORDS_JSON"
)"
MAPPED_IP_COUNT="$(
jq '[.[] | select((.latitude | type) == "number" and (.longitude | type) == "number")] | length' \
"$RECORDS_JSON"
)"
PROXY_REQUESTS="$(
jq '[.[] | select(.is_proxy == true) | .request_count] | add // 0' \
"$RECORDS_JSON"
)"
GENERATED_AT="$(date --iso-8601=seconds)"
TOP_COUNTRIES_ROWS="$(
jq -r '
group_by(.country_code // "")
| map({
country: (.[0].country_name // "Unknown"),
code: (.[0].country_code // "--"),
requests: (map(.request_count) | add),
unique_ips: length
})
| sort_by(-.requests)
| .[:15][]
| "<tr>"
+ "<td>\((.country) | @html)</td>"
+ "<td>\((.code) | @html)</td>"
+ "<td>\(.requests)</td>"
+ "<td>\(.unique_ips)</td>"
+ "</tr>"
' "$RECORDS_JSON"
)"
TOP_CITIES_ROWS="$(
jq -r '
group_by([
.country_code // "",
.region_name // "",
.city_name // ""
])
| map({
location: (
[
.[0].city_name,
.[0].region_name,
.[0].country_name
]
| map(select(. != null and . != ""))
| join(", ")
),
requests: (map(.request_count) | add),
unique_ips: length
})
| sort_by(-.requests)
| .[:15][]
| "<tr>"
+ "<td>\(((if .location == "" then "Unknown" else .location end)) | @html)</td>"
+ "<td>\(.requests)</td>"
+ "<td>\(.unique_ips)</td>"
+ "</tr>"
' "$RECORDS_JSON"
)"
TOP_IP_ROWS="$(
jq -r '
sort_by(-.request_count)
| .[:20][]
| "<tr>"
+ "<td><code>\((.ip) | @html)</code></td>"
+ "<td>\(.request_count)</td>"
+ "<td>\((.country_name // "Unknown") | @html)</td>"
+ "<td>\((.city_name // "") | @html)</td>"
+ "<td>\((.isp // "") | @html)</td>"
+ "</tr>"
' "$RECORDS_JSON"
)"
cat > "$REPORT_TMP" <<HTML
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>IP Traffic Analysis Report</title>
<link
rel="stylesheet"
href="https://unpkg.com/[email protected]/dist/leaflet.css">
<link
rel="stylesheet"
href="https://unpkg.com/[email protected]/dist/MarkerCluster.css">
<link
rel="stylesheet"
href="https://unpkg.com/[email protected]/dist/MarkerCluster.Default.css">
<style>
:root {
color-scheme: light;
--background: #f4f7fb;
--card: #ffffff;
--text: #172033;
--muted: #64748b;
--line: #dbe3ee;
--accent: #2563eb;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--background);
color: var(--text);
}
main {
width: min(1400px, 94vw);
margin: 32px auto 56px;
}
h1 {
margin-bottom: 6px;
}
h2 {
margin-top: 0;
}
.muted {
color: var(--muted);
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 16px;
margin: 24px 0;
}
.card,
section {
background: var(--card);
border: 1px solid var(--line);
border-radius: 14px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.05);
}
.card {
padding: 18px;
}
.card strong {
display: block;
margin-top: 5px;
font-size: 1.8rem;
}
section {
padding: 20px;
margin-top: 20px;
}
.tables {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
gap: 20px;
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 10px 12px;
border-bottom: 1px solid var(--line);
text-align: left;
white-space: nowrap;
}
th {
background: #f8fafc;
}
#map {
height: 620px;
border-radius: 10px;
}
.downloads a {
display: inline-block;
margin-right: 14px;
color: var(--accent);
font-weight: 650;
text-decoration: none;
}
code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
</style>
</head>
<body>
<main>
<h1>IP Traffic Analysis Report</h1>
<div class="muted">
Generated ${GENERATED_AT} from ${TOTAL_LOG_LINES} non-empty access-log entries.
</div>
<div class="cards">
<div class="card">
<span class="muted">Log entries</span>
<strong>${TOTAL_LOG_LINES}</strong>
</div>
<div class="card">
<span class="muted">Public-IP requests</span>
<strong>${PUBLIC_REQUESTS}</strong>
</div>
<div class="card">
<span class="muted">Unique public IPs</span>
<strong>${UNIQUE_IP_COUNT}</strong>
</div>
<div class="card">
<span class="muted">Countries</span>
<strong>${COUNTRY_COUNT}</strong>
</div>
<div class="card">
<span class="muted">Mapped IPs</span>
<strong>${MAPPED_IP_COUNT}</strong>
</div>
<div class="card">
<span class="muted">Proxy requests</span>
<strong>${PROXY_REQUESTS}</strong>
</div>
</div>
<section>
<h2>Traffic map</h2>
<p class="muted">
Each marker represents one unique public IP address. Nearby markers are clustered.
</p>
<div id="map"></div>
</section>
<div class="tables">
<section>
<h2>Top countries</h2>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Country</th>
<th>Code</th>
<th>Requests</th>
<th>Unique IPs</th>
</tr>
</thead>
<tbody>
${TOP_COUNTRIES_ROWS}
</tbody>
</table>
</div>
</section>
<section>
<h2>Top cities</h2>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Location</th>
<th>Requests</th>
<th>Unique IPs</th>
</tr>
</thead>
<tbody>
${TOP_CITIES_ROWS}
</tbody>
</table>
</div>
</section>
</div>
<section>
<h2>Top IP addresses</h2>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>IP address</th>
<th>Requests</th>
<th>Country</th>
<th>City</th>
<th>ISP</th>
</tr>
</thead>
<tbody>
${TOP_IP_ROWS}
</tbody>
</table>
</div>
</section>
<section class="downloads">
<h2>Downloads</h2>
<a href="traffic-details.csv">Detailed CSV</a>
<a href="traffic-data.json">Detailed JSON</a>
</section>
</main>
<script src="https://unpkg.com/[email protected]/dist/leaflet.js"></script>
<script src="https://unpkg.com/[email protected]/dist/leaflet.markercluster.js"></script>
<script>
const map = L.map('map').setView([20, 0], 2);
L.tileLayer(
'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
{
maxZoom: 18,
attribution: '© OpenStreetMap contributors'
}
).addTo(map);
const markers = L.markerClusterGroup();
const bounds = [];
function escapeHtml(value) {
return String(value ?? '').replace(
/[&<>'"]/g,
function (character) {
return {
'&': '&',
'<': '<',
'>': '>',
"'": ''',
'"': '"'
}[character];
}
);
}
fetch('traffic-data.json')
.then(function (response) {
if (!response.ok) {
throw new Error('Unable to load traffic-data.json');
}
return response.json();
})
.then(function (trafficData) {
trafficData.forEach(function (row) {
if (
typeof row.latitude !== 'number' ||
typeof row.longitude !== 'number'
) {
return;
}
const location = [
row.city_name,
row.region_name,
row.country_name
].filter(Boolean).join(', ');
const proxyText = row.is_proxy === true ? 'Yes' : 'No';
const popup =
'<strong>' + escapeHtml(row.ip) + '</strong><br>' +
'Requests: ' + Number(row.request_count || 0) + '<br>' +
'Location: ' + escapeHtml(location || 'Unknown') + '<br>' +
'ISP: ' + escapeHtml(row.isp || '') + '<br>' +
'Proxy: ' + proxyText;
const marker = L.marker([
row.latitude,
row.longitude
]).bindPopup(popup);
markers.addLayer(marker);
bounds.push([row.latitude, row.longitude]);
});
map.addLayer(markers);
if (bounds.length > 0) {
map.fitBounds(bounds, {
padding: [25, 25],
maxZoom: 8
});
}
})
.catch(function (error) {
document.getElementById('map').innerHTML =
'<p style="padding: 20px">Map error: ' +
escapeHtml(error.message) +
'</p>';
});
</script>
</body>
</html>
HTML
mv -f "$REPORT_TMP" "$REPORT_DIR/index.html"
chmod 0644 \
"$REPORT_DIR/index.html" \
"$REPORT_DIR/traffic-details.csv" \
"$REPORT_DIR/traffic-data.json"
printf '\nReport created successfully:\n'
printf ' HTML: %s/index.html\n' "$REPORT_DIR"
printf ' CSV: %s/traffic-details.csv\n' "$REPORT_DIR"
printf ' JSON: %s/traffic-data.json\n' "$REPORT_DIR"
What the script produces #
index.html – Summary cards, ranked tables, and the interactive map.
traffic-details.csv – Spreadsheet-friendly detail for every looked-up IP.
traffic-data.json – Machine-readable enriched records used by the map.
Why the curl configuration file is used #
The API key is stored inside the shell script, but it is not passed directly as a visible curl command-line argument. At runtime, the script writes the bearer header into a temporary root-only curl configuration file under the private mktemp directory. Curl receives the configuration path, while the key itself stays out of the ordinary process listing.
4. Protect the hardcoded API key #
Make the script executable, root-owned, and readable only by root:
sudo chmod 700 /usr/local/sbin/analyze-ip-traffic.sh
sudo chown root:root /usr/local/sbin/analyze-ip-traffic.sh
sudo ls -l /usr/local/sbin/analyze-ip-traffic.sh

5. Test the Bulk API before processing a log #
Test the API with two public IP addresses. Replace YOUR_API_KEY in this one-time command:
printf '1.1.1.1
8.8.8.8
' |
curl \
--silent \
--show-error \
--fail-with-body \
--request POST \
--header "Authorization: Bearer YOUR_API_KEY" \
--data-binary @- \
"https://bulk.ip2location.io/?format=json&fields=country_code,country_name,region_name,city_name,latitude,longitude" |
jq
A successful request returns a JSON object keyed by IP address. Exact locations may change over time as network allocations and geolocation data are updated.

6. Run the analysis #
Nginx #
sudo /usr/local/sbin/analyze-ip-traffic.sh \
 /var/log/nginx/access.log
Apache #
sudo /usr/local/sbin/analyze-ip-traffic.sh \
 /var/log/apache2/access.log
A successful run prints outputs similar to:
Reading 1 log file(s)...
Looking up 44 unique public IP address(es)...
Sending batch 1 (44 IPs)...
Report created successfully:
HTML: /var/www/html/ip-traffic-report/index.html
CSV: /var/www/html/ip-traffic-report/traffic-details.csv
JSON: /var/www/html/ip-traffic-report/traffic-data.json

7. Open the HTML report #
When the web server uses /var/www/html as its document root, open:
http://YOUR_SERVER_IP/ip-traffic-report/
Or, on a configured HTTPS virtual host:
https://example.com/ip-traffic-report
The IP Traffic Analysis report provides a complete overview of your web server traffic by enriching access logs with IP geolocation intelligence. It includes:
- Total non-empty log entries processed.
- Requests containing globally routable IP addresses.
- Unique public IP addresses.
- Number of represented countries.
- Number of IP records with map coordinates.
- Total requests from addresses classified as proxies.
- Top countries, cities, and individual IP addresses.
- A clustered interactive map with request, location, ISP, and proxy details.
The browser needs access to the Leaflet/marker-cluster CDN and the OpenStreetMap tile service. The report itself does not require PHP, Node.js, a database, or an application server.
Does report generation require root? #
HTML generation itself does not require root. Root is used because web server logs are normally restricted and an ordinary user usually cannot write into /var/www/html. To run as a regular user, change REPORT_DIR to a user-owned location and analyze a readable copy of the log.

8. Inspect and query the generated data #
Preview the CSV #
sudo head -n 10 \
 /var/www/html/ip-traffic-report/traffic-details.csv
Show the ten busiest IP addresses #
sudo jq '
sort_by(-.request_count)
| .[:10]
| .[]
| {
ip,
requests: .request_count,
country: .country_name,
city: .city_name,
isp
}
' /var/www/html/ip-traffic-report/traffic-data.json
Summarize requests by country #
sudo jq '
group_by(.country_code // "")
| map({
country: (.[0].country_name // "Unknown"),
requests: (map(.request_count) | add),
unique_ips: length
})
| sort_by(-.requests)
' /var/www/html/ip-traffic-report/traffic-data.json
List detected proxy addresses #
sudo jq '
.[]
| select(.is_proxy == true)
| {
ip,
requests: .request_count,
country: .country_name,
isp,
vpn: .proxy.is_vpn,
tor: .proxy.is_tor,
data_center: .proxy.is_data_center
}
' /var/www/html/ip-traffic-report/traffic-data.json
9. Regenerate the report with cron #
Open root’s crontab:
sudo crontab -e
For Nginx, run the report at 15 minutes past every hour:
15 * * * * /usr/local/sbin/analyze-ip-traffic.sh /var/log/nginx/access.log /var/log/nginx/access.log.1 >> /var/log/ip-traffic-analysis.log 2>&1
For Apache:
15 * * * * /usr/local/sbin/analyze-ip-traffic.sh /var/log/apache2/access.log /var/log/apache2/access.log.1 >> /var/log/ip-traffic-analysis.log 2>&1
Review cron output with:
sudo tail -n 100 /var/log/ip-traffic-analysis.log
The example includes the active log and the immediately previous rotated log. This avoids losing all earlier traffic immediately after rotation while keeping the lookup window reasonably small.
10. Restrict access to the report #
The report contains personal and operational information. Protect it before making the URL reachable from the public internet.
Apache basic authentication #
sudo apt install -y apache2-utils
sudo htpasswd -c /etc/apache2/ip-report-passwords admin
Add this to the relevant Apache virtual host:
<Directory "/var/www/html/ip-traffic-report">
AuthType Basic
AuthName "IP Traffic Report"
AuthUserFile /etc/apache2/ip-report-passwords
Require valid-user
</Directory>
Test and reload Apache:
sudo apachectl configtest
sudo systemctl reload apache2
Nginx basic authentication #
sudo apt install -y apache2-utils
sudo htpasswd -c /etc/nginx/ip-report-passwords admin
Add this location to the relevant Nginx server block:
location /ip-traffic-report/ {
auth_basic "IP Traffic Report";
auth_basic_user_file /etc/nginx/ip-report-passwords;
}
Test and reload Nginx:
sudo nginx -t
sudo systemctl reload nginx
11. Troubleshooting #
The script says the API key is missing #
Open the script and replace the placeholder assigned to YOUR_API_KEY. Keep the surrounding double quotes.
The API returns error 401 #
Error 401 means the key is invalid or the account has insufficient query allowance. Confirm that the key is correct, the account has a paid plan with Bulk API access, and sufficient queries remain.
No globally routable addresses were found #
Inspect the first field of the log.
Nginx check #
sudo awk 'NF { print $1 }' /var/log/nginx/access.log | head
Apache check #
sudo awk 'NF { print $1 }' /var/log/apache2/access.log | head
Common causes of unexpected results during IP Traffic Analysis include:
- The file is an error log rather than an access log.
- The server uses a custom access-log format.
- The real client IP is not the first field.
- Only private reverse-proxy addresses are being logged.
- The log is empty.
The report shows CDN or load-balancer locations #
Configure the web server’s trusted real-IP module before analysis. The shell script should consume the corrected server log rather than parse an untrusted forwarding header by itself.
The map is blank #
Open the browser developer console and verify that Leaflet, the cluster plugin, OpenStreetMap tiles, and traffic-data.json are not being blocked. Also confirm that at least some JSON records contain numeric latitude and longitude values.
The HTML report is too large #
For large-scale IP Traffic Analysis, consider analyzing a shorter time range or fewer rotated log files. The report separates enriched data into a JSON file, but a map containing a very large number of unique IP addresses may still impact browser performance.
Conclusion #
You now have a Debian 13 traffic analysis workflow that uses shell tools and the IP2Location.io Bulk API. It validates and counts Apache or Nginx client addresses, sends only unique public IPs in batches of no more than 1,000, restores request counts after enrichment, and writes a static HTML dashboard with a clustered geolocation map.
The generated output can be used to investigate:
- Countries and cities generating the most requests.
- The busiest individual client addresses.
- ISPs, hosting providers, autonomous systems, and usage types.
- VPNs, Tor exits, data centers, search-engine crawlers, and AI crawlers.
- Changes in geographic traffic patterns over time.
