Use case · Web analytics

Umami self hosted on a dedicated server

Self-hosting Umami means one Docker Compose stack, the Umami app plus a PostgreSQL 15 container, on a Linux machine you control. Version 3 dropped MySQL, so PostgreSQL is the only database. Bind the app to 127.0.0.1, replace the default admin/umami password over an SSH tunnel, and give a TLS proxy ports 80 and 443 after that, not before.

See the machines

What Umami needs

ItemRequirementStated in
DatabasePostgreSQL v12.14 or later; MySQL removed in v3Installation page, FAQ, MySQL migration guide
Source installNode.js 18.18 or laterInstallation page
Docker routeUmami image plus postgres:15-alpinedocker-compose.yml
Required settingsDATABASE_URL (the only mandatory variable); a unique APP_SECRET per installEnvironment variables page
Port3000 inside the containerdocker-compose.yml, Dockerfile
OptionalREDIS_URL caching (v3.0.1+); TWO_FACTOR_ENCRYPTION_KEY for 2FA (v3.3.0+)Environment variables page

No CPU or RAM minimum is published, so disk is the figure to plan: the database grows with every pageview and a self-hosted install keeps data until you delete it (FAQ). The docs recommend running the database in UTC. An instance still on MySQL moves to PostgreSQL at v2.19.0 with the official migration guide before it can take v3.

Choosing the machine

Umami is one Node.js process and one database, so a single-job machine is enough to start. In the dedicated server range, the Intel Xeon E3-1585L v5 cartridge fits: 4 cores at 3 GHz, NVMe storage, a 1 Gbps unmetered port and, being an m710x, its own out-of-band console. The m510 cartridges carry a console as well; the remaining cartridges have power control only. Rule out the X-Gene ARM cartridge: it boots Ubuntu 16.04 and 18.04 and nothing else, too old for the Docker Engine 28 this build checks for.

If the dashboard will share a box with the sites it measures, take a single- or dual-socket rack server; every racked one carries a full out-of-band console. The hardware is yours alone, with root, on our own network, AS19624. Ready servers are live about 30 minutes once the payment is approved, and builds to order take 4 to 24 hours. Choose the city nearest the people you measure, from the five on the locations page.

Secrets and the Compose stack

Start from a fresh Ubuntu 24.04 machine (the Caddy step below needs 24.04: Caddy is not in 22.04's archive) where Docker Engine at version 28 or later, with the Compose plugin, is installed per Docker's Ubuntu instructions. The block below creates a root-only /opt/umami/.env (mode 600 before a byte is written) holding a random database password, APP_SECRET and the 2FA key; printf is a shell builtin, so the values never show in ps. The secrets are generated in memory first, but nothing is written when .env already exists, when the Engine predates version 28, or when a secret has the wrong length: a fresh database password would no longer match the volume PostgreSQL initialised with the first one. Only when all three checks pass does it write .env and compose.yaml and start the stack.

# one-time setup; Engine 28+
v=$(sudo docker version --format '{{.Server.Version}}')
p=$(openssl rand -hex 24); s=$(openssl rand -hex 32); k=$(openssl rand -hex 32)
if [ "${v%%.*}" -ge 28 ] 2>/dev/null && [ ! -e /opt/umami/.env ] && [ "${#p}${#s}${#k}" = 486464 ]; then
sudo mkdir -p /opt/umami && sudo install -m 600 /dev/null /opt/umami/.env &&
printf 'POSTGRES_PASSWORD=%s\nDATABASE_URL=postgresql://umami:%s@db:5432/umami\nAPP_SECRET=%s\nTWO_FACTOR_ENCRYPTION_KEY=%s\n' "$p" "$p" "$s" "$k" | sudo tee /opt/umami/.env >/dev/null &&
sudo tee /opt/umami/compose.yaml >/dev/null <<'EOF' &&
services:
  umami:
    image: ghcr.io/umami-software/umami:3
    ports:
      - "127.0.0.1:3000:3000"
    env_file: .env
    depends_on:
      db:
        condition: service_healthy
    init: true
    restart: always
    healthcheck:
      test: ["CMD-SHELL", "curl http://localhost:3000/api/heartbeat"]
      interval: 5s
      timeout: 5s
      retries: 5
  db:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: umami
      POSTGRES_USER: umami
    env_file: .env
    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
volumes:
  umami-db-data:
EOF
sudo docker compose -f /opt/umami/compose.yaml up -d
else echo "not started: Docker '$v' below 28, .env exists, or a secret came out the wrong length"; fi
  • Our edits to the official docker-compose.yml, the complete list: image tag latest became 3, so a future major version only arrives when you edit it; port 3000:3000 became 127.0.0.1:3000:3000; the inline DATABASE_URL, APP_SECRET and TWO_FACTOR_ENCRYPTION_KEY placeholders and the umami database password moved into .env with generated values, read through env_file. Everything else is as shipped.
  • Why loopback: an unaddressed mapping listens on 0.0.0.0 and [::]. Docker's packet rules send IPv4 traffic past ufw; the same happens on IPv6 when the Docker daemon has its own IPv6 networking enabled; otherwise the [::] listener is the docker-proxy process, which ufw still filters. On Engine 28+ a 127.0.0.1 mapping answers on this machine only. The database publishes no port.
  • Anyone allowed to run docker inspect here can read the secrets from the container environment, so keep the docker group to admins.

At start the container runs scripts/start-docker.sh, which calls scripts/check-db.js: it checks the database version and applies migrations with prisma migrate deploy. The installation page says the first database setup also creates the admin account.

Admin password over a tunnel

  1. On your workstation: ssh -L 13000:127.0.0.1:3000 you@SERVER_IP, through your own login.
  2. Open http://localhost:13000 and log in with username admin, password umami, the account every new install gets.
  3. Profile button, Settings, Profile, Change password: set a long random one.
  4. Security tab: enable two-factor authentication. The key it requires is already in .env.
  5. Exit the tunnel. Nothing has a public route yet.

Firewall, TLS and nightly backups

With the password changed, point an A record (and AAAA, if you took IPv6 at checkout) for your stats host at the server and run this second block. It sets ufw to write IPv6 rules, allows OpenSSH (use your own port if SSH is moved), enables ufw without the prompt, then opens 80 and 443: Caddy runs on the host, so without that rule it is blocked on both address families and cannot get its certificate. Caddy is our choice of proxy, packaged in Ubuntu 24.04's universe archive. The block then installs /opt/umami/backup.sh (root, 700), a cron entry for 03:15, and takes the first backup.

# Run only after the admin password has been changed over the tunnel.
# Firewall (IPv6 rules on), Caddy for TLS, backup script, nightly cron, first backup.
# Replace stats.example.com with your DNS name first.
sudo sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw &&
sudo ufw allow OpenSSH && sudo ufw --force enable && sudo ufw allow 80,443/tcp &&
sudo apt install -y caddy &&
printf '%s {\n  reverse_proxy 127.0.0.1:3000\n}\n' stats.example.com | sudo tee /etc/caddy/Caddyfile >/dev/null &&
sudo systemctl reload caddy &&
sudo install -m 700 /dev/null /opt/umami/backup.sh &&
sudo tee /opt/umami/backup.sh >/dev/null <<'EOF' &&
set -euo pipefail
umask 077
d=/var/backups/umami; t=$(date +%F)
dc() { docker compose -f /opt/umami/compose.yaml "$@"; }
mkdir -p -m 700 "$d"
dc exec -T db pg_dump -U umami -d umami -Fc > "$d/umami-db-$t.dump"
dc exec -T db pg_restore --list < "$d/umami-db-$t.dump" > /dev/null
tar -czf "$d/umami-files-$t.tar.gz" -C / opt/umami/compose.yaml opt/umami/.env opt/umami/backup.sh etc/caddy/Caddyfile
tar -tzf "$d/umami-files-$t.tar.gz" > /dev/null
[ -s "$d/umami-db-$t.dump" ] && find "$d" -name 'umami-*' -mtime +14 -delete
echo "backup ok $t"
EOF
printf '%s 3 * * * root bash /opt/umami/backup.sh > /var/log/umami-backup.log 2>&1\n' 15 | sudo tee /etc/cron.d/umami-backup >/dev/null &&
sudo bash /opt/umami/backup.sh

The script stops at the first failed command, verifies the dump with pg_restore --list and the archive with tar -tzf, and only then prunes sets older than 14 days. Umami keeps everything in PostgreSQL and the app container holds no state, so the dump plus .env, compose.yaml, the script and the Caddyfile is a complete set. Pull /var/backups/umami to a second machine every day with rsync -a and no --delete, and expire old copies there with a separate job.

Sites, tracking code and tracker settings

  1. Websites, Add website: any display name, plus the real domain, which Umami uses to drop your own site from the referrer report.
  2. Edit that website and copy the snippet under Tracking code into each page's head: a single deferred script element whose source is /script.js on your stats host, carrying a data-website-id.
  3. Load a page; the docs say data should appear on the dashboard at once. If not, check the browser's Network tab for the script and the /api/send request.
Setting in .envWhat the docs say it does
TRACKER_SCRIPT_NAMEServes the tracker under another file name or path than script.js, against some ad blockers
COLLECT_API_ENDPOINTMoves collection off /api/send; the tracker follows it
IGNORE_IPIPs and CIDR ranges that are never recorded
DISABLE_TELEMETRYStops Umami's anonymous telemetry
PRIVATE_MODEBlocks every external call, website icons included

Apply a change with sudo docker compose -f /opt/umami/compose.yaml up -d --force-recreate umami. Caddy forwards the visitor address in X-Forwarded-For, which Umami reads (src/lib/ip.ts); CLIENT_IP_HEADER covers proxies with a non-standard header. For many hostnames behind one front end, see the Nginx Proxy Manager use case.

Restoring a backup

This overwrites the live database, so it first dumps the current one to before-restore.dump and checks both files. Run it in a root shell and replace DATE.

# Live restore, as root (sudo -i). Set f to the dump you want back.
# Refuses if the safety dump already exists; keeps it for rollback.
f=/var/backups/umami/umami-db-DATE.dump
b=/var/backups/umami/before-restore.dump
dc="docker compose -f /opt/umami/compose.yaml"
if [ ! -e "$b" ] && $dc exec -T db pg_restore --list < "$f" >/dev/null &&
   $dc exec -T db pg_dump -U umami -d umami -Fc > "$b" &&
   $dc exec -T db pg_restore --list < "$b" >/dev/null; then
  $dc stop umami &&
  $dc exec -T db pg_restore -U umami -d umami --clean --if-exists --no-owner < "$f" &&
  $dc up -d umami && sleep 15 &&
  curl -fsS http://127.0.0.1:3000/api/heartbeat && echo "restored; $b kept for rollback"
else
  echo "stopped: $f unreadable, or $b exists or failed its check"
fi

Rollback: stop umami, run the same pg_restore --clean line with $b in place of $f, then bring it back with $dc up -d umami. Delete before-restore.dump only when you are satisfied; while it exists, the block refuses to run. If the heartbeat check fails straight after the restore, give the migrations a minute and retry it.

On a replacement server, order matters: the original .env must be on disk before the db container first starts.

Restoring onto a replacement server, as root: install Docker Engine 28+ as above; place both files of one set (the umami-db-DATE.dump and the umami-files-DATE.tar.gz) in /var/backups/umami; extract the archive with tar -xzf /var/backups/umami/umami-files-DATE.tar.gz -C / --exclude=etc/caddy/Caddyfile (block 2 rewrites the Caddyfile, and a pre-placed one would stop apt install caddy at a config-file prompt) before the db container has ever started, so the original .env sets the database password on first initialisation; bring up the database alone: docker compose -f /opt/umami/compose.yaml up -d db; run the restore block; then run the second setup block again for ufw, Caddy and the cron entry, with stats.example.com replaced by your DNS name as before.

Updates, licence and privacy

Back up, then use the documented Docker update: sudo docker compose -f /opt/umami/compose.yaml pull followed by the same file with up -d --force-recreate. Migrations apply on start through check-db.js. After a major upgrade the update page advises refreshing planner statistics: sudo docker compose -f /opt/umami/compose.yaml exec db psql -U umami -d umami -c 'ANALYZE;'. A v4 arrives only when you change the 3 tag yourself, after reading its release notes. postgres:15-alpine stays on major 15; a later major needs a dump and restore, not a tag edit.

Licence, read generally (our reading, not legal advice): the repository's LICENSE is MIT. You may run, modify and redistribute Umami, commercially included; any copy you pass on carries the MIT copyright line and permission notice. There is no obligation to release source, and running it for your own sites owes nothing.

Privacy, only as far as the docs go: the FAQ states the tracker sets no cookies, leaves the cookie-banner question to the other services a site uses and the requirements that apply, and says GDPR compliance turns on configuration and use. It records pageviews, referrers, browsers, operating systems, device types and approximate location. Anything further is a question for your own adviser.

Questions

Which login does a fresh Umami install create?

Username admin, password umami, created when Umami first sets up its database. The docs say to change it right after the first login. Do it over an SSH tunnel to the loopback port, before any proxy gives the dashboard a public name.

Can a current Umami still run on MySQL?

No. Version 3 removed MySQL and supports PostgreSQL only, minimum v12.14. A MySQL instance is first brought to v2.19.0, migrated into a new PostgreSQL database with the official guide, and then upgraded.

Does the Umami tracker use cookies?

Its documentation says the web tracker does not. A site may still need a notice because of other services on it and the requirements that apply to it; that is outside what Umami's docs decide.

What if ad blockers drop the script?

Set TRACKER_SCRIPT_NAME to serve the tracker under a different name or path, and COLLECT_API_ENDPOINT to move /api/send; the tracker picks up the new endpoint itself. Recreate the container, then update the script source in your pages.

How long does a self-hosted instance keep data?

Indefinitely unless you delete it, per the FAQ. Size the disk for the database volume plus 14 days of dumps, and watch free space.

Can one Umami instance track several websites?

Yes. Each site added under Websites gets its own ID and tracking code. The data-domains attribute restricts the tracker to listed hostnames, which keeps staging traffic out of the numbers.

Run your analytics on your own hardware

Pick a line with a console and an unmetered port, then run the two setup blocks above.

See the machines

Related on Server Room

Other use cases