# A Tiny 'Views' Badge Sent Me Down a Rabbit Hole (PaperMod + Busuanzi + Debugging --> swapped with GoatCounter the GOAT)

October 18, 2025 · 9 min · Iman Alipour Comments

I only wanted a tiny “views” badge next to my RSS link—just a friendly nudge that real humans were reading my words. PaperMod makes it easy to tuck little UI bits in the footer, so I dropped in Busuanzi (that neat, no-account page/site view counter) and called it a day.

The thing is, I don’t think the counter even works correctly. Or maybe I’m undermining my blog popularity! But regardless, it was a small stiching task that I did on this lovely Friday evening. The day before my next term of German class starts. The day before playing deadface CTF that I’ve been counting days for.

### The neat footer row#

First I got the layout right. PaperMod supports extension partials, so I used a simple flex row to keep things side by side:

    <div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
      <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
         class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
        { partial "svg.html" (dict "name" "rss") }
        <span>RSS</span>
      </a>

      <span aria-hidden="true">·</span>

      <span id="busuanzi_container_site_pv">
        Site views: <span id="busuanzi_value_site_pv">—</span>
      </span>
      <span aria-hidden="true">·</span>
      <span id="busuanzi_container_site_uv">
        Visitors: <span id="busuanzi_value_site_uv">—</span>
      </span>
    </div>

So far, so good. The labels showed up. The numbers, though? Nothing. Just an em dash. I shrugged—maybe it was a caching thing.

### The first round of checks#

I added the script site‑wide in `layouts/partials/extend_head.html`:

    <script src="//cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js" defer></script>

Refreshed. Still blank.

DevTools time. Network tab: the script does load:

- Request URL: `https://cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.min.js`
- Request Method: GET
- Status Code: 304 Not Modified
- Referrer Policy: strict-origin-when-cross-origin
- User-Agent: a normal Chromium build (nothing exotic)

No red flags. Just a perfectly normal 304.

Maybe my IDs were wrong? Busuanzi has a couple of flavors: `busuanzi_value_*` (with container wrappers) or shorthand like `busuanzi_site_pv`. I tried both, carefully, one at a time. Still… —.

### The odd message#

Then it flashed for a moment—the Chinese line that made me google twice to be sure I saw it right:

> “域名过长，已被禁用 views”

That’s “domain name too long, disabled.” Wait, what?

I checked my hostname: `blog.alipourimjourneys.ir`. I counted: 27 characters. Busuanzi’s limit? 22. Suddenly, everything clicked. The script loads, but the service refuses to count for long domains, so the placeholders never fill.

### Two ways out#

At this point I had two clean options:

1. Keep Busuanzi and move the site to the apex domain (short enough).
2. Keep my beloved `blog.` subdomain and swap in Vercount, a Busuanzi‑style drop‑in without the 22‑char limit.

I liked the subdomain (habit, mostly), so I tried Vercount.

### The swap (five-minute fix)#

I removed the Busuanzi script and added Vercount instead:

    <!-- extend_head.html -->
    <script defer src="https://events.vercount.one/js"></script>

I kept the same tidy footer row, but switched the IDs to Vercount’s:

    <div class="site-meta-row" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
      <a href="/index.xml" rel="alternate" type="application/rss+xml" title="RSS"
         class="rss-link" style="display:inline-flex;align-items:center;gap:.35rem;">
        { partial "svg.html" (dict "name" "rss") }
        <span>RSS</span>
      </a>

      <span aria-hidden="true">·</span>

      <span>Site views: <span id="vercount_value_site_pv">—</span></span>
      <span aria-hidden="true">·</span>
      <span>Visitors: <span id="vercount_value_site_uv">—</span></span>
    </div>

For per‑post counts (in the post meta), I added:

    <span id="vercount_value_page_pv">—</span> views

Deploy. Refresh. Numbers.

### But if you want to stay with Busuanzi…#

If your apex domain is short enough, pointing the site there works fine. The gist:

- Set `baseURL` in Hugo to the apex:

      baseURL = "https://alipourimjourneys.ir/"

- Add a 301 redirect from the long subdomain to the apex (Cloudflare, Netlify, Nginx—pick your tool).

- Keep your Busuanzi spans as you had them; once the host fits the limit, they’ll populate.

### Post‑mortem checklist (a.k.a. things I learned)#

- If the script loads but you get blanks, it’s not always IDs or caching. Sometimes it’s a service rule.
- Busuanzi refuses long hostnames (>22 chars) and ignores localhost. On those hosts, the placeholders stay empty or show the Chinese “disabled” note.
- Don’t mix providers on the same page. Load exactly one script (Busuanzi or Vercount).
- CSP and blockers matter. If you run a strict Content‑Security‑Policy or heavy ad blockers, they can block the script. Test in a clean profile and allow the script origin.

### Happy ending#

Now my footer shows RSS, site views, and visitors on a single neat line, and each post has a tiny “views” chip that quietly ticks upward. I got the tiny badge I wanted—and a reminder that the smallest features can hide the most interesting puzzles.

If you hit the same wall, check the hostname length first. It might save you an afternoon—and lead you to a solution you’ll feel weirdly proud of.

### Final notes#

Yea… I just felt this feature was missing, and it’s good to have it there. I hope I stop adding things to the blog, and just keep writing in it. I’ve spent too much time on it, and it feels like wasted time.

## Change of plans: Self‑hosting GoatCounter for PaperMod (Docker + Cloudflare + Onion)#

This is the self‑host part I ended up with: Docker for GoatCounter, Cloudflare (orange‑cloud) in front, Nginx on the server as reverse proxy, plus my `.onion` mirror counting to the same GoatCounter site. Paste this whole section at the end of the post.

### 1) Clean up old counters (optional, once)#

    # From your Hugo site root
    grep -RinE "busuanzi|vercount|vercount_value_|busuanzi_value_" .

Remove any matching `<script>` and leftover `*_value_*` spans you no longer want.

### 2) Run GoatCounter with Docker Compose#

Minimal `docker-compose.yml` in `~/goatcounter`:

    services:
      goatcounter:
        image: arp242/goatcounter:2.6
        container_name: goatcounter
        ports:
          - "127.0.0.1:8081:8080"   # bind only to localhost; nginx will proxy
        volumes:
          - goatcounter-data:/home/goatcounter/goatcounter-data
        restart: unless-stopped

    volumes:
      goatcounter-data: {}

Start:

    docker compose pull && docker compose up -d

Initialize the site (replace email if needed):

    docker compose exec goatcounter   goatcounter db create site   -vhost=statsblog.alipourimjourneys.ir   -user.email=iman.alip2001@gmail.com

> If you ever see “no site at this domain” on localhost: use the exact vhost above.

### 3) Cloudflare + Origin certificate (CF → origin TLS)#

- DNS: `statsblog.alipourimjourneys.ir` → server IP (proxied/orange).
- SSL/TLS mode: Full (strict).
- Issue a Cloudflare Origin Certificate for `statsblog.alipourimjourneys.ir` and save on the server:

    sudo mkdir -p /etc/ssl/cloudflare
    sudo nano /etc/ssl/cloudflare/origin.crt   # paste PEM
    sudo nano /etc/ssl/cloudflare/origin.key   # paste key
    sudo chown root:root /etc/ssl/cloudflare/origin.{crt,key}
    sudo chmod 644 /etc/ssl/cloudflare/origin.crt
    sudo chmod 600 /etc/ssl/cloudflare/origin.key

### 4) Nginx vhost for the stats subdomain#

    # /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf
    server {
      listen 80;
      server_name statsblog.alipourimjourneys.ir;
      return 301 https://$host$request_uri;
    }

    server {
      listen 443 ssl http2;
      server_name statsblog.alipourimjourneys.ir;

      ssl_certificate     /etc/ssl/cloudflare/origin.crt;
      ssl_certificate_key /etc/ssl/cloudflare/origin.key;

      # Proxy to the container on localhost
      proxy_set_header Host              $host;
      proxy_set_header X-Real-IP         $remote_addr;
      proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
      proxy_set_header X-Forwarded-Proto $scheme;

      location / { proxy_pass http://127.0.0.1:8081; }
    }

Enable & reload:

    sudo ln -s /etc/nginx/sites-available/statsblog.alipourimjourneys.ir.conf /etc/nginx/sites-enabled/
    sudo nginx -t && sudo systemctl reload nginx

### 5) Hugo integration (clearnet + onion, singular/plural + “0 views”)#

Self‑host `count.js` so the onion mirror never fetches a third‑party script:

- Save the official `count.js` as `static/js/count.js` in your Hugo repo.

Then add this to `layouts/partials/extend_head.html` (loader + helper):

    <!-- Loader: choose endpoint based on hostname -->
    <script>
    (function () {
      var ONION = /\.onion$/i.test(location.hostname);
      window.goatcounter = { endpoint: ONION ? '/count' : 'https://statsblog.alipourimjourneys.ir/count' };
      var s = document.createElement('script');
      s.async = true; s.src = '/js/count.js';
      document.head.appendChild(s);
    })();
    </script>

    <!-- Helper: inline meta counts (post pages + lists) and totals -->
    <script>
    (function () {
      const ONION = /\.onion$/i.test(location.hostname);
      const GC_HOST = ONION ? '' : 'https://statsblog.alipourimjourneys.ir'; // '' = same-origin on .onion
      const SING='view', PLUR='views';
      const nf = new Intl.NumberFormat();

      function ready(fn){ if (document.readyState !== 'loading') fn(); else document.addEventListener('DOMContentLoaded', fn); }
      function gcPath(){ try { return (window.goatcounter && window.goatcounter.get_data) ? window.goatcounter.get_data().p : location.pathname; } catch { return location.pathname; } }
      async function fetchCount(path){
        try {
          const r = await fetch(`${GC_HOST}/counter/${encodeURIComponent(path)}.json?t=${Date.now()}`, { credentials:'omit' });
          if (r.status === 404) return 0;
          if (!r.ok) throw 0;
          const j = await r.json();
          const n = Number(String(j.count).replace(/,/g,''));
          return Number.isFinite(n) ? n : 0;
        } catch { return null; }
      }
      const label = (n) => (n === null ? '—' : `${nf.format(n)} ${n === 1 ? SING : PLUR}`);

      ready(async function () {
        // A) Single post: add to existing meta row + small bottom
        const post = document.querySelector('.post-single');
        if (post) {
          const meta = post.querySelector('.post-header .post-meta, .post-header .entry-meta, .post-meta');
          const path = gcPath();
          const n = await fetchCount(path);
          if (meta) {
            let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
            span.textContent = label(n ?? 0);
          }
          const after = post.querySelector('.post-content') || post;
          let bottom = post.querySelector('.post-views-bottom'); if (!bottom) { bottom = document.createElement('div'); bottom.className='post-views-bottom'; after.insertAdjacentElement('afterend', bottom); }
          bottom.textContent = label(n ?? 0);
        }

        // B) Lists (/posts, home, sections): show “0 views” instead of hiding
        const cards = document.querySelectorAll('article.post-entry');
        await Promise.all(Array.from(cards).map(async (card) => {
          const meta = card.querySelector('.entry-footer, .post-meta');
          const link = card.querySelector('a.entry-link, h2 a, .entry-title a');
          if (!meta || !link) return;
          try {
            const u = new URL(link.getAttribute('href'), location.origin);
            if (u.origin !== location.origin) return;
            let span = meta.querySelector('.post-views'); if (!span) { span = document.createElement('span'); span.className='post-views'; meta.appendChild(span); }
            const n = await fetchCount(u.pathname);
            span.textContent = label(n ?? 0);
          } catch {}
        }));

        // C) Site totals (optional placeholders)
        const pvEl = document.getElementById('vercount_value_site_pv');
        if (pvEl) { const n = await fetchCount('TOTAL'); pvEl.textContent = (n===null) ? '—' : nf.format(n); }
      });
    })();
    </script>

Style so meta items stay inline with middle dots (PaperMod‑like). Put this in `assets/css/extended/goatcounter.css`:

    .post-single .post-header .post-meta,
    .post-single .post-header .entry-meta,
    .post-entry .entry-footer,
    .post-entry .post-meta {
      display:flex; align-items:center; flex-wrap:wrap; gap:.35rem;
    }
    .post-single .post-header .post-meta > *,
    .post-single .post-header .entry-meta > *,
    .post-entry .entry-footer > *,
    .post-entry .post-meta > * {
      display:inline-flex; align-items:center; white-space:nowrap;
    }
    .post-single .post-header .post-meta > * + *::before,
    .post-single .post-header .entry-meta > * + *::before,
    .post-entry .entry-footer > * + *::before,
    .post-entry .post-meta > * + *::before {
      content:"·"; margin:0 .5rem; opacity:.6;
    }
    .post-views-bottom { margin:.6rem 0 0; opacity:.75; font-size:.95em; }

### 6) Count Tor (.onion) visits into the same site #

In the .onion server block, proxy GoatCounter endpoints to the same site by forcing the Host header:

    # inside: server { ... } for fjthpp2h3mj2r....onion

    location = /count {
      proxy_pass http://127.0.0.1:8081/count;
      proxy_set_header Host statsblog.alipourimjourneys.ir;
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_set_header X-Forwarded-Proto http; # onion is http
    }

    location /counter/ {
      proxy_pass http://127.0.0.1:8081/counter/;
      proxy_set_header Host statsblog.alipourimjourneys.ir;
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_set_header X-Forwarded-Proto http;
      add_header Cache-Control "no-store";
      proxy_no_cache 1; proxy_cache_bypass 1;
    }

Because the loader above sets `window.goatcounter.endpoint` to `/count` on `.onion`, both hits and reads stay on the onion origin and are tallied into the same GoatCounter site.

> Note on DNT/Tor: Tor Browser sends `DNT: 1`. GoatCounter respects DNT by default, so Tor users won’t be counted unless you disable “Respect Do Not Track” in GoatCounter settings. Your choice; I left the code compatible either way.

### 7) Troubleshooting quick notes#

- 400 “no site at this domain” → create the site with `-vhost=statsblog.alipourimjourneys.ir` or fix your proxy `Host` header.
- Counts don’t change on onion → verify Tor path via `torsocks`, watch logs:

      docker compose logs -f goatcounter
      # look for “ignored: bot” (curl UA), or DNT being respected

- DevTools sanity → on clearnet you should see `https://statsblog…/count` and `…/counter/*.json`; on onion you should see `/count` and `/counter/*.json` (same‑origin).

That’s it. One dashboard; clearnet + onion both increment; tiny inline counters everywhere.

Honestly, self-hosting GoatCounter wasn’t as straightforward as I thought it would be. It almost made me cry a few times. I definitly thought about my life choices a few times along the way. But oh well, now it’s done and dusted, and I’m a happy puppy! :D

Downloads: Markdown · Signature (.asc)

View OpenPGP signature

    -----BEGIN PGP SIGNATURE-----

    iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuoACgkQtYgoUOBM
    jSolRg//SwN9nMf9/Wgkr5h+dQowZMCHXXcKWgO8J08ITVDN1+weNNGANFrz63SQ
    DajHGeSogYW1+AAxJaAeQ7hLdOX9foV5pT+kWANIjRBXnFyW+WR7kt6tmN2rcSH8
    z4GKxqE3kdd3kCRhqT0pLiwnurqLgdCK+YldftO6GB8WP4oNDqOwHvoIE6o0ekdH
    sn7ZBIxMaWc5UqijjqgBHhdHz3uSmL+rN4UwLFM3WXXlwl3HdGyjHcNTG7k648s2
    X5+7a78OZAuDElpyp9y6WqiAFJQdrwBbTv3vvpU+f911voV7ZA+KbUqHsQrISTKz
    cd+tPw2lcayfBZRtHMrL3fygvT3AWktcSavZrU5EOX/u/P7eMWEYu0FYh6aHqRak
    +Ft2FR7EPlB2faS6wWYfoua6BYxFvevXX1fk+0HhZn9UJxJPaa/WTgVWDgpt++Ce
    fdaDmsR69LIj2QTjPMXdQiUKkWPG2ziadTwJEWUHzOuREifmuBgp9Mwedk6zYkdT
    m5Roi70IPtX3dDDHG5Votw3AKng3gaU7YcNzZVyi3iZkQkUHPUK47Wj21FajikMP
    gCcWsoYWBRqdhL5XDrodt28AuLpm0cslz79DZdbLnielcjOS+GaUynjLzEWveOib
    dxLcbIIap+nt315cjvkfatGv14Dres/K7vhQAhqGy5o0LM1D0qc=
    =o387
    -----END PGP SIGNATURE-----

Verify locally:

    curl -fSLO https://blog.alipour.eu/sources/posts/view_count.md
    curl -fSLO https://blog.alipour.eu/sources/posts/view_count.md.asc
    gpg --verify view_count.md.asc view_count.md

Open on GitHub ↗
Comments powered by Isso
RSS · Visitors this week: —