What you end up with
Umami counts visits to your websites without cookies and shows them on a dashboard you own. By the end you will have:
- Umami and its PostgreSQL 15 database running in Docker, coming back by themselves after a reboot;
- the app answering only on the server itself (127.0.0.1, port 3000), with Caddy in front doing HTTPS;
- a firewall that lets in SSH and web traffic and nothing else;
- a nightly backup of the database and of the files needed to use it, checked before anything old is removed.
Umami's documentation gives no minimum CPU or memory figure. What it does state: PostgreSQL 12.14 or newer, no MySQL from version 3 onwards, and on your own server every record stays until you remove it. So the database only grows, and disk space is what to keep an eye on.
Will a small server do?
For most sites, yes. Umami is one app plus one database, and nothing else needs the machine. Among our dedicated servers, the small Intel Xeon E3-1585L v5 server (an HPE m710x cartridge) is a sensible first pick: NVMe storage, a 1 Gbps port with no traffic allowance, and a console of its own, so you can still see the screen when a bad rule cuts off SSH. The m510 cartridges have one too; the other cartridges give you a power switch and nothing more, while rack servers and blades all come with the full console.
Leave the ARM cartridge for other jobs: it can only run Ubuntu 16.04 or 18.04, far older than anything Docker Engine 28 supports. A ready server is already racked, so yours is handed over within 30 minutes of approval; the instant servers list shows what is waiting right now. There is no contract, you get one IPv4 address and free IPv6, and you can reinstall the system yourself, any number of times.
Step one: secrets and the app
Begin with a clean Ubuntu 24.04 system (step three installs Caddy, which Ubuntu 22.04 does not package) plus a Docker Engine release from 28 onward, with its Compose plugin, from Docker's official package source. Paste the block below. It creates /opt/umami/.env, readable by root alone from the moment it exists, with a random database password, an APP_SECRET (the docs want a unique random one per install) and a key for two-factor sign-in. Paste it twice and the second run writes nothing, because new secrets would no longer match the database created with the first ones. It goes ahead only if Docker is new enough, no .env exists yet and every generated secret has the right length; if any of those fails, it says so and stops before writing a file.
# Step 1: runs once only
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
Every change we made to Umami's docker-compose.yml: the image is pinned to the 3 tag rather than latest; the app is published on 127.0.0.1 alone rather than every address; the placeholder secrets and the stock umami database password now come from the generated .env through env_file. Nothing else is different.
Why bind to 127.0.0.1? Leave the address out and Docker listens on 0.0.0.0 and [::]: IPv4 connections skip ufw entirely, and IPv6 ones skip it too once IPv6 support is switched on inside Docker (without that, a docker-proxy process answers on [::] and ufw does see it). From Docker Engine 28, a 127.0.0.1 port stays local to the machine.
Your first sign-in, done privately
Each new Umami creates a user named admin whose password is umami. Replace it while the dashboard has no public address yet:
- On your laptop, run
ssh -N -L 3001:127.0.0.1:3000 yourname@your.server.ip, signing in as yourself. - Visit
http://localhost:3001and sign in as admin / umami. - Click your profile button, then Settings, Profile and Change password.
- Worth doing too: enable two-factor sign-in on the Security tab. The key it needs is already in your
.env. - End the SSH session with Ctrl+C. Now the dashboard can go public.
Each time the container starts, its start script (scripts/start-docker.sh in the repository) runs scripts/check-db.js, which confirms the database version and applies any waiting migrations before the app comes up.
Step three: going public, with backups switched on
Create a DNS record for your stats address, say stats.yourdomain.com, pointing at the server; if you took the free IPv6, add the AAAA record as well. Then paste this. It switches on ufw with IPv6 rules, lets in SSH and then web traffic (Caddy runs directly on the server, so it needs ports 80 and 443 open for visitors and for its certificate), installs Caddy, which is our pick and comes from Ubuntu 24.04's own archive, and writes its two-line config. It also installs the backup script and a nightly job, then runs a first backup so you know it works.
# Step 3, after your new admin password is set. Swap in your own stats address.
# Turns the firewall on, adds HTTPS, sets up nightly backups and runs the first one.
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.yourdomain.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' 40 | sudo tee /etc/cron.d/umami-backup >/dev/null &&
sudo bash /opt/umami/backup.sh
Umami stores all of its data in the database. Together with .env, compose.yaml, the backup script and the Caddyfile, that nightly dump covers everything. You manage the server; we keep the hardware and the network running. Copying the backups somewhere else is therefore your job. Each day, pull /var/backups/umami onto a different machine with rsync -a, without --delete (an emptied folder could otherwise erase your copies), and tidy old files on that machine with its own job.
Adding your websites
- In the dashboard, open Websites and press Add website. Give it a name of your choice and type in the site's domain.
- Press Edit on it and copy what sits under Tracking code. It is one script tag holding your website ID; paste it into the head section of your pages.
- Open your site in a browser. The visit should show up on the dashboard straight away.
A few documented settings are handy. Add them to .env, then from /opt/umami run sudo docker compose up --detach --force-recreate --no-deps umami:
TRACKER_SCRIPT_NAMErenames script.js andCOLLECT_API_ENDPOINTmoves /api/send. Both exist because some ad blockers stop the defaults. Change the script address in your pages to match.IGNORE_IPlists addresses or ranges that are never counted, your office for example.DISABLE_TELEMETRY=1switches off Umami's anonymous usage reports, andDISABLE_UPDATES=1its new-version check.
Prefer to manage proxies from a browser? See Nginx Proxy Manager on your own server.
Bringing a backup back
Restoring replaces the live database, so the block saves what is there first and checks both files before touching anything. Become root with sudo -i, fill in the date, then paste:
# Root shell first: sudo -i. Put the date of the backup you want in f.
# Saves the current data as pre-restore.dump and checks it before overwriting.
f=/var/backups/umami/umami-db-DATE.dump
b=/var/backups/umami/pre-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
Not what you expected? Stop the app, run the pg_restore --clean line again with $b instead of $f, and recreate it with $dc up -d umami. Once you are happy, delete pre-restore.dump; until you do, the block will not run again. If the heartbeat check fails right after the restore, wait a minute for migrations and try it once more.
Moving to another server? Work as root, and keep the order: PostgreSQL reads its password from .env only when it first creates its data, so the original file must be unpacked before the database container runs for the first time.
- Set up a Docker Engine release from 28 onward, with its Compose plugin.
- Put the two files from one night,
umami-db-DATE.dumpandumami-files-DATE.tar.gz, in the folder/var/backups/umami. - From inside
/var/backups/umami, unpack the settings, leaving the old Caddyfile out:tar -C / --exclude=etc/caddy/Caddyfile -xzf umami-files-DATE.tar.gz. Step three writes a fresh one, and an old copy already in place would make the Caddy install stop and ask what to do with it. - Start the database on its own:
cd /opt/umami && docker compose up -d db. - Paste the restore block above.
- Paste step three again to bring back the firewall, Caddy and the nightly job.
Staying current, and the fine print
To update, take a fresh backup, go to /opt/umami and follow the documented routine: sudo docker compose pull, and after it sudo docker compose up --detach --force-recreate. Migrations run as the app starts. After a big version jump, Umami's update page suggests refreshing PostgreSQL's statistics: open sudo docker compose exec db psql -U umami umami and type ANALYZE;. Because the image is pinned to 3, the next major version waits until you change the tag on purpose. The database stays on PostgreSQL 15 for the same reason; a newer major release of it means a dump and restore, never just a new tag.
About the licence, in general terms and not as legal advice: Umami is published under MIT (see LICENSE in the repository). You can use Umami for anything, change it and share it; just include Umami's copyright and licence text in whatever copies you give out. Nothing obliges you to publish your source code, and using it on your own sites costs you no paperwork.
About privacy: Umami's FAQ says its tracker uses no cookies, and that the need for a cookie notice comes down to the other services your site runs and the requirements that apply. It adds that meeting GDPR depends on the way you set up and use Umami. For anything past that, ask someone qualified.
Questions people ask
- Is there a default password for Umami in Docker?
- Yes. On first start it creates the user admin with the password umami. Change it at once, while the app can only be reached through your SSH tunnel to 127.0.0.1 and before Caddy or any other proxy publishes it.
- Do I need MySQL or PostgreSQL?
- PostgreSQL, 12.14 or newer. Umami dropped MySQL in version 3. An older install on MySQL follows the docs' migration guide: update it to v2.19.0, move the data into PostgreSQL, then upgrade.
- Will my visitors see a cookie banner because of Umami?
- Umami's documentation says its tracker does not set cookies. Other tools on your site may still call for a notice, as may the requirements that apply to you, so look at the whole site rather than just the analytics.
- How do I keep my own visits out of the stats?
- The docs describe a browser setting: open the developer console on your site and run localStorage.setItem('umami.disabled', 1). It works per website. For a fixed office address, IGNORE_IP in the .env file does the job on the server.
- What do I need to back up?
- The PostgreSQL database plus .env, compose.yaml and your proxy config. Lose the .env and the database password and APP_SECRET go with it, so store the files beside the dump and keep a copy somewhere other than this machine.