Analytics

Website analytics without Google Analytics: visitor stats from server logs and the visitor's real IP

A web server log analytics dashboard: hourly traffic bars, the most-visited pages, the share of requests from bots, and the visitor's real IP recovered from behind a CDN

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 sourceWhat it countsWhat it misses
Web server logEvery request that reached the origin server — humans, bots, scanners, all of themVisits answered from the CDN cache that never reached the server at all
JS script (analytics)Only real browsers that executed the JavaScriptVisitors with ad blockers, people who closed the page before the script ran, and bots (an advantage here)
Cloudflare dashboardEvery request at the edge, including cached static filesIt 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.

💡 Tip: Before comparing two tools, put them on the same time zone. Servers in Europe run on UTC by default, which is 3.5 hours behind Tehran, and that alone turns "yesterday's stats" into two different numbers in two tools. To put a server on Tehran time: timedatectl set-timezone Asia/Tehran

Website 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
⚠️ And now the most important sentence in this article: never trust these headers blindly. An HTTP header is just text that the sender writes. If someone finds your server's real IP and can connect to it directly, a single 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

ToolHow it collects dataDatabaseAgainst ad blockersApprox. RAM useMinimum server
GoAccessReads the server logNoneFully immune — it has no JavaScript at all30–80 MB1 GB
Umami~2 KB scriptPostgreSQL or MySQLFairly good (if served from your own domain)~150 MB Node + ~200 MB database2 GB
Plausible~1 KB scriptPostgreSQL + ClickHouseFairly good1 to 1.5 GB (ClickHouse is heavy)4 GB
MatomoScript + optional log importMySQL / MariaDBWeak — a direct target of ad-block lists~300 MB PHP + a growing database2 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.

⚠️ The 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
💡 Tip: Draw a hard line between "excluding from stats" and "blocking". Blocking 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.

Related tutorials

Ready for some hands-on practice?

A Mehran Host hourly cloud server is ready in under 60 seconds. Practice as much as you like and pay only for the hours you use. Estimate the cost with the pricing calculator before you sign up.