---
title: "How to Install Umami Analytics on Docker (2026 Guide)"
description: "Learn how to install Umami Analytics with Docker Compose on your VPS. Step-by-step guide to self-host this privacy-focused Google Analytics alternative."
date: 2026-07-21
categories: ["self-hosting"]
tags: ["self-hosted","docker","analytics"]
---

import Button from "../../components/widgets/Button.astro";
import { Picture } from "astro:assets";
import YouTubeEmbed from "../../components/widgets/YouTubeEmbed.astro";
import Notice from "../../components/widgets/Notice.astro";
import ListCheck from "../../components/widgets/ListCheck.astro";
import Tabs from "../../components/widgets/Tabs.astro";
import Tab from "../../components/widgets/Tab.astro";
import Accordion from "../../components/widgets/Accordion.astro";
import imag1 from "../../assets/images/24/01/cloudflare-tunel-setup.png";
import imag2 from "../../assets/images/24/02/umami-add-website.png";
import imag3 from "../../assets/images/24/02/umami-dashboard.jpeg";

[Umami](https://umami.is/) is a simple, fast, privacy-focused web analytics tool and a solid Google Analytics alternative. It's MIT-licensed, has over 37,000 GitHub stars, and collects only the metrics you need without tracking your visitors. If you want to compare options, [Plausible](https://www.bitdoze.com/plausible-tool/) and [Matomo](https://matomo.org/) are similar self-hosted analytics tools. You can also [install Plausible Analytics](https://www.bitdoze.com/install-plausible-analytics/) with a similar Docker setup.

With Google Analytics 4 and GDPR enforcement in the EU, self-hosted web analytics has become the default for operators who want full data ownership. Google Analytics scripts are heavy and slow down your site. Umami adds less than 2KB of overhead.

This guide covers how to install Umami Analytics on Docker with Docker Compose, including tracker setup, ad-blocker bypass, upgrades, and backups. Umami v3 shipped in November 2025 with a new UI, heatmaps, session replay, and it's now PostgreSQL-only (MySQL support was dropped).

<Notice type="info" title="Umami v3 (November 2025)">
Umami v3 is a major release with a redesigned UI, Segments, Cohorts, Session Replay, Heatmaps, and Web Vitals tracking. MySQL is no longer supported. PostgreSQL is the only database option. The compose files in this guide are updated for v3. See the <a href="https://umami.is/blog/umami-v3" rel="nofollow">Umami v3 blog post</a> for full details.
</Notice>

> You can find more free open source self-hosted apps at [toolhunt.net self hosted section](https://toolhunt.net/sh/).

## Steps to install Umami Analytics with Docker Compose

We'll deploy Umami on a VPS with Docker Compose, then configure Cloudflare Tunnels for public access with SSL. The video walkthrough below covers the full process.

<YouTubeEmbed
  url="https://www.youtube.com/embed/dWrgbxwIo8M"
  label="Umami Analytics install on Docker"
/>

### 1. Prerequisites

<ListCheck>
<ul>
<li>VPS with Docker and Docker Compose v2 installed. A <a href="https://go.bitdoze.com/hetzner" rel="nofollow">Hetzner</a> CX22 at ~€4/mo is sufficient (Umami + Postgres use ~300MB RAM total). <a href="https://go.bitdoze.com/hostinger-vps" rel="nofollow">Hostinger</a> is another affordable option.</li>
<li>Dockge or any Docker management tool, or just plain <code>docker compose</code>. See the <a href="https://www.bitdoze.com/dockge-install/">Dockge install guide</a> for a full walkthrough. You can also check other <a href="https://www.bitdoze.com/best-self-hosted-panels/">self-hosted server panels</a>.</li>
<li>Cloudflare Tunnel configured for your VPS (or any reverse proxy with SSL termination)</li>
<li>A domain or subdomain ready for Umami</li>
<li>PostgreSQL v12.14+ (we use v15-alpine, which satisfies this)</li>
</ul>
</ListCheck>

<Notice type="warning" title="Don't use 'analytics' as your subdomain">
Ad-blockers commonly block requests to <code>analytics.yourdomain.com</code>. Use something like <code>stats</code>, <code>metrics</code>, or <code>track</code> instead. This applies to the subdomain AND the script path.
</Notice>

### 2. Docker Compose file for Umami

Below are two compose files. The first is a clean setup with no backup, use this if you plan to back up with a cron-based `pg_dump` (recommended). The second adds a `tiredofit/db-backup` sidecar for automatic scheduled dumps.

<Tabs>
<Tab name="Simple (No Backup)">

```yaml
services:
  umami:
    image: docker.umami.is/umami-software/umami:postgresql-latest
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@umami-db:5432/${POSTGRES_DB}
      APP_SECRET: ${APP_SECRET}
      DISABLE_TELEMETRY: ${DISABLE_TELEMETRY:-1}
    depends_on:
      umami-db:
        condition: service_healthy
    init: true
    restart: always
    healthcheck:
      test: ["CMD-SHELL", "curl http://localhost:3000/api/heartbeat"]
      interval: 5s
      timeout: 5s
      retries: 5

  umami-db:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      TZ: UTC
    volumes:
      - ./umami-db-data:/var/lib/postgresql/data
    restart: always
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 5s
      timeout: 5s
      retries: 5
```

The Umami service runs on port 3000, connects to PostgreSQL over the internal Docker network, and includes a health check hitting `/api/heartbeat`. The Postgres data lives in a local volume at `./umami-db-data`. You can change the port mapping to whatever you want.

</Tab>
<Tab name="With Backup">

```yaml
services:
  umami:
    image: docker.umami.is/umami-software/umami:postgresql-latest
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@umami-db:5432/${POSTGRES_DB}
      APP_SECRET: ${APP_SECRET}
      DISABLE_TELEMETRY: ${DISABLE_TELEMETRY:-1}
    depends_on:
      umami-db:
        condition: service_healthy
    init: true
    restart: always
    healthcheck:
      test: ["CMD-SHELL", "curl http://localhost:3000/api/heartbeat"]
      interval: 5s
      timeout: 5s
      retries: 5

  umami-db:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      TZ: UTC
    volumes:
      - ./umami-db-data:/var/lib/postgresql/data
    restart: always
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 5s
      timeout: 5s
      retries: 5

  umami-db-backup:
    container_name: umami-db-backup
    image: tiredofit/db-backup
    volumes:
      - ./backups:/backup
    environment:
      DB_TYPE: postgres
      DB_HOST: umami-db
      DB_NAME: ${POSTGRES_DB}
      DB_USER: ${POSTGRES_USER}
      DB_PASS: ${POSTGRES_PASSWORD}
      DB_BACKUP_INTERVAL: 720
      DB_CLEANUP_TIME: 72000
      CHECKSUM: SHA1
      COMPRESSION: GZ
      CONTAINER_ENABLE_MONITORING: false
    depends_on:
      umami-db:
        condition: service_healthy
    restart: always
```

The backup sidecar dumps the database every 12 hours and cleans up backups older than 50 days. Dumps land in `./backups`. The `tiredofit/db-backup` image is heavier than a simple cron. If you want something lighter, see the [backup and restore section](#database-backup-and-restore) for a one-liner `pg_dump` alternative.

</Tab>
</Tabs>

<Notice type="info" title="Pin your image version">
<code>postgresql-latest</code> now points to Umami v3 (currently v3.2.0 as of June 2026). For reproducible deploys, pin to a specific tag like <code>3.2.0</code>. Note that v3 tags dropped the <code>v</code> prefix. It's <code>3.2.0</code>, not <code>v3.2.0</code>. See <a href="https://hub.docker.com/r/umamisoftware/umami/tags" rel="nofollow">Docker Hub tags</a> for all available versions. The image is also available on GHCR (<code>ghcr.io/umami-software/umami:latest</code>) and Docker Hub (<code>umamisoftware/umami:postgresql-latest</code>).
</Notice>

If you need to [run multiple PostgreSQL databases](https://www.bitdoze.com/multiple-postgres-databases-docker/) on the same host, you can consolidate them into a single Postgres container to save memory.

### 3. Configure the .env file

Create a `.env` file in the same directory as your compose file (or add the variables in Dockge's environment section).

```sh
POSTGRES_USER=umami
POSTGRES_PASSWORD=your-secure-password
POSTGRES_DB=umami
APP_SECRET=run-openssl-rand-hex-32-to-generate
DISABLE_TELEMETRY=1
```

<Notice type="warning" title="Generate a unique APP_SECRET">
Never use the example value. Generate a secure secret with:<br /><code>openssl rand -hex 32</code><br />Paste the output as your <code>APP_SECRET</code>. This is used for session encryption.
</Notice>

### 4. Deploy Umami

If using Dockge, add a name for your stack and hit deploy. With plain Docker Compose:

```sh
docker compose up -d
```

Verify both containers are healthy:

```sh
docker compose ps
```

You should see both `umami` and `umami-db` with status `healthy`. Check the Umami logs to confirm migrations ran:

```sh
docker compose logs -f umami
```

Healthy output looks like: `All migrations have been successfully applied` followed by the server listening on port 3000.

Access Umami at `http://your-vps-ip:3000`. Log in with the default credentials:

- **Username:** `admin`
- **Password:** `umami`

<Notice type="error" title="Change the default password now">
The default credentials <code>admin</code> / <code>umami</code> are public knowledge. Go to <strong>Settings → Profile → Change password</strong> immediately, before exposing Umami to the internet.
</Notice>

Quick verification with curl:

```sh
curl http://localhost:3000/api/heartbeat
# Should return: OK
```

### 5. Configure Cloudflare Tunnels for Umami

Go to **Access → Tunnels** in the Cloudflare dashboard, choose your tunnel, and add a hostname mapping a domain or subdomain to the Umami service on port 3000.

<Picture
  src={imag1}
  alt="Cloudflare Tunnel setup for Umami Analytics"
/>

<Notice type="info" title="Getting 'Unknown' visitor IPs?">
If Umami shows "Unknown" for visitor locations behind Cloudflare Tunnels, add <code>CLIENT_IP_HEADER=cf-connecting-ip</code> to the <code>umami</code> service environment block in your compose file. This tells Umami to read the real client IP from Cloudflare's header. Restart the container after the change.
</Notice>

> You can also check [Setup CloudPanel as Reverse Proxy with Docker and Dockge](https://www.bitdoze.com/cloudpanel-setup-dockge/) to use CloudPanel as a reverse proxy to your Docker containers, or [self-host with Docker and Cloudflare Tunnels](https://www.bitdoze.com/cloudreve-docker-setup/) for another deployment example.

### 6. Add your first website to Umami

After the tunnel is configured, access Umami at your domain. Log in and go to **Settings** in the header. You'll see a button to add a website:

<Picture
  src={imag2}
  alt="Adding a website in Umami Analytics"
/>

After adding a website, click **Edit Website** (or go to **Settings**) to find your tracking code. You can paste this into your site's `<head>`.

<Picture
  src={imag3}
  alt="Umami Analytics dashboard overview"
/>

## Umami tracking code and tracker configuration

Once your website is added, Umami gives you a tracking script. Here's how to use it and what options are available.

### Basic tracking script

The standard embed looks like this:

```html
<script
  defer
  src="https://your-umami-domain.com/script.js"
  data-website-id="your-website-id"
></script>
```

- `data-website-id` (the UUID of the website you added in Umami, visible in the tracking code snippet)
- `data-host-url` (set this if your tracker domain differs from the dashboard domain, e.g., you serve the script from a CDN)

### Custom events and advanced options

Umami supports both programmatic and declarative event tracking.

**Programmatic** (call from JavaScript):

```js
// Track a custom event with properties
umami.track("signup", { plan: "pro", source: "header" });
```

**Declarative** (add HTML attributes to any element):

```html
<button data-umami-event="click-download" data-umami-file="whitepaper.pdf">
  Download
</button>
```

Other useful data attributes:

| Attribute | Purpose |
|-----------|---------|
| `data-domains="example.com,api.example.com"` | Restrict tracking to specific domains |
| `data-do-not-track="true"` | Respect the browser's DNT header |
| `data-auto-track="false"` | Disable automatic pageview tracking (useful for SPAs that handle their own routing) |
| `data-performance="true"` | Track Core Web Vitals (new in v3.1.0) |
| `data-auto-pageview="false"` | Suppress automatic pageview on script load (new in v3.2.0) |

> If you're using an Astro site, see [Plausible Analytics for Astro with Cloudflare Workers](https://www.bitdoze.com/astro-plausible-cloudflare-workers/) for a related analytics integration approach.

### Bypass ad blockers

Ad-blockers target two default paths: `/script.js` and `/api/send`. You can rename both with environment variables:

```env
TRACKER_SCRIPT_NAME=custom-tracker
COLLECT_API_ENDPOINT=/api/collect
```

Add these to the `umami` service environment in your compose file, then restart. Update your tracking script `src` to match:

```html
<script
  defer
  src="https://your-domain.com/custom-tracker.js"
  data-website-id="your-website-id"
></script>
```

<Notice type="info" title="Ad-blocker bypass requires compose changes">
After adding <code>TRACKER_SCRIPT_NAME</code> and <code>COLLECT_API_ENDPOINT</code> to your compose file, restart the Umami container: <code>docker compose up -d --force-recreate umami</code>. Make sure any reverse proxy rules pass through the new endpoint path.
</Notice>

<Accordion label="Full ad-blocker bypass example" group="tracker">

Here's a complete snippet to add to the `umami` service environment in your compose file:

```yaml
environment:
  DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@umami-db:5432/${POSTGRES_DB}
  APP_SECRET: ${APP_SECRET}
  DISABLE_TELEMETRY: ${DISABLE_TELEMETRY:-1}
  TRACKER_SCRIPT_NAME: my-stats
  COLLECT_API_ENDPOINT: /api/c
  CLIENT_IP_HEADER: cf-connecting-ip
```

Then in your site's HTML:

```html
<script
  defer
  src="https://stats.yourdomain.com/my-stats.js"
  data-website-id="your-website-id"
></script>
```

If you use a reverse proxy (Nginx, Caddy, Traefik), ensure the custom paths are forwarded to the Umami container on port 3000. No extra routing config is needed if the proxy sends all traffic to Umami.

</Accordion>

## How to update Umami Analytics on Docker

### Standard updates (same major version)

For minor updates within v3.x:

```bash
# 1. Back up the database first
docker compose exec -T umami-db pg_dump -U umami umami | gzip > backups/umami-$(date +%F-%H%M).sql.gz

# 2. Pull the latest image
docker compose pull

# 3. Recreate containers
docker compose up -d --force-recreate

# 4. Verify: look for "All migrations have been successfully applied"
docker compose logs -f umami
```

### Upgrading from Umami v2 to v3

<Notice type="warning" title="Breaking changes in Umami v3">
v3 drops MySQL support, has a completely new UI, and runs schema migrations that can take time on large databases. <strong>Always back up before upgrading.</strong> If you were on MySQL, follow the <a href="https://docs.umami.is/docs/guides/migrate-mysql-postgresql" rel="nofollow">MySQL to PostgreSQL migration guide</a> first.
</Notice>

Steps:

```bash
# 1. Back up
docker compose exec -T umami-db pg_dump -U umami umami > umami-backup-$(date +%F-%H%M).sql

# 2. Pull and recreate
docker compose pull
docker compose up -d --force-recreate umami

# 3. Watch logs for migration output
docker compose logs -f umami

# 4. Run ANALYZE on PostgreSQL (critical after major upgrades)
docker compose exec -T umami-db psql -U umami umami -c "ANALYZE;"
```

The `ANALYZE` command updates PostgreSQL's query planner statistics after schema migrations. The [official Umami docs](https://docs.umami.is/docs/updates) recommend this. Without it, dashboard queries can become slow after a major upgrade.

After upgrading, old Docker images accumulate on disk. You can [clean up Docker disk space](https://www.bitdoze.com/clean-docker-overlay2-dir/) to reclaim storage.

### Database backup and restore

<Notice type="error" title="Always back up before upgrading">
A failed migration can corrupt data. Always run <code>pg_dump</code> before pulling a new major version.
</Notice>

<Tabs>
<Tab name="Manual pg_dump">

**Backup** (one-liner, can be cron'd):

```bash
docker compose exec -T umami-db pg_dump -U umami umami | gzip > backups/umami-$(date +%F).sql.gz
```

Add to crontab for daily backups at 3 AM:

```bash
0 3 * * * cd /path/to/umami && docker compose exec -T umami-db pg_dump -U umami umami | gzip > backups/umami-$(date +\%F).sql.gz
```

**Restore:**

```bash
gunzip < backups/umami-YYYY-MM-DD.sql.gz | docker compose exec -T umami-db psql -U umami umami
```

For offsite backups, push the compressed dump to any S3-compatible storage (MinIO, Cloudflare R2, Backblaze B2).

</Tab>
<Tab name="tiredofit/db-backup">

If you used the "With Backup" compose file, dumps are in the `./backups` directory, created every 12 hours. To restore from a tiredofit backup:

```bash
# Find the latest backup
ls -lt backups/

# Restore it
zcat backups/umami-db-*.sql.gz | docker compose exec -T umami-db psql -U umami umami
```

The `tiredofit/db-backup` sidecar is heavier than a cron-based approach (it runs its own scheduler, compression, and cleanup). For a minimal footprint, the manual `pg_dump` cron is lighter.

</Tab>
</Tabs>

## Verify and troubleshoot your Umami installation

<ListCheck>
<ul>
<li><code>docker compose ps</code>: both <code>umami</code> and <code>umami-db</code> show status <code>healthy</code></li>
<li><code>docker compose logs umami</code>: shows "All migrations have been successfully applied"</li>
<li><code>curl http://localhost:3000/api/heartbeat</code>: returns OK</li>
<li>Login works with <code>admin</code> / <code>umami</code> (change password immediately)</li>
<li>Embed the tracking script on a test page, open browser DevTools → Network tab, confirm <code>script.js</code> loads (200 OK)</li>
<li>Visit the test page, check Umami dashboard for a real-time visit</li>
</ul>
</ListCheck>

If you want to [monitor your server resources](https://www.bitdoze.com/sever-monitoring/) alongside Umami, set up a lightweight monitoring stack.

<Accordion label="Container keeps restarting" group="troubleshoot">

Check `docker compose logs umami`. The most common cause is a wrong `DATABASE_URL` or the database container isn't healthy yet. Verify your `.env` variables match what the compose file references. The `depends_on` condition should wait for the DB health check, but if the DB is slow to start on a low-memory VPS, give it 30 seconds.

</Accordion>

<Accordion label="'service umami-db not found' error" group="troubleshoot">

A typo in `depends_on`. Make sure it says `umami-db` (with "m"), not `unami-db`. This was a bug in earlier versions of this guide's compose file.

</Accordion>

<Accordion label="Blank or slow dashboard after upgrade" group="troubleshoot">

After a major version upgrade (especially v2 → v3), PostgreSQL's query planner may have stale statistics. Run:

```bash
docker compose exec -T umami-db psql -U umami umami -c "ANALYZE;"
```

</Accordion>

<Accordion label="Visitor IPs show as 'Unknown'" group="troubleshoot">

When Umami runs behind Cloudflare Tunnels or a reverse proxy, the client IP doesn't reach it directly. Add to the `umami` service environment:

```env
CLIENT_IP_HEADER=cf-connecting-ip
```

Use `x-forwarded-for` if you're behind Nginx or Traefik instead of Cloudflare. Restart the container after the change.

</Accordion>

<Accordion label="Tracking script blocked by ad-blockers" group="troubleshoot">

Default paths `/script.js` and `/api/send` are on blocklists. Use `TRACKER_SCRIPT_NAME` and `COLLECT_API_ENDPOINT` environment variables to rename them. See the [bypass ad blockers section](#bypass-ad-blockers) above.

</Accordion>

<Accordion label="Out of memory / OOM kills" group="troubleshoot">

Umami uses ~200MB RAM, Postgres ~50-100MB. A 1GB VPS is the practical minimum. Check resource usage with `docker stats`. If containers are getting OOM-killed, consider adding memory limits in compose:

```yaml
deploy:
  resources:
    limits:
      memory: 256M
```

</Accordion>

## Umami v3: what's new

| Version | Date | Highlights |
|---------|------|-----------|
| v3.0.0 | Nov 2025 | New UI, Segments, Cohorts, Links, Pixels, Admin page. PostgreSQL-only (MySQL dropped). |
| v3.1.0 | Apr 2026 | **Boards**, **Session Replay**, **Web Vitals**, redesigned share page, OR filters, regex, funnels. Requires Node 22. |
| v3.2.0 | Jun 2026 | **Heatmaps**, improved Session Replay, event/session property reporting, `data-auto-pageview` attribute. |

<Notice type="info" title="Self-hosting vs Umami Cloud">
Self-hosted Umami = unlimited events, full data ownership, free (MIT license). Umami Cloud starts at free (100K events/mo, 1 website) but the Pro plan is $20/mo for 1M events and 20 websites. For a solo operator running a few sites, self-hosting on a ~€4/mo VPS is the clear winner.
</Notice>

PostgreSQL v12.14+ is required for v3. The `postgres:15-alpine` image used in this guide satisfies this.

## Conclusion

You now have Umami Analytics installed on Docker, with a working tracker configuration, ad-blocker bypass options, and a solid upgrade and backup workflow. Umami is free, uses about 300MB of RAM total, and gives you full ownership of your analytics data. No third-party access, no GDPR headaches.

Key things to remember: back up before every major upgrade, pin your image version for reproducibility, and change the default password immediately after first login. If you want to explore other options, you can also [install Plausible Analytics](https://www.bitdoze.com/install-plausible-analytics/) with a similar Docker approach, or [monitor your server resources](https://www.bitdoze.com/sever-monitoring/) to keep an eye on your VPS.

<Button text="Explore More Self-Hosted Tools" link="https://toolhunt.net/sh/" variant="outline" color="blue" size="md" />