Technical Blog

Self-Hosting Umami on Netlify + Azure: What I’d Do Differently (and Why Your Database Probably Isn’t the Problem)

· min read
Self-Hosting Umami on Netlify + Azure: What I’d Do Differently (and Why Your Database Probably Isn’t the Problem)

Self-Hosting Umami on Netlify + Azure

Prisma fights, pgcrypto drama, CSP facepalms, cold starts… and assumptions with teeth


TL;DR

I set out to self-host Umami analytics for this website using:

  • Netlify (Next.js runtime - I already host my site here so it made sense to me!)
  • Azure PostgreSQL Flexible Server (I have a Visual Studio Enterprise subscription with Azure Credits so paying for PostgreSQL hosting anywhere else felt stupid!)
  • Terraform + GitHub Actions (OIDC, no secrets)

What followed:

  • Prisma migrations breaking on Azure extension restrictions
  • Authentication and permission quirks
  • Netlify build/runtime oddities
  • CSP silently killing analytics
  • “Performance issues” that turned out not to be database-related

What I ended up with:

  • A clean, privacy-friendly analytics platform
  • A deeper understanding of cold starts
  • And a reminder that:

Assumptions have teeth


Architecture (What I Built)

flowchart LR User --> Browser Browser -->|JS Tracking| UmamiScript UmamiScript -->|POST /api/collect| Netlify Netlify -->|Prisma| AzurePostgres[(Azure PostgreSQL)] Netlify -->|UI| Browser

Supporting stack:

  • DNS: Netlify
  • App Hosting: Netlify
  • Code Hosting: GitHub
  • Infra: Terraform
  • Identity: OIDC → Entra
  • Secrets: Key Vault (eventually)

Identity & Deployment (No Secrets, No Regret)

sequenceDiagram participant GH as GitHub Actions participant Entra as Entra ID participant Azure as Azure GH->>Entra: OIDC token request Entra-->>GH: JWT GH->>Azure: Exchange token Azure-->>GH: Access token GH->>Azure: Terraform apply

This is one of those:

“Once you do it this way, you never go back”

moments.

I’m a big fan of WIF/Federated Credentials/OIDC anywhere I can use it - and if I’m going to push people to do it in my day job, I’m going to eat my own dog food too!


The Journey (Where Things Went Sideways)

1. Connection String Lies

Initial error:

password authentication failed for user "ctadmin"

Reality:

  • Password wasn’t URL encoded
  • Azure expects strict URI format

Correct format:

postgresql://admin:<encoded>@host:5432/umami?sslmode=require

2. Azure vs pgcrypto

ERROR: extension "pgcrypto" is not allow-listed

Azure managed Postgres is:

“Postgres… but with opinions”

Fix:

resource "azurerm_postgresql_flexible_server_configuration" "extensions" {
  name  = "azure.extensions"
  value = "pgcrypto"
}

3. Prisma Says “No”

P3009: migrate found failed migrations

Fix:

pnpm prisma migrate resolve --applied 01_init

Prisma doesn’t forget. Or forgive.


4. Permissions (The Sneaky One)

Even after “success”:

P1010: access denied

Fix:

ALTER DATABASE umami OWNER TO ctadmin;
ALTER SCHEMA public OWNER TO ctadmin;
GRANT ALL ON SCHEMA public TO ctadmin;

Because Azure created the DB… but not in a way your app actually wants.


5. Netlify “Helpful Magic”

Error:

publish directory cannot be same as base

Fix:

  • Remove config
  • Trust the plugin
  • Fix lockfile:
pnpm install --no-frozen-lockfile

6. CSP (The Silent Assassin)

Everything loaded.

Nothing worked.

Cause:

  • script-src ✔
  • connect-src ❌

Fix:

connect-src https://{analyticsSubdomain}.cirriustech.co.uk;

Instant recovery.


Performance: The Bit That Looks Like a DB Problem (But Isn’t)

Observed:

  • ~8s blank UI
  • ~8s data load
  • then fast

That pattern matters.

flowchart TD A[Cold Load] --> B[Runtime Spin Up] B --> C[Next.js Init] C --> D[Prisma Init] D --> E[DB Connect] E --> F[Queries] F --> G[Render] G --> H[Warm State] H --> I[Fast Navigation]

If it were the DB:

  • every query would be slow
  • not just the first

But:

  • warm = fast
  • metrics = idle

So:

The database has an alibi


Cost Reality Check

Option Cost Behaviour
B1ms £ Slow cold start, fast warm
B2s £££ Slightly faster cold, no real gain
VM (DB only) ££ Consistent but more ops
VM (full stack) £ Fast, but you own everything
Azure Container Apps hybrid ££ Interesting, but awkward split

Key insight:

Scaling the DB masked the symptom, not the cause.


What I’d Do Differently

If starting again:

1. Assume cold start first

Not database.

Always.

2. Validate with metrics before scaling

If health graphs are flat:

it’s not the DB

3. Fix CSP early

Because debugging “nothing happens” is painful.

4. Treat managed services as opinionated

Not neutral.

5. Keep architecture coherent

Avoid weird splits (e.g. ACA + VM hybrid madness)


Production Hardening Checklist

  • Lock down DB firewall
  • Move secrets to Key Vault
  • Automate Netlify env sync
  • Add uptime ping (keep warm)
  • Ignore internal traffic in Umami
  • Add event tracking
  • Enable CSP report-only

Final Result

  • Clean analytics
  • Custom domain
  • Privacy-first
  • Cheap
  • Fully controlled


Closing Thought

This started as:

“Spin up Umami”

It became:

“Understand runtime behaviour, Prisma, CSP, Azure constraints, and cold starts”

Which is, honestly, the point.

Because…

Assumptions Have Teeth

If you want more detail on the solution, feel free to leave a comment, or email me (link on homepage - while you’re there, have a look around! 😎


This is a personal blog. Opinions are my own.

comments powered by Disqus