Self-Host Umami Analytics: Fast Docker Compose Guide

by Fahim

GA4 is a bloated, confusing nightmare. It drags down your page load speeds, forces you to slap ugly cookie banners on your site, and hides basic data behind overly complex menus. Let’s fix that. We’re going to spin up Umami Analytics using Docker Compose and hook it up to a PostgreSQL database in less than ten minutes.

Docker Compose terminal logs showing Umami Analytics and PostgreSQL starting up successfully
Docker Compose terminal logs showing Umami Analytics and PostgreSQL starting up successfully

Why I Dumped GA4 for Umami

I binned GA4 after watching my PageSpeed scores tank. It was loading over 80KB of heavy JS, throwing random CORS errors on spotty mobile connections, and making me build custom reports just to see basic page views. It’s absolute overkill for developer blogs and SaaS landing pages.

Umami is the exact opposite. Its tracking script is under 2KB, loads asynchronously, and runs on a tiny single-core VPS without breaking a sweat. Best of all? You own your data. No cookie consent banners are needed because it doesn’t track users across the web or collect personally identifiable info.

My self-hosted Umami instance currently tracks over 100,000 monthly page views on a cheap $5 VPS. The dashboard loads in under 300ms, and my PageSpeed scores are back to a perfect 100. Here is how I set it up, and how you can do the same.

Setting Up the Directory Structure

I like to keep my self-hosted services organized so backups don’t turn into a scavenger hunt. We’ll create a dedicated directory for Umami to keep its configuration isolated from other tools on the server.

If you’re running multiple services on one machine, a clean layout is a lifesaver. You can read more about how I organize my environments in my guide on the best project folder structure guide.

Run these commands on your server to get the folders ready:

mkdir -p ~/docker/umami/data
cd ~/docker/umami

The data folder will hold our persistent PostgreSQL database files. This ensures your analytics data survives even if you tear down or update the Docker containers.

Writing the Docker Compose File

We need a solid, production-ready setup that links Umami to a PostgreSQL database. We’ll use the official Umami image and a stable Postgres release.

Create a docker-compose.yml file inside your ~/docker/umami directory. We’ll also add a health check on the database. Trust me, you don’t want Umami trying to connect before Postgres is actually ready to accept connections (it will crash).

Fire up your favorite text editor and paste this in:

version: '3.8' services: umami-db: image: postgres:15-alpine container_name: umami-db environment: POSTGRES_DB: ${DB_NAME} POSTGRES_USER: ${DB_USER} POSTGRES_PASSWORD: ${DB_PASSWORD} volumes: - ./data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"] interval: 5s timeout: 5s retries: 5 restart: always umami: image: ghcr.io/umami-software/umami:postgresql-latest container_name: umami ports: - "3000:3000" environment: DATABASE_URL: postgresql://${DB_USER}:${DB_PASSWORD}@umami-db:5432/${DB_NAME} APP_SECRET: ${APP_SECRET} TRACKER_SCRIPT_NAME: custom-tracker depends_on: umami-db: condition: service_healthy restart: always

This setup follows the official Docker Compose specification. Take a look at the TRACKER_SCRIPT_NAME variable. Changing this from the default script.js to something custom is a neat trick—it helps bypass aggressive ad-blockers that target standard tracking filenames.

Setting Up Environment Variables

Hardcoding database passwords directly inside your compose file is a terrible habit. Instead, we’ll use a .env file to keep our credentials secure and out of plain sight.

Create a .env file in the same directory as your docker-compose.yml:

DB_NAME=umamianalytics
DB_USER=umami_admin
DB_PASSWORD=super_secure_password_here_123
APP_SECRET=generate_a_long_random_string_here

Swap out super_secure_password_here_123 with a strong password. For the APP_SECRET, you need a random 32-character string. You can generate one instantly using openssl in your terminal:

openssl rand -hex 32

Grab that output and paste it as your APP_SECRET. Umami uses this secret to sign web tokens and secure your sessions.

Spinning It Up (And Avoiding a Common Gotcha)

With the files ready, we can spin up the containers. But this is where I hit my first roadblock during my first install.

Without a proper health check, the Umami container boots up faster than PostgreSQL can initialize its internal storage. Umami will instantly crash with a database connection error, enter a restart loop, and spam your logs with connection timeouts.

By using the service_healthy condition in our depends_on block, we force Umami to wait until Postgres is fully up and running. Let’s start the containers:

docker compose up -d

To make sure everything booted cleanly, tail the container logs. Here is what I run to monitor the startup sequence:

docker compose logs -f umami

You should see Umami running database migrations, building tables, and finally printing a message that the server is listening on port 3000. If you see schema errors, double-check that your DATABASE_URL in the .env file matches your actual database credentials.

Adding the Tracking Code to Your Site

Now that the instance is live, open your browser and head to http://your-server-ip:3000. Log in with the default credentials:

  • Username: admin
  • Password: umami

Go straight to your settings and change that default password immediately. Once that’s secure, add your website in the dashboard to generate your tracking snippet.

The dashboard will give you a tiny HTML script tag. Copy and paste it into the <head> of your website. It looks like this:

<script async src="https://your-server-ip:3000/custom-tracker.js" data-website-id="your-unique-website-uuid">
</script>

This is incredibly clean. Unlike setting up complex triggers in other self-hosted tools where you have to worry about queues, Umami just works. For example, when you build complex workflows using a self-hosted Activepieces instance, you often have to manage webhook queues. Umami handles high volumes of tracking requests asynchronously without blocking your frontend thread.

Securing Umami with a Reverse Proxy (SSL)

Running your analytics over unencrypted HTTP on port 3000 is a bad idea. It’s insecure, and modern browsers will block the script on HTTPS sites due to mixed-content security policies. We need to put Umami behind a reverse proxy to handle SSL.

I use Caddy because it automatically provisions and renews Let’s Encrypt SSL certificates with zero fuss. If you prefer Nginx or Traefik, go for it, but Caddy keeps things incredibly simple.

Here is a quick Caddyfile block to route traffic to your Umami container:

analytics.yourdomain.com { reverse_proxy localhost:3000
}

Once you reload Caddy, your tracking script will load securely over HTTPS. Don’t forget to update the src attribute in your website’s script tag to use your new secure domain instead of the raw IP address.

If you want to dive deeper into database performance as your traffic grows, check out the PostgreSQL official documentation to learn how to tune your database.

FAQ

How much memory does Umami actually use?

It’s incredibly lightweight. In my production setup, the Node.js container hovers around 60MB to 100MB of RAM, while the Postgres container uses about 30MB to 50MB under moderate load. It runs perfectly fine on the cheapest $5 VPS you can find.

Can I migrate my historical data from GA4?

Directly importing GA4 history is tough because of how differently GA4 structures its events. That said, Umami does provide API and CLI tools to import CSV data. Check out the Umami official documentation for their latest migration scripts.

How do I back up my data?

Since we mapped the Postgres data directory to the host filesystem, backups are incredibly easy. You can run a standard pg_dump inside the container to export your database to a single SQL file:

docker exec -t umami-db pg_dump -U umami_admin umamianalytics > backup.sql

I run this command daily via a simple cron job and upload the resulting SQL file to secure offsite storage.

What’s Next?

Now that your analytics are self-hosted and privacy-friendly, you can keep expanding your server stack. If you want to connect your analytics events to custom notification triggers or automated workflows, check out my guide on how to self-host n8n with PostgreSQL using Docker Compose.

all_in_one_marketing_tool