Skip to content
Farhan Munim Analyst & developer Unavailable for new projects
← Blog

· Technical · Analytics  · 17 min read

How I migrated from GA4 to self-hosting Umami on Coolify

Umami running on a Hetzner VPS through Coolify, tracking three sites, with five years of Google Analytics history rebuilt inside it – and everything I’d do differently.

Google Analytics is free. It costs you in other ways: a heavy third-party script, visitor data sent elsewhere, and a cookie banner dragged along behind it.

Umami does the same job in a much smaller package. MIT-licensed, cookieless, and it runs on your own server with a single Postgres database. No ClickHouse, no Redis, no queues – one app container and one database.

You getYou don’t get
Pageviews, visitors, referrersSession replay
Countries, citiesHeatmaps
Browsers, devices, OSAnything that needs a cookie
UTM campaigns, custom events

I moved three sites over in one evening. Rebuilding five years of GA history inside it took rather longer.

This is the full walkthrough: the steps you’ll follow, plus the bits I got wrong so you don’t have to.

A note on the examples. mochi.yourdomain.com is a placeholder, not my real subdomain. Website IDs and dates are illustrative too.

What you’ll need

  • A VPS running Coolify (mine is on Hetzner)
  • A domain you control, with DNS you can edit quickly
  • A site you can add a <script> tag to
  • A GA4 property, if you want the history

My setup, for reference: domain on Cloudflare with the orange cloud on, main site built with Astro on Cloudflare Pages, plus two others — one static, one a small web app.

You don’t need that exact stack. Two steps are shaped by it: the Cloudflare IP header and the Astro snippet.

Step 1: Pick a subdomain that won’t get blocked

Do this first. It’s the one decision that’s genuinely annoying to change later.

Ad blockers work from filter lists, and self-hosting doesn’t get you an exemption. Rather than repeat the folklore, I searched the current EasyPrivacy source. Here’s what’s actually in it:

PatternWhat’s in the list
analytics. as a subdomainNo wildcard rule. Only ~200 named hosts (analytics.nike.com, analytics.python.org…)
stats., metrics., insights.Same – named hosts only, nothing generic
umami.yourdomain.comNot blocked today. But self-hosted instances get added by name once spotted; there’s a section literally commented “Plausible selfhosted”
/umami.js as a filenameBlocked everywhere, on any domain
/script.js (Umami’s default)Not blocked generically
/api/send (Umami’s default endpoint)Not blocked generically

Three rules follow from that:

  1. Don’t name the subdomain after the tool. umami. is the one that gets enumerated.
  2. Never rename the tracker script to umami.js. The default script.js is fine.
  3. Something neutral is safest (mochi., kettle.). But insights. or even analytics. are clean against current lists if you’d rather the name meant something.

I went with a meaningless word.

EasyPrivacy is only one list. AdGuard, Brave and Ghostery keep their own, so none of this is a guarantee. Once you’re live, uBlock Origin’s logger tells you definitively whether a request is blocked and which rule did it.

Step 2: DNS

In Cloudflare DNS, add an A record for your subdomain pointing at the VPS IPv4.

If you’re using the orange cloud, there’s a catch. Coolify’s Traefik gets its certificate through an HTTP-01 challenge, and that fails through the proxy. Two ways round it:

OptionHow
Grey-cloud first (simplest)Grey-cloud the record, deploy, let the certificate issue, then turn the orange cloud back on
Origin CertificateUse a Cloudflare Origin Certificate on the Coolify side

Either way, set Cloudflare SSL/TLS to Full (strict). On “Flexible”, traffic between Cloudflare and your server is plaintext.

Check DNS is live before you deploy. Traefik will fail the certificate request if it isn’t:

dig mochi.yourdomain.com +short

Step 3: Deploy Umami in Coolify

Umami is in Coolify’s one-click catalogue, so this part is easy.

  1. Project → Create New Resource → search Umami → click it.
  2. Coolify creates the service with two containers (Umami app and PostgreSQL) and generates the secrets.
  3. Open Domain settings on the Umami container. Newer Coolify splits this into separate fields:
FieldValue
Protocolhttps
Domainmochi.yourdomain.comno scheme in this field
Port3000 – type it in. The greyed-out 3000 is placeholder text, not a value
Search engine indexingNon-indexable. It’s a private dashboard
www redirectNone
  1. Deploy. The first run takes a few minutes while Prisma runs its migrations.

My mistake here: I typed https://mochi.yourdomain.com into the Domain field, forgetting Protocol was its own dropdown. That gives you http://https://… and nothing works.

Step 4: Environment variables worth adding

None are required. Coolify’s template generates DATABASE_URL, POSTGRES_PASSWORD and APP_SECRET for you.

You’ll see APP_SECRET=$SERVICE_PASSWORD_64_UMAMI. That’s a Coolify reference to a generated 64-character secret, not a literal value. Leave it alone, and don’t hand-edit any $SERVICE_* reference.

These are the ones worth adding. Coolify → the Umami service → Environment Variables → Add → Save → Redeploy:

VariableValueWhy
CLIENT_IP_HEADERcf-connecting-ipRequired if the Cloudflare proxy is on. Without it, every visitor looks like a Cloudflare datacentre. Must be lowercase – uppercase silently does nothing
DISABLE_TELEMETRY1Cosmetic
DISABLE_UPDATES1You update through Coolify anyway
IGNORE_IPYour home IPKeeps your own visits out. Only works once CLIENT_IP_HEADER is set
REMOVE_TRAILING_SLASH1Only if your site serves paths without trailing slashes. Read Step 9 first – it can make things worse

Skip TRACKER_SCRIPT_NAME and COLLECT_API_ENDPOINT. The defaults aren’t on the filter lists, and changing them means editing every script tag if you change your mind later.

Step 5: First login

Open https://mochi.yourdomain.com – no port. Sign in with admin / umami.

Then immediately go to Settings → Users. Change the password and rename the account. Set your timezone in Profile while you’re there.

Step 6: Add your websites

Settings → Websites → Add website. Give it a name and the domain, with no protocol and no www.

Save, then hit Edit to copy the Website ID. That’s the UUID that goes in your script tag.

The ID isn’t a secret. It sits in the page source of every site running Umami, so there’s no need to hide it in an env var.

One instance tracks as many sites as you like. Each gets its own entry and its own ID, and the dropdown at the top-left switches between them.

Do this for every site you’re moving. Expect each one to have its own quirks — mine did:

SiteQuirkWhat I did
Main site (Astro, Cloudflare Pages)Five years of GA. pagePath blank before mid-2022. Trailing slashes everywhereBlank paths became /(unknown). Left the slashes alone
Static side projectSame page reachable as / and /index.html, /changelog and /changelog.htmlFolded the .html variants together on import. Site should redirect them going forward
Web appMostly slash-free paths with one stray /blog/. Admin and login pages in the dataStripped the stray slash. Used IGNORE_IP to keep my own admin visits out

Step 7: Put the script on the site

The tag Umami gives you:

<script defer src="https://mochi.yourdomain.com/script.js" data-website-id="9e9dbe50-…"></script>

Put it in <head>, in whatever shared layout or template your site uses, so it lands once on every page.

For Astro on Cloudflare Pages, that’s the base layout:

{import.meta.env.PROD && (
  <script
    defer
    src="https://mochi.yourdomain.com/script.js"
    data-website-id="9e9dbe50-…"
  ></script>
)}

import.meta.env.PROD is built into Astro, so npm run dev locally won’t pollute your dashboard. Commit, push, let Pages rebuild.

If a site is maintained through some other tool, this instruction was enough for mine:

Add Umami analytics tracking to this site. Insert the following script tag inside <head> on every page served to visitors, using the shared layout or template if there is one so it’s added once. Place it before any existing Google Analytics tags and do not remove those. [script tag] Do not add data-do-not-track. Confirm which files you changed.

Step 8: Check it’s actually working

Open the site in Chrome → F12 → Network tab → type your subdomain in the Filter box → reload.

You want two entries: script.js returning 200, and a POST to /api/send.

What I saw was script.js 200 and nothing else. The script loaded and chose not to send anything.

The cause was data-do-not-track="true", which I’d added as a courtesy. It tells the script to bail out if the browser sends a Do Not Track or Global Privacy Control signal – which Brave, Firefox in strict mode and plenty of extensions do by default.

The script was doing exactly what I’d asked. Removing the attribute fixed it instantly.

Umami is cookieless and stores no personal identifier either way. That attribute buys you nothing and costs you a chunk of your data. Don’t add it.

Other things that look the same:

What you seeCauseFix
No request at allCloudflare Rocket Loader rewriting defer scriptsCloudflare → Speed → Optimization → turn it off
script.js from (disk cache)You’re testing a stale copyTick Disable cache in the Network toolbar (only works while DevTools is open)
script.js 404Typo in the path or subdomainCheck src against the Domain field in Coolify
POST returns 403Cloudflare WAF blocking itCheck the firewall events log
Request blocked or cancelledYour own ad blockerTest in a clean profile
Dashboard empty, Network fineYou’re on Overview with “This year”Check Realtime – it shows hits within seconds

One more: visiting the site from another device doesn’t magically create data. Only a page carrying the script does.

Step 9: A word on trailing slashes

Umami stores the path exactly as the browser reports it. So /blog and /blog/ are two different pages.

REMOVE_TRAILING_SLASH=1 strips the slash from incoming hits. It does not touch rows already in the database.

Before you set it, open yourdomain.com/blog with no slash and watch the address bar:

What happensWhat it meansWhat to do
Redirects to /blog/Your site uses trailing slashesDon’t set the variable. GA exports usually have slashes too, so you’d create the split you’re trying to avoid
Stays at /blogYour site is slash-freeSet the variable, and strip the slashes from imported rows so they match

For the second case:

UPDATE website_event
SET url_path = left(url_path, -1)
WHERE url_path <> '/' AND url_path LIKE '%/';

I nearly set this without checking. My site redirects to the slash, so it would have been a mistake.

Step 10: Bring your Google Analytics history across

The honest bit first: Umami has no import feature. It’s not a hidden option, it’s a years-old open request.

Two things make it harder than it sounds:

  1. Umami’s /api/send endpoint timestamps events server-side, at the moment they arrive. You can’t POST historical pageviews and have them land on past dates. Backfilling has to be a direct database insert.
  2. Umami spreads data across a session table and a website_event table, with foreign keys, visit IDs and its own vocabulary for browsers and devices. You can’t just load flat rows in.

The old community tool, ga-extractor, targets Umami v1’s schema and would produce nonsense against a current build.

So the plan is: export what GA4 will give you, read the real schema off your own database, and write a generator that turns one into the other.

Exporting from GA4

The GA4 web UI caps exports and samples anything large. What you want is a Google Sheets add-on that talks to the GA4 Data API directly, giving you a flat, unsampled, row-per-combination export.

I used GA4 Magic Reports – free, in the Workspace Marketplace. It produces exactly the shape the generator needs.

  1. New Google Sheet → Extensions → Add-ons → Get add-ons → search “GA4 Magic Reports” → install and authorise it against the Google account that owns the GA4 property.
  2. Extensions → GA4 Magic Reports → Create new report. A sidebar opens on the right.
  3. Fill it in:
FieldValue
Report nameAnything – it becomes the tab name
Account / PropertyThe GA4 property for the site
Date rangeAs early as the property goes, through to today
Dimensions (in this order)sessionMedium, sessionSource, date, browser, deviceCategory, operatingSystem, pagePath, city, country
MetricstotalUsers, sessions, screenPageViews
Limit / max rowsLeave at the default for now — see below
  1. Create report, then Extensions → GA4 Magic Reports → Run reports.

Each report lands in its own tab. The add-on also builds a Report Configuration sheet listing every report as a row.

That sheet is the useful part. You can edit a date range there and re-run, rather than rebuilding from the sidebar every time.

The top of each result tab is a header block – Property, Date range, Last run, Total rows, Rows returned, plus Cardinality, Thresholding and Sampling flags. Then a Totals row, then the detail rows under a repeated header. The generator skips the header block and starts reading from the last header row.

Check that header block before you export anything. You want Sampling: No and Thresholding: No. If either says Yes, GA has altered the data and your reconstruction won’t match.

The row limit is why my main site needed three reports. One combination of all nine dimensions per row adds up quickly over five years.

Compare Total rows with Rows returned. If they differ, the report was truncated. Split by date range in the Report Configuration sheet until each one fits:

ReportRows
2015–20239,478
20245,932
2025–today4,211

The smaller sites fitted in a single report.

To export: with a result tab selected, File → Download → Comma Separated Values (.csv). One file per tab. The generator reads every matching file and concatenates them.

Those three metrics cover everything Umami’s main views display. What I deliberately left out, and why:

Not pulledWhy
hourWould let you spread events along a real time-of-day curve. Only worth it if you care about hourly charts for old data. I didn’t
language, screenResolution, regionUmami has columns for them, but they’d be synthetic at row level. Leaving them NULL is more truthful — those panels show “Unknown” before the cutover and populate normally afterwards
pageTitleTitles change over the years, and Umami shows paths anyway

Read the schema off your own database

Coolify → the Umami service → Terminal → pick the PostgreSQL container. The Umami image has no psql.

psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -P pager=off -c "\d session" -c "\d website_event"
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -P pager=off -c "select migration_name from _prisma_migrations order by finished_at desc limit 5"

-P pager=off matters. Without it, output stops at an (Enter: next line) prompt and the web terminal suspends the process. If that happens: fg then q, or kill %1.

Mine was on migration 14_add_link_and_pixel, well past v2. The columns that mattered:

TableColumns
sessionsession_id, website_id, browser, os, device, screen, language, country (char(2), ISO codes), region, city, created_at, distinct_id
website_eventevent_id, website_id, session_id, visit_id (not null), created_at, url_path, referrer_domain, event_type (1 = pageview), hostname, plus UTM and click-ID columns

How the generator maps GA to Umami

GA4Umami
totalUsersOne session row each
sessionsOne visit_id each, spread across the sessions
screenPageViewsOne website_event row each, spread across the visits
country nameISO-2 via pycountry, plus a fix-up map for GA’s spellings (Côte d'Ivoire, Myanmar (Burma), Turkey, Russia…)
browserUmami’s vocabulary: Chrome→chrome, Safari (in-app)→ios-webview, Edge→edge-chromium, Android Webview→chromium-webview
operatingSystemWindows→Windows 10, Macintosh→Mac OS, Android→Android OS, iOS→iOS
sessionSourcereferrer_domain, with search engines mapped to their domain (google→google.com)
(direct) / (not set)NULL
dateA timestamp inside that day, spread along a plausible daytime curve

Every visit gets at least one event, even where GA reported more sessions than pageviews. Totals land about 0.5% above GA as a result.

Visits are spaced more than 30 minutes apart so Umami treats them as distinct.

The generator writes one .sql file wrapped in BEGIN / COMMIT, so any error rolls the whole lot back. It’s parameterised on website ID, hostname, cutoff date and CSV filename pattern – each site was a copy with those four values changed.

The blank paths (the bit I nearly got wrong)

My main site’s export had 2,529 rows with no pagePath. First instinct: drop them as noise.

That would have been a real mistake. Look at where they sat:

YearPageviews with a pathBlank
202103,448
20226,52812,510
2023 onward31,5030

GA4 simply wasn’t recording pagePath before mid-2022. Dropping the blanks would have deleted 2021 entirely and two thirds of 2022.

Map them to a literal path instead. I used /(unknown).

The timeline stays intact, the totals reconcile with GA, and the pages table labels the gap honestly rather than quietly handing it to the homepage.

Before you throw any rows away, check where the gaps sit.

Normalising static-site paths

Two of my smaller sites had the same page reachable under several URLs: / and /index.html, /changelog and /changelog.html, /blog and /blog/.

The generator folds those together on import.

Worth doing, but it only half solves it. Your live site needs to redirect the variants too, or the split reappears from day one.

Pick a cutoff date

Every import stops the day before live tracking started. Historical and real data never overlap on the same day.

That also makes it trivial to undo:

DELETE FROM website_event WHERE website_id='…' AND created_at < '2026-09-18';
DELETE FROM session       WHERE website_id='…' AND created_at < '2026-09-18';

Loading it

From your PC. Windows 10/11 has ssh and scp built in, so there’s nothing to install:

scp "C:\path\to\umami_ga_import.sql" root@<vps-ip>:/tmp/
ssh root@<vps-ip>

Run that scp in a second PowerShell tab, not inside the SSH session. The file is on your PC. Run it on the server and it tries to resolve a host called C:. Ask me how I know.

Then on the VPS, one command at a time:

PG=$(docker ps --format '{{.Names}}' | grep -i postgres); echo $PG

That should print exactly one container name.

# backup first, always
docker exec $PG sh -c 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB"' | gzip > ~/umami-pre-import.sql.gz
zcat ~/umami-pre-import.sql.gz | grep -c "CREATE TABLE"     # expect 14
# load
docker exec -i $PG sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"' < /tmp/umami_ga_import.sql

You’ll get a stream of INSERT 0 1000 lines. The last line must be COMMIT. If you see ROLLBACK, nothing was written.

# verify
docker exec $PG sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -P pager=off -c "select date_part('"'"'year'"'"',created_at) yr, count(*) from website_event group by 1 order by 1"'
rm /tmp/umami_ga_import.sql

Then set the Umami date range to All time and have a look.

What to expect afterwards

ReliableWrong or empty for the historical period
Traffic over timeBounce rate and visit duration (event spacing is synthetic)
Top pagesHourly charts (generic curve)
ReferrersScreen, language, region (not exported)
Countries and citiesJourneys, Funnels, Retention, Cohorts, Attribution
Browsers, OS, devices

My year-by-year totals matched GA to within a few dozen views.

The Journeys-type reports need real event sequencing, which aggregate exports simply don’t contain.

One number to watch: visitors will read higher than GA’s headline user count. GA dedupes a returning person across days, and row-level exports can’t. Compare visits and pageviews, not visitors.

Getting into the VPS from Windows

I’d never done SSH from PowerShell before this, so here are the notes I wish I’d had.

You need two things to start: the server’s IP (Hetzner console → Servers → the IPv4 on the server page, or the Tailscale IP if you use it), and how you authenticate – root password or SSH key.

ssh root@<ip>

First time it asks Are you sure you want to continue connecting (yes/no)? – type yes. Then a password prompt, where nothing appears as you type.

A root@hostname:~# prompt means you’re in. Permission denied (publickey) means password login is disabled and you’ll need a key set up through the Hetzner console.

What actually happened to me: the connection sat silent for 30 seconds, then Connection timed out. The Hetzner Cloud Firewall on that server had 80 and 443 open, but not 22.

FixNote
Add an inbound TCP 22 rule in Hetzner → FirewallsIdeally restricted to your home IP
Use the Tailscale IP (100.x.x.x)Bypasses the Hetzner firewall entirely

Tailscale was already on the box, so I used that and left 22 closed.

Two more things worth knowing:

  • The Hetzner browser console mangles input. && arrived as 77 and line breaks between commands disappeared, so apt update && apt upgrade -y followed by reboot became one rejected line. Keyboard layout mismatch. Use SSH for anything longer than a single word.
  • Run commands a few at a time. Pasting ten lines works right up until something fails, and then you can’t see which line did it. I did the whole import in batches of three, checking output each time.

The reboot notice

*** System restart required *** at login means a package was updated that can’t be hot-swapped. cat /var/run/reboot-required.pkgs tells you which.

Mine listed a new kernel plus libc6 and libssl3 – the C library and the TLS library every process on the box uses. Those are almost always security fixes, and until you reboot, everything running is still on the old copies.

Not an emergency. Not something to leave for weeks either.

What a reboot affects:

ServiceImpact
Anything on Cloudflare PagesNone
Everything in CoolifyDown 1–2 minutes, comes back on its own
Uptime KumaAlerts on itself. Expected
apt update
apt upgrade -y
reboot

Three separate commands. Reconnect after a couple of minutes and run docker ps to confirm the containers are back.

Ignore the “new release 24.04 available” nag. A major version upgrade is its own project.

Backups and updates

If you already back up your Docker volumes, that covers the Postgres volume. But a copy of a live volume isn’t a clean restore point.

Add a scheduled pg_dump in Coolify – the service → Scheduled Tasks, on the Postgres container — writing into the volume so your existing backup job picks it up. Mine rsyncs to a NAS nightly:

pg_dump -U umami umami | gzip > /var/lib/postgresql/data/backup/umami-$(date +\%F).sql.gz

Keep the pre-import dumps for a couple of weeks, then bin them.

For updates, Coolify → the service → Redeploy pulls the latest image. Take a dump before any major version bump.

Never change the Postgres image’s major version on an existing install without testing a dump-and-restore first.

Retiring Google Analytics

Leave GA running alongside Umami for a few weeks so you can compare live numbers.

Then pull the tags – and check how many you actually have. My main site had two GA4 properties firing, which I only spotted in the Network tab.

Once GA is gone, look at your cookie banner again. Umami is cookieless, so if analytics cookies were the only reason it existed, it can go too.

Update your privacy page to say analytics are cookie-free, self-hosted, and where the data lives.

Mistakes to skip past

These cost me time, roughly in order of how avoidable they were:

#Mistake
1data-do-not-track="true" silently dropping visitors. Don’t add it
2Nearly setting REMOVE_TRAILING_SLASH without checking my site’s URL style
3Nearly dropping the blank-path GA rows without checking they were the whole of 2021
4Typing https:// into a Domain field that had a separate Protocol dropdown
5Running scp inside the SSH session instead of on my PC
6Forgetting -P pager=off and suspending psql in the web terminal
7CLIENT_IP_HEADER needing to be lowercase

The whole thing, in one paragraph

Pick a neutral subdomain → add the DNS record → one-click Umami in Coolify with https / domain / 3000 → add CLIENT_IP_HEADER=cf-connecting-ip if you’re behind Cloudflare → change the admin password → add each website → script tag in <head> with no data-do-not-track → check the Network tab for two requests → GA4 Magic Reports in Sheets with nine dimensions and three metrics, split into reports that fit the row limit → download the CSVs → read the real schema off your Postgres container → generate the SQL per site → SSH in (Tailscale if 22 is closed) → scp the file up → back up → load → verify → set the range to All time → run GA in parallel for a few weeks → pull the tags.