📊 Know if it's working
Self-hosted, cookieless Umami for every app, proof that real visits land, events for the clicks that matter, and a five-minute weekly read.
You'll leave with
- Umami running on your server, with no cookie banner needed
- Your app reporting real visits you've watched arrive
- Named events for the steps that matter, and a weekly five-minute habit
Your app is live, and you can't tell whether anyone has opened it. By the end of this lesson your server runs its own analytics (software that counts visits and what visitors do), your app reports to it, and you've watched a real visit arrive.
Why self-hosted and cookieless
The usual choice, Google Analytics, sets cookies and sends your visitors' data to Google, hence all those cookie banners.
What are cookies and cookie banners?
A cookie is a small note a site asks your browser to keep and send back, so it can recognise you. EU-style privacy laws require consent for the ones a site doesn't strictly need: that's the banner.
I use Umami instead: free, open-source analytics (anyone can read its code) running on my own server, which is what self-hosted means. It sets no cookies and, by its own description, collects no personal data, so there's no banner. One copy serves every app: my public apps, from Handstand to TravelMap, report to one dashboard. Each new app costs one script tag, which on every page view sends your stats server a beacon: a small request (a message from the browser to a server) made in the background.
What's a script tag?
A line of HTML that loads a JavaScript file. In the page's <head> (its top section, for settings rather than content) and marked defer, it never holds up the page.
Umami runs as two containers (sealed boxes that each run one program), Umami and its own Postgres database, behind your Caddy front door. Two rules from day one: change the default admin password, and use hex passwords so the database address can't break.
What's Postgres?
A free, widely used database: a program that stores data in tables. Umami keeps every visit there, so it holds your whole history.
🦥 Skipped: Google Analytics, cookie banners, and writing your own counter. Rather not host it? Umami Cloud is the hosted version: the same kind of tag, their server.
Install it once
About fifteen minutes, once per server, on the front door and web network from Deploy behind one front door. Below, yourdomain.com, myapp and YOUR_VPS_IP stand for your own domain, app name and server. Menus follow Umami version 3: if a button has moved, ask Claude to check its docs.
Give the dashboard an address
The dashboard needs its own name, stats.yourdomain.com, so an A record (the DNS line saying which server a name lives on) comes first. Ask Claude Code, the AI assistant in your VS Code editor: "Add an A record for stats.yourdomain.com pointing at my VPS without touching other records; list them before and after to prove it. Then check 1.1.1.1 each minute until it returns the VPS IP."
What's happening underneath: an add-only record through the Hostinger MCP (the plug-in from Your own server that lets Claude edit DNS). Check it yourself against 1.1.1.1, a public DNS server, until the answer ends with Address: YOUR_VPS_IP:
nslookup stats.yourdomain.com 1.1.1.1
Start Umami and its database
One prompt, with Claude Code open in your app's folder (type /add-dir ../infra first, as last lesson):
EXAMPLE — YOU ASK CLAUDE CODE
{"ok":true}.↳ Bash · ssh · docker run ×2 · docker ps · caddy validate · caddy reload · curl /api/heartbeatWhat's happening underneath (paste it into your server tab yourself if you'd rather). Secrets first, made on the server so they never cross the chat:
mkdir -p /root/secrets && chmod 700 /root/secrets
test ! -e /root/secrets/umami.env && PW=$(openssl rand -hex 32) && cat > /root/secrets/umami.env <<EOF
POSTGRES_DB=umami
POSTGRES_USER=umami
POSTGRES_PASSWORD=$PW
DATABASE_URL=postgresql://umami:$PW@umami-db:5432/umami
APP_SECRET=$(openssl rand -hex 32)
TWO_FACTOR_ENCRYPTION_KEY=$(openssl rand -hex 32)
EOF
chmod 600 /root/secrets/umami.env
cut -d= -f1 /root/secrets/umami.env
One env file (NAME=value settings read at startup) serves both containers, each ignoring the other's lines; chmod 600 lets only root read it. test ! -e means "only if the file doesn't exist", so a rerun can't replace secrets already in use. The last line prints names, never values.
Why hex passwords?
Hex is only 0–9 and a–f. The database address puts the password between a colon and an @, so a password containing @, : or / breaks it. Random hex is strong, with nothing to break.
Then the database, and Umami once it answers (if pg_isready says no response, wait ten seconds and run it again):
docker run -d --name umami-db --restart unless-stopped --network web \
--env-file /root/secrets/umami.env \
-v umami_pgdata:/var/lib/postgresql/data \
postgres:16-alpine
docker exec umami-db pg_isready -U umami
docker run -d --name umami --restart unless-stopped --network web \
--env-file /root/secrets/umami.env \
ghcr.io/umami-software/umami:3
docker exec caddy wget -qO- http://umami:3000/api/heartbeat
The last line asks Umami's heartbeat (a tiny address that answers when it's alive) from inside the front door. Connection refused usually means Umami is still setting up its tables: wait half a minute, then read docker logs umami if it persists. The :3 keeps you on version 3, so updates can't jump to a breaking version 4.
🛑 The umami_pgdata volume (storage Docker keeps when containers are replaced) is your whole history: never delete it. Keep the server boring adds nightly backups and a tested restore.
Last, add this block to infra/Caddyfile and ship it like your app's block in the last lesson: diff, commit, copy up as Caddyfile.new, validate, back up, reload.
stats.yourdomain.com {
encode zstd gzip
reverse_proxy umami:3000
}
curl -s https://stats.yourdomain.com/api/heartbeat
(PowerShell: curl.exe.) An SSL error means no certificate yet: see the certificates section of Deploy behind one front door.
Change the password before anything else
Open https://stats.yourdomain.com. Every fresh Umami starts with the login admin / umami, which is public knowledge: until you change it, anyone can read your stats.
- Log in as
admin/umami. - Click your username at the bottom of the sidebar, then Settings, Profile, Change password. Use a long one from your password manager.
- Click Security in the left menu, then turn on the Enable 2FA switch (two-factor authentication: an authenticator app on your phone gives a code at each login). Keep the backup codes in your password manager.
- Log out and back in with the new password and a code.
🛑 Do this yourself, in the browser. Never type this password into Claude Code: the chat is saved, password and all.
Claude can still check your work without it. Ask: "Check that https://stats.yourdomain.com refuses Umami's default login, admin / umami, and show me only the status code." It should report 401. Underneath, it tries the default through Umami's API (the addresses programs use to talk to it). Run it yourself in Git Bash or a Mac terminal; 200 means the default still works, so change it now:
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://stats.yourdomain.com/api/auth/login \
-H 'Content-Type: application/json' -d '{"username":"admin","password":"umami"}'
Add your app and copy its ID
In the sidebar, click Websites, then Add website (top right). Name: anything. Domain: the exact address, myapp.yourdomain.com. Save. Then click the pencil (edit) icon at the end of its row: the settings page shows the Website ID (a long code of letters, digits and dashes) and, under Tracking code, the whole tag.
Keep a small file in infra mapping each domain to its ID (replace YOUR_WEBSITE_ID; one line per app, commas between):
{
"myapp.yourdomain.com": "YOUR_WEBSITE_ID"
}
IDs aren't secret (they're in your public HTML), so Claude can keep it. Ask: "Here's Umami's tracking code for myapp.yourdomain.com: [paste it]. Add the domain and ID to ../infra/websites.json, check no other domain already has that ID, and commit." Underneath it's a one-line edit.
One website ID per domain
Paste the wrong ID and nothing errors: two apps' visits quietly pool under one name. So no ID is typed by hand; a script looks it up.
EXAMPLE — YOU ASK CLAUDE CODE
</head>; a second run says already there. Deployed: the live page's data-website-id matches websites.json.↳ Write · ../infra/add-umami.mjs↳ Bash · node ../infra/add-umami.mjs · git diff · scp · curl | grep data-website-idWhat's happening underneath: a ten-line script in infra (replace yourdomain.com in both places):
import { readFileSync, writeFileSync } from 'node:fs';
const [file, domain] = process.argv.slice(2);
const ids = JSON.parse(readFileSync(new URL('./websites.json', import.meta.url), 'utf8'));
const id = ids[domain];
if (!file || !id) { console.log('usage: node add-umami.mjs FILE DOMAIN (DOMAIN must be in websites.json)'); process.exit(1); }
const html = readFileSync(file, 'utf8');
if (html.includes('stats.yourdomain.com/script.js')) { console.log('already there:', file); process.exit(0); }
if (!html.includes('</head>')) { console.log('no </head> in', file); process.exit(1); }
const tag = `<script defer src="https://stats.yourdomain.com/script.js" data-website-id="${id}"></script>\n`;
writeFileSync(file, html.replace('</head>', tag + '</head>'));
console.log('added', domain, 'to', file);
node ../infra/add-umami.mjs index.html myapp.yourdomain.com
What's idempotent?
Safe to run again: the second run changes nothing. The script never adds a second copy of the tag, which would count every visit twice.
Copy the page up as last lesson (scp index.html root@YOUR_VPS_IP:/root/myapp/; no reload needed), then fetch the live page and keep only the line with the ID:
curl -s https://myapp.yourdomain.com/ | grep data-website-id
(PowerShell: curl.exe -s https://myapp.yourdomain.com/ | Select-String data-website-id.) Expect your tag, with the ID websites.json lists. Run the script once per HTML file of every app: that's how seven of my static apps were done.
Apps built from templates
Some apps have no finished HTML files: a server program fills templates (HTML with gaps) to build each page. With Jinja templates (common in Python apps), like my price tool's, the tag goes in the shared head partial, the small template every full page includes (say templates/_head.html). In Next.js (a JavaScript framework), like TravelMap, it goes in the root layout that wraps every page (replace both placeholders):
import Script from 'next/script';
<Script src="https://stats.yourdomain.com/script.js" data-website-id="YOUR_WEBSITE_ID" strategy="afterInteractive" />
Next.js adds the real tag while the page starts up in the browser, so curl can't show it: check templated apps in a browser, as in step 6.
⚠️ Never put the tag in an HTML fragment that JavaScript loads into an open page, like a panel of search results: each load would count as another page view.
Prove a real visit lands
A tag proves nothing yet, and neither does a 200: Umami answers 200 even to visits it throws away. In Chrome or Edge on a computer (Safari hides these tools until you switch them on; phones lack them):
- Open
https://myapp.yourdomain.comand DevTools, the browser's developer tools: Ctrl+Shift+I (Mac: Cmd+Option+I). Click Network. - Reload (Ctrl+R, Mac: Cmd+R) and type
sendin the Filter box. - Click the
sendrow whose Type isfetch(skip apreflightrow: the browser asking permission). Under Payload (what the beacon carried),websiteis your ID; under Response:
A sessionId means stored; {"beep":"boop"} means Umami took you for a bot. No send row? Clear the filter: a red or blocked script.js usually means an ad blocker, so try a private window with extensions off. Visitors block it too, so every number is a floor. For the cookie check, click Application (behind » if hidden), then Cookies and your site: none comes from your stats domain.
Then open your app on your phone, with Umami open on your computer (click your site, then Realtime, a live feed): the visit appears within seconds.
📍 My first automated test of the fleet's tracking used a headless browser (a real Chrome with no window, driven by a script): a 200, and zero visits in the dashboard. It looked broken; it was the bot filter. First Bites and Baby Signs only showed up once the check posed as a normal desktop browser.
What's a user agent?
A line each browser sends to say what it is, such as Chrome on Windows. A headless browser's says HeadlessChrome, and Umami's bot filter drops it.
EXAMPLE — YOU ASK CLAUDE CODE
{"beep":"boop"}. With it: a sessionId, so stored; Realtime should list a desktop Chrome visit.↳ Write · ua-check.mjs↳ Bash · node ua-check.mjsWhat's happening underneath: the script watches the page's requests like your Network tab, setting a normal user agent first for the second run (Chrome's --user-agent flag, or Network.setUserAgentOverride over the DevTools Protocol your check.mjs from Check the AI's work uses):
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36
Keep everyday checks headless so they don't inflate your numbers. And do all this before launch, as Handstand did: without a baseline, a quiet week and a broken tag look the same.
Track the steps that matter
Page views say people arrived, not where they gave up. For that, fire a named event (a message you send Umami yourself, with a name you choose) at each step that matters: main button, signup, checkout start.
📍 Traqqit Max fires a named event at each step, from the main button through signup to checkout start (right before handing over to Stripe, the payment company) and back. Lined up in Umami's Funnels report, they can show where people stop.
EXAMPLE — YOU ASK CLAUDE CODE
"name":"search-click" in its payload, and the response has a sessionId, so it was stored.↳ Edit · app.js↳ Bash · scp app.js · node ua-check.mjs --click '#search'What's happening underneath (replace #search with # plus your main button's id, and search-click with your own name):
const track = (name, data) => window.umami && window.umami.track(name, data);
document.querySelector('#search').addEventListener('click', () => track('search-click'));
window.umami only exists if Umami's script loaded; when a blocker stops it, the && ends the line and your button works as usual, so tracking can never break a purchase. And the event survives the page leaving for Stripe: the tracker sends a keepalive request, which the browser finishes anyway.
Check it: click the button with the Network tab open (filter send); the new row's Payload has your event's name, and the site's Events view lists it. Add the helper at signup and payment later (Accounts without building auth, Get paid without leaking money).
🛑 No personal data in events: no emails, no names, nothing a person typed (an address search is somebody's address). Count the action, not the person.
Read it in five minutes a week
Once a week, open the dashboard, set the range to the last 7 days, and give it five minutes. Across the top of each site:
| Number | What it counts |
|---|---|
| Views | Every page load: one person reading five pages is five |
| Visits | Sittings: come back later in the week, and that's another |
| Visitors | Separate browsers, close to people (a phone and a laptop count as two) |
| Bounce rate | The share of visits that saw one page and left |
Pick one number per app that means "this is working" and write it in the app's NOTES.md: returning visitors (the Retention report) for a tool people should come back to, completed logs (a named event) for a tracker. Then ask one question: which app or page deserves my next evening? The one with visitors. Zero after a launch post means the message or the channel, not the app.
Visitors aren't users: count signups in your accounts database (Accounts without building auth builds one). Apps that save only in the browser (localStorage, on the visitor's device) leave no trace on your server, so they show zero users however many people use them: decide early what signal to store.
Umami reports each site on its own; my fleet total comes from one small private page that adds them up through Umami's API.
🦥 Skip that until you have three or more apps. Then ask Claude: "Using Umami's API, print last week's visitors for every site beside the week before, with an API key from a root-only env file." Underneath, /api/websites lists your sites and /api/websites/ID/stats returns each one's numbers. Make the key (a password made for scripts) under Settings, API keys (version 3.4 and later) instead of handing a script yours; a 404 usually means docs for another version.
Do it with Claude: the prompts
For each new app, three prompts (with your app's domain):
- "Add the Umami snippet for myapp.yourdomain.com, with its website ID from ../infra/websites.json, to index.html's head, deferred, only if it isn't already there."
- "Open the live site with a normal desktop user agent and confirm the pageview reaches Umami."
- "Add a track() helper that does nothing when analytics is blocked, and fire it on the main button."
What's happening underneath: all three come down to the beacon: the website ID, the page's address and title, where the visitor came from, screen size and language, sent to /api/send on your own server with no cookie. No third party sees your visitors. If Claude gets one wrong (a mistyped ID, a test still headless, a helper that throws when blocked), steps 5 to 7 catch it.
Your turn
- The heartbeat answers
{"ok":true}, the default-login check prints401, and your login asks for a two-factor code. infra/websites.jsonis committed, andcurl -s https://myapp.yourdomain.com/ | grep data-website-idshows the ID it lists for your domain.- In DevTools on your live site, the
sendrow shows your ID and asessionId, and no cookie comes from your stats domain; your phone's visit has appeared in Realtime. - Clicking your main button sends its event name in a
sendpayload, and Umami's Events view lists it. - Your app's
NOTES.mdnames its one number, a weekly five-minute slot is in your calendar, and after the first one you've written down which app or page gets your next evening.
Now you can see whether anyone comes. Next, Launch, honestly: a front door that answers visitors' questions in order, only true proof, and one plain post where your app's people already are.