You open three different tools and get three completely different numbers: the server log says 40,000, the analytics script says 9,000 and the Cloudflare dashboard shows a third figure. None of them is broken; each one counts something different. This guide covers website analytics without Google Analytics: how to measure visitor stats properly on your own server and, more importantly, how to work out which number to believe.
Four numbers everyone mixes up: hits, page views, sessions and unique visitors
Before running a single command we need to agree on the terms, because most "my stats are wrong" arguments start right here:
- Hit (request) — every HTTP request that reaches the server. A typical WordPress page generates 30 to 80 requests on its own: the HTML, a few CSS and JS files, fonts, images, the favicon. This is the biggest and least meaningful number in your stats.
- Page view — only the requests that returned an HTML document. This is the number you mean when you ask "how many visits did I get today?"
- Session — a run of consecutive page views by one person. The industry convention: if more than 30 minutes pass between two requests, a new session has started. The number is a convention, not a law of nature.
- Unique visitor — the number of distinct people. It is the only number you really want, and precisely the one no tool can give you exactly; there are only good estimates and bad ones.
Now it is clear why different sources never confirm each other:
| Stats source | What it counts | What it misses |
|---|---|---|
| Web server log | Every request that reached the origin server — humans, bots, scanners, all of them | Visits answered from the CDN cache that never reached the server at all |
| JS script (analytics) | Only real browsers that executed the JavaScript | Visitors with ad blockers, people who closed the page before the script ran, and bots (an advantage here) |
| Cloudflare dashboard | Every request at the edge, including cached static files | It separates humans from bots with an estimation model, not with certainty |
The real ratios you see on small Persian-language sites: hits usually run at 10 to 20 times the page views, and a JavaScript analytics tool reports roughly 20–40% less than the bot-cleaned log. If you see gaps like these, everything is healthy.
timedatectl set-timezone Asia/TehranWebsite analytics without Google Analytics starts with the raw server log
Before installing any dashboard, spend half an hour with the raw log; no tool gives you that unfiltered picture of your real traffic. The log path depends on the web server:
/var/log/nginx/access.log # Nginx
/var/log/apache2/access.log # Apache (Debian/Ubuntu)
/var/log/httpd/access_log # Apache (AlmaLinux/Rocky)
~/access-logs/example.com # cPanel / LiteSpeed
The standard combined format, which is enabled almost everywhere, writes each line like this:
203.0.113.9 - - [30/Jul/2026:14:02:11 +0330] "GET /blog/vps HTTP/2.0" 200 18432 "https://www.google.com/" "Mozilla/5.0 (Linux; Android 14) ..."
So for awk: field 1 is the IP, field 4 the time, field 7 the path, field 9 the status code, field 10 the response size, field 11 the referrer, and field 12 onward the user agent. (If you are not comfortable with awk and sort, keep our Linux commands cheat sheet close at hand.)
Page views per day
The main trick is to set aside static files and failed responses; what remains is page views:
awk '$9 ~ /^(200|304)$/ && $7 !~ /\.(css|js|png|jpe?g|gif|svg|webp|ico|woff2?|map|xml|txt)($|\?)/ {
split($4, d, ":"); gsub(/\[/, "", d[1]); print d[1]
}' /var/log/nginx/access.log | sort | uniq -c
3184 28/Jul/2026
2971 29/Jul/2026
3402 30/Jul/2026
Most-visited pages
awk '$9 == 200 && $7 !~ /\.(css|js|png|jpe?g|gif|svg|webp|ico|woff2?)/ {print $7}' \
/var/log/nginx/access.log | sed 's/?.*//' | sort | uniq -c | sort -rn | head -20
Stripping the query string with sed matters; otherwise /blog?utm_source=telegram and /blog are counted as two separate pages and your stats end up fragmented.
Hourly traffic chart
awk '{split($4, d, ":"); print d[2]":00"}' /var/log/nginx/access.log \
| sort | uniq -c | awk '{printf "%s %5d %s\n", $2, $1, substr("########################################", 1, $1/50)}'
The output is a text histogram that tells you within seconds when your site's traffic peaks, which is exactly the number that decides at what hour to schedule backups and heavy cron jobs.
Top IPs and unique counts
awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -20
awk '{print $1}' /var/log/nginx/access.log | sort -u | wc -l
Look closely at the output of the first command: if your site is behind Cloudflare, this list is practically meaningless, and the next section is about exactly that.
Estimating sessions with the 30-minute rule
This is what every analytics tool does behind the scenes. Note that it needs gawk, because mawk (Ubuntu's default) has no mktime function:
sudo apt install gawk
gawk -v gap=1800 '
BEGIN { split("Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec", m, " ")
for (i = 1; i <= 12; i++) mon[m[i]] = i }
{ t = $4; sub(/^\[/, "", t); split(t, p, /[\/:]/)
ts = mktime(p[3]" "mon[p[2]]" "p[1]" "p[4]" "p[5]" "p[6])
if (ts - last[$1] > gap) s++
last[$1] = ts }
END { print "approx sessions:", s }' /var/log/nginx/access.log
The word approx in the output isn't false modesty: this estimate treats an IP as a person, and in Iran that is the biggest mistake you can make. The next two sections are about exactly that.
The main trap: behind Cloudflare, all your visitors look like the same few people
This is the most common visitor-stats mistake on Iranian sites, and its symptom is unmistakable: you pull the list of top IPs and find five or six addresses with tens of thousands of requests each, and the rest of the list is empty. You conclude that your site is under attack. It isn't.
When a site sits behind Cloudflare, or any other content delivery network, or any reverse proxy for that matter, the browser connects to an edge server and Cloudflare connects to yours. The connection your server sees therefore comes from Cloudflare, and REMOTE_ADDR, the first column of the log, is the edge server's IP, not the visitor's.
The real visitor IP isn't lost; Cloudflare puts it in an HTTP header:
CF-Connecting-IP: 5.160.x.x # Cloudflare only
X-Forwarded-For: 5.160.x.x, 172.68.x.x # general standard, a chain of addresses
curl -H "CF-Connecting-IP: 8.8.8.8" records any IP they like in your logs and stats, and if that same header drives rate limiting or user bans, it becomes a real vulnerability.The right fix is one simple condition: first check that the connection came from inside Cloudflare's published ranges, and only then read the header. If the connection came from anywhere else, discard the header.
Nginx — with the real_ip module
This module is compiled into Nginx by default. Create a config file and pull Cloudflare's official list straight from Cloudflare:
curl -s https://www.cloudflare.com/ips-v4 | sed 's|^|set_real_ip_from |; s|$|;|' > /etc/nginx/conf.d/cloudflare.conf
curl -s https://www.cloudflare.com/ips-v6 | sed 's|^|set_real_ip_from |; s|$|;|' >> /etc/nginx/conf.d/cloudflare.conf
echo 'real_ip_header CF-Connecting-IP;' >> /etc/nginx/conf.d/cloudflare.conf
echo 'real_ip_recursive on;' >> /etc/nginx/conf.d/cloudflare.conf
nginx -t && systemctl reload nginx
From then on, $remote_addr everywhere in Nginx (the log, limit_req, access rules) is automatically the visitor's real IP, and you don't need to change anything else. Rebuild this file once a month, with a cron job for example, because Cloudflare adds ranges from time to time. If you've only just set up Nginx, our guide to setting up Nginx on Ubuntu covers the rest of the basic configuration.
Apache — with mod_remoteip
sudo a2enmod remoteip
# /etc/apache2/conf-available/remoteip.conf
RemoteIPHeader CF-Connecting-IP
RemoteIPTrustedProxy 173.245.48.0/20
RemoteIPTrustedProxy 103.21.244.0/22
# ... the rest of the ranges from cloudflare.com/ips-v4
# and this line is critical: change %h to %a
LogFormat "%a %l %u %t \"%r\" %>s %O \"%{Referer}i\" \"%{User-Agent}i\"" combined
If you don't change %h to %a, the module works correctly but the log still records the proxy's IP, and you spend hours hunting for the bug.
On LiteSpeed and OpenLiteSpeed, you do the same thing in the server settings with the "Use Client IP in Header" option; make sure you choose Trusted IP Only, not Yes. The difference between the two is exactly the difference between safe and unsafe.
The PHP version — when you can't touch the web server config
<?php
// The CDN header is meaningful ONLY when the TCP peer is the CDN itself.
// Anyone able to reach the origin directly can forge CF-Connecting-IP.
function ip_in_cidr(string $ip, string $cidr): bool
{
$parts = explode('/', $cidr);
$bits = isset($parts[1]) ? (int) $parts[1] : 32;
$ipL = ip2long($ip);
$netL = ip2long($parts[0]);
if ($ipL === false || $netL === false) {
return false; // IPv6 needs inet_pton() — handled separately
}
$mask = $bits === 0 ? 0 : (-1 << (32 - $bits));
return ($ipL & $mask) === ($netL & $mask);
}
function client_ip(): string
{
$peer = $_SERVER['REMOTE_ADDR'] ?? '';
$ranges = file(__DIR__ . '/cloudflare-ips-v4.txt', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($ranges as $cidr) {
if (ip_in_cidr($peer, $cidr)) {
$fwd = $_SERVER['HTTP_CF_CONNECTING_IP'] ?? '';
return filter_var($fwd, FILTER_VALIDATE_IP) ? $fwd : $peer;
}
}
return $peer; // not from the CDN → the header is untrusted, ignore it
}
One complementary step that many people forget: configure the firewall so that ports 80 and 443 are open only to Cloudflare's ranges. Forging the header then becomes practically impossible, because nobody can reach the origin directly. How to do it with ufw is covered in our Linux server security checklist.
Why counting by IP fails in Iran: the mobile CGNAT problem
Suppose you've done everything above and now have your visitors' real IPs. The bad news: in Iran that IP still isn't a good way to count people, for two opposite reasons.
One: an IP merges many people into one. Iran's mobile carriers, MCI (Hamrah-e Aval), Irancell and RighTel, use CGNAT (carrier-grade NAT), which means thousands of subscribers reach the internet from behind a single shared public IP. If 500 people visited your site today on MCI's mobile network, you may see only a few dozen distinct IPs in the log. Counting unique IPs shrinks those 500 people to a few dozen.
Two: the same IP logic turns one person into many. When a phone moves between cell towers, switches from Wi-Fi to mobile data or simply sits idle for a few minutes, its public IP changes. A user who spent ten minutes on your site can easily leave three different IPs in the log and be counted as three "unique visitors".
On top of all this, add the widespread use of VPNs in Iran, a side effect of the country's internet filtering: IP-based country and city stats on Persian-language sites usually show your visitors in Germany, the Netherlands and Turkey. Don't make marketing decisions based on those numbers. (For a more accurate picture of how Iran's network really behaves, see our article on reducing ping and routing to Iran.)
The practical conclusion: a list of IPs answers "who sent us requests", not "how many people came". It is excellent for security, rate limiting and finding scanners; for counting people it is useless.
The right signal of identity is a first-party cookie, which survives CGNAT, tower handovers and VPNs alike:
<?php
// A first-party cookie survives CGNAT, tower handover and VPN switching —
// none of which an IP-based counter can tell apart.
if (empty($_COOKIE['vid'])) {
setcookie('vid', bin2hex(random_bytes(8)), [
'expires' => time() + 400 * 86400, // Chrome caps cookie lifetime at 400 days
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);
}
If you don't want cookies at all, Plausible's method is a good alternative: build a daily ID from a hash of daily salt + IP + user agent + domain and throw the salt away every 24 hours. You can then count today's unique visitors, but you can't follow anyone from one day to the next, which is why these tools never give you a true monthly unique-visitor figure.
Self-hosted web analytics tools: GoAccess vs Umami, Plausible and Matomo, compared honestly
| Tool | How it collects data | Database | Against ad blockers | Approx. RAM use | Minimum server |
|---|---|---|---|---|---|
| GoAccess | Reads the server log | None | Fully immune — it has no JavaScript at all | 30–80 MB | 1 GB |
| Umami | ~2 KB script | PostgreSQL or MySQL | Fairly good (if served from your own domain) | ~150 MB Node + ~200 MB database | 2 GB |
| Plausible | ~1 KB script | PostgreSQL + ClickHouse | Fairly good | 1 to 1.5 GB (ClickHouse is heavy) | 4 GB |
| Matomo | Script + optional log import | MySQL / MariaDB | Weak — a direct target of ad-block lists | ~300 MB PHP + a growing database | 2 GB + an archiving cron job |
The real choice looks like this: if you want complete numbers with nothing slipping through, GoAccess misses nothing, because it reads the log. If you want to know what users actually did (where they came from, how many pages they viewed, where they left), you need a JavaScript tool, and Umami is the lightest option. Matomo is the most complete and the heaviest, and its best feature is the log import (misc/log-analytics/import_logs.py). Only choose Plausible if you have 4 GB of RAM to spare; ClickHouse won't even start on a 1 GB server.
Setting up GoAccess in two minutes
sudo apt install goaccess
# one-off report as a single HTML file
goaccess /var/log/nginx/access.log \
--log-format=COMBINED \
--ignore-crawlers \
--anonymize-ip \
-o /var/www/html/stats/index.html
# live dashboard that updates itself with every new request
goaccess /var/log/nginx/access.log \
--log-format=COMBINED --ignore-crawlers --real-time-html \
--ws-url=wss://example.com:7890 \
--persist --restore --db-path=/var/lib/goaccess \
-o /var/www/html/stats/index.html &
Three switches matter: --ignore-crawlers sets known bots aside, --anonymize-ip zeroes the last part of each IP, and --persist together with --restore keeps your stats from disappearing after a log rotation or a restart.
stats/index.html file exposes the full list of your site's paths, referrers and IPs. Always put it behind authentication: htpasswd -c /etc/nginx/.htpasswd admin, then a location /stats { auth_basic "stats"; auth_basic_user_file /etc/nginx/.htpasswd; } block.JavaScript-based tools are best run with Docker, so their dependencies don't get tangled up with the system; the method is in our Docker on a cloud server tutorial.
Bot traffic in analytics: how much of your stats isn't human at all
On an average Persian-language site, 40 to 60% of raw requests come from bots; on a newly launched site that figure easily passes 80%. The mix has changed, too: alongside Googlebot and Bingbot, AI crawlers (GPTBot, ClaudeBot, PerplexityBot, Bytespider), SEO tools (AhrefsBot, SemrushBot) and security scanners now account for a large share.
To measure the bot share on your own log:
LOG=/var/log/nginx/access.log
BOTS='bot|crawl|spider|slurp|facebookexternalhit|headless|python-requests|curl/|wget|ahrefs|semrush|mj12|dotbot|petalbot|bytespider|censys|zgrab'
total=$(wc -l < $LOG); bots=$(grep -icE "$BOTS" $LOG)
echo "bots: $bots / $total = $(( 100 * bots / total ))%"
To drop them from any analysis, simply run the log through grep -viE "$BOTS" before the commands from the second section.
Filtering by user agent only catches the honest bots, though. Malicious bots pass themselves off as Chrome. For those, look at behavioral signs, such as this one-liner that finds IPs which fetched dozens of pages without requesting a single CSS or JS file (a real browser never behaves like that):
awk '$7 ~ /\.(css|js)($|\?)/ {asset[$1] = 1}
{seen[$1]++}
END {for (i in seen) if (!(i in asset) && seen[i] > 20) print seen[i], i}' \
/var/log/nginx/access.log | sort -rn | head
Googlebot means saying goodbye to SEO, so only leave it out of your reports. Don't overdo it in the other direction either: filtering out every request without a referrer also throws away people who type your address directly and users of privacy-focused browsers.Three more sources of polluted stats that everyone forgets: your own IP and your colleagues', the uptime monitor that hits your homepage every minute, and the site's own cron jobs that call it with curl. Set all three aside with a simple grep -v.
Data retention: a short window is a privacy control in itself
Your access log is not just a text file; the combination of IP address, exact time and browser details is effectively personal data, and it can be used to reconstruct what individual people did. A simple, practical recommendation: don't keep raw logs for more than two days, and store aggregated numbers permanently instead. "3,402 page views on July 30" can be kept for years and points to no one.
# /etc/logrotate.d/nginx
/var/log/nginx/*.log {
daily
rotate 2
compress
delaycompress
missingok
notifempty
create 0640 www-data adm
}
And if you want the IP stored truncated from the moment it is written, in Nginx:
map $remote_addr $ip_anon {
~^(?<a>\d+\.\d+\.\d+)\.\d+$ "$a.0";
default "0.0.0.0";
}
log_format privacy '$ip_anon - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" "$http_user_agent"';
access_log /var/log/nginx/access.log privacy;
Why is a short window a genuine security control rather than a gesture? Because the risk of a leak grows in direct proportion to how much history you keep. If someone gains access to your server tomorrow, "two days of logs" versus "two years of logs" is the difference between a minor incident and a disaster. Data you don't have can't leak, and you don't have to protect it. Unrotated logs are also one of the most common reasons disks fill up on small servers, a topic we cover in our Linux server monitoring guide.
Next step
The order of work is clear. If you're behind a CDN, fix the real IP first. Then spend an evening reading the raw log with awk to get a feel for the real shape of your traffic. After that, set up GoAccess for ongoing reports, and turn to Umami only if you need behavioral analysis. Set logrotate to two days right at the start, too.
If you want to practice all of this without risk (put Cloudflare in front of a test domain, forge the header and watch the correct configuration stop it), create an hourly cloud server and pay only for those few hours.
Frequently asked questions
How can I see my website's visitor stats without Google Analytics?
Read the access log your web server already writes, with nothing to install. On Nginx it is usually /var/log/nginx/access.log, and on cPanel hosting it is the access-logs folder in your home directory. One awk command counts page views per day, and for an ongoing report GoAccess turns the same log into an HTML page within seconds. No script is added to your site and no data leaves your server.
Why are my Google Analytics numbers different from my server stats?
Because they count different things. The server log records every HTTP request, including bots, CSS and JS files and images, while Google Analytics only sees real browsers that ran its JavaScript. Visitors with ad blockers, and people who leave before the script loads, never appear in JavaScript-based stats. A 20–40% gap between the two is normal; a gap of several times means you are comparing hits with page views, not one tool with another.
Why do a few IPs have thousands of visits in my site stats?
Almost always because your site is behind Cloudflare or another reverse proxy, and those IPs belong to its edge servers, not your visitors. The real IP arrives in the CF-Connecting-IP header and goes back into the log through Nginx's real_ip module or Apache's mod_remoteip, provided it is trusted only for connections from Cloudflare's published ranges; otherwise anyone can forge it. If one IP still has thousands of requests after that, it really is a bot or scanner.
How can I tell whether my site's visits come from real people or bots?
Run two tests on the raw log. First, the user agent: patterns such as bot, crawl, spider, python-requests and curl/ catch the honest bots, typically 40–60% of raw requests on a Persian-language site. Second, behavior: an IP that fetched dozens of pages but not one CSS or JS file is not a real browser. Also exclude your own IP, your uptime monitor and the site's own cron jobs. Remove search engine bots from your reports, not from your server.
Can I count unique visitors without cookies?
Yes, but only within a single day and only as an estimate. Build the visitor ID from a hash of a daily salt, the IP, the user agent and the domain, and discard the salt every 24 hours. Because the salt changes, nobody can be followed across days, so this method never gives a true monthly unique count. In Iran the error is larger still: mobile carrier CGNAT puts thousands of subscribers behind one IP, and tower handovers make one user look like several.