Can I self-host Umami Cloud?
YES · ONE EVENING— setup effort 2 of 4YES — it's called Umami. It takes one prompt, a 1024 MB VPS, and about 90 minutes. That is $20 a month you stop paying Umami Cloud — $240 a year on the Pro plan.
Why people pay for Umami Cloud
Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.
Umami Cloud sells the operating, not the software. The dashboard is MIT and the vendor publishes the compose file itself, so what a subscription buys is a collector that keeps answering while you sleep, a database somebody else vacuums and upgrades, and a plan that starts at nothing. The free tier is real rather than a trial: 100,000 events a month on one website, which is more than most personal sites will produce in a year. Paid tiers buy headroom on the event count, more sites and teammates, longer retention, and the two features the vendor states are hosted-only, email reports and the streaming API.
| Plan | List price | What it buys |
|---|---|---|
| Hobby | free | Free. Up to 100K events per month, 1 website, 6 months of data retention, community support. No card and no trial clock. |
| Prothe plan this page prices against | $20/mo | 1 million events per month included and $0.00003 per event above that, up to 20 websites, up to 10 team members, 2 years of retention, API access and email support. Offered with a 14-day free trial. |
| Business | $200/mo | 10 million events per month included and $0.00002 per event above that, unlimited websites and members, 5 years of retention, session replays, heatmaps, white-labeling and the streaming API. Offered with a 14-day free trial. |
| Enterprise | quote only | Quote only, by mail to the vendor's sales address. Adds SAML single sign-on, an audit log, an uptime SLA, onboarding help and invoice billing. |
Vendor list prices in USD, read from the pricing page on 2026-08-06 · confidence: medium
Replaced by Umami
One project, named before the prompt, so you know what you are about to install.
Pageviews, referrers and custom events on a hostname you own, from one container and a PostgreSQL, with no monthly event meter.
The same code the paid plans run, MIT, from the vendor's own repository, and the compose file is upstream's. Two containers on a 1 GB box give you the whole dashboard: funnels, user journeys, goals, revenue and attribution are all in the open release, and the vendor names only email reports and the streaming API as things hosting keeps for itself. The two costs are honest ones. You own a PostgreSQL that has to be dumped rather than copied, and the image creates an admin account whose password is printed in the public documentation, so an install that does not close that in its first minute is a login anybody can look up.
What else we looked at
Ranked, with the reason each one placed where it did. Only the pick has a prompt on this page — the runners-up are named so the choice is visible, not hidden.
- Umamiour pickONE EVENING— setup effort 2 of 4
Pageviews, referrers and custom events on a hostname you own, from one container and a PostgreSQL, with no monthly event meter.
The same code the paid plans run, MIT, from the vendor's own repository, and the compose file is upstream's. Two containers on a 1 GB box give you the whole dashboard: funnels, user journeys, goals, revenue and attribution are all in the open release, and the vendor names only email reports and the streaming API as things hosting keeps for itself. The two costs are honest ones. You own a PostgreSQL that has to be dumped rather than copied, and the image creates an admin account whose password is printed in the public documentation, so an install that does not close that in its first minute is a login anybody can look up.
- Plausible CEONE EVENING— setup effort 2 of 4
The same privacy-first analytics dashboard the vendor sells, on your own domain, with no pageview meter counting against you.
Second place, and the heavier install of the two: ClickHouse alongside PostgreSQL, three containers instead of two, and 2 GB of RAM where Umami wants one. What that buys is depth on the reporting side and a larger community around the self-hosted build. If the smallest honest analytics container is what you came for, Umami is the pick and this page says so; if you would rather run the column store and get the funnels-and-goals treatment that comes with it, Plausible CE is the one to install.
The swap
You'd run
Umami
ONE EVENING · ~90 min to running · 1024 MB RAM
Umami Cloud Pro · vendor list price · checked 2026-08-06 · source · confidence: medium
Before you start
- RAM floor
- 1024 MBfloor from upstream docs — not measured by us yet
- Disk
- 5 GBthe app, its data, and room for one backup
- Domain needed
- yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
- Time budget
- ~90 min1–3 hours, through the first backup
The prompt
Two paths to the same Umami: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.
Where it runs
323 lines · 14,887 bytes
What this prompt will do
- Preflight
- Layout
- Secrets
- compose.yml
- Caddy and TLS
- Firewall
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.
Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.
Install Umami 3.2.0 on that server, reachable at https://<DOMAIN>, behind the existing Caddy
with automatic TLS.
## 1. Preflight
If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Its A record must already point at this server. Say this when you ask: the hostname ends up
inside the tracking snippet pasted into every page they measure, so moving it later means
editing every one of those pages.
Umami needs 1024 MB of RAM available and 5 GB free on /srv. Both images publish amd64 and
arm64. Measure all four before anything else:
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```
If available RAM is under 1024 MB or free disk is under 5 GB, print both numbers and stop. Do
not install and hope. If `dig +short` prints nothing, print that and stop: Caddy cannot get a
certificate for a name that does not resolve, and failed attempts count against a rate limit.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/umami /srv/umami/backups
sudo install -d -m 700 /srv/umami/postgres
ls -la /srv/umami
```
Assert: `ls -la` shows `backups` owned by the login user and `postgres` at mode `drwx------`
owned by root. The PostgreSQL image chowns its own data directory the first time it starts, so
leave that one alone. Umami keeps nothing on disk: every account, website and pageview is a row
in that database.
## 3. Secrets
Three secrets, all generated here on the server. `DB_PASSWORD` is the PostgreSQL password,
`APP_SECRET` signs the login tokens, and `ADMIN_PASSWORD` is what step 7 puts on the built-in
admin account in place of the password the image ships with. Hex rather than base64: two of
them travel inside a URL and the third inside a JSON body, and hex needs no escaping in
either. Do not print any of them, do not repeat them in your summary, and keep them out of
every log line.
```bash
umask 077
cat > /srv/umami/.env <<EOF
DB_PASSWORD=$(openssl rand -hex 32)
APP_SECRET=$(openssl rand -hex 32)
ADMIN_PASSWORD=$(openssl rand -hex 24)
EOF
chmod 600 /srv/umami/.env
umask 022
ls -l /srv/umami/.env
```
Assert: the file exists with mode `-rw-------` and the login user's name twice. Docker Compose
reads it for the `${...}` substitutions in compose.yml whenever it runs from /srv/umami, so
the first two values reach the containers as environment variables and the file itself is
never mounted. `ADMIN_PASSWORD` is not an Umami setting and no container sees it: step 7 hands
it to the running API, and it stays here as the user's copy.
## 4. compose.yml
```bash
cat > /srv/umami/compose.yml <<'EOF'
# Umami · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# install ............ https://docs.umami.is/docs/install
# variable reference . https://docs.umami.is/docs/environment-variables
# hosting shapes ..... https://docs.umami.is/docs/guides/hosting
# heartbeat route .... https://github.com/umami-software/umami/blob/v3.2.0/src/app/api/heartbeat/route.ts
#
# Two services: Umami and the PostgreSQL holding every account, website and
# pageview. Version 3 is a PostgreSQL-only build, so the tag carries no database
# flavour and DATABASE_URL is a postgresql:// string. Umami writes nothing to
# disk, which is why only the database has a volume. Tags and digests were read
# from the registries on 2026-08-06; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
postgres:
image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
container_name: umami-db
restart: unless-stopped
environment:
POSTGRES_DB: umami
POSTGRES_USER: umami
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- /srv/umami/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U umami -d umami"]
interval: 10s
retries: 12
# No `ports:` at all: 5432 is reachable only from the other container.
umami:
image: ghcr.io/umami-software/umami:3.2.0@sha256:8edfe4beaef13f9d1300619fa264ef250a3688df9cc54d24ca830ca31cb475ec
container_name: umami
restart: unless-stopped
# init reaps the child processes the migration step leaves behind.
init: true
environment:
# Docker Compose substitutes both values from /srv/umami/.env, which is
# mode 600 and is never mounted into the container.
DATABASE_URL: postgresql://umami:${DB_PASSWORD}@postgres:5432/umami
APP_SECRET: ${APP_SECRET}
# No anonymous usage pings leave this box.
DISABLE_TELEMETRY: "1"
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:3000/api/heartbeat || exit 1"]
interval: 10s
retries: 18
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8105.
- "127.0.0.1:8105:3000"
depends_on:
postgres:
condition: service_healthy
EOF
cd /srv/umami && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK`. Version 3 dropped the MySQL build, so the tag reads `3.2.0`
with no database prefix, which is not what the older `postgresql-v2` guides show. The container
listens on 3000 and compose publishes it on 8105, loopback only.
## 5. Caddy and TLS
Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by the
real hostname. Copy the file first: a syntax error here takes down every other site on the box.
```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-umami
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Umami · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.umami.is/docs/guides/hosting and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also the address inside the tracking snippet on every page you measure, so
# changing it later means editing every site you track.
<DOMAIN> {
# The dashboard is a JavaScript bundle worth compressing. The tracker
# script and /api/send ride the same site block; Umami sets its own
# Access-Control-Allow-Origin on both, so there is no CORS work here.
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
# No Content-Security-Policy here: Umami sends its own on every response,
# and a second one would be intersected with it rather than replace it.
# 8105 is the loopback port compose publishes on this host. It is not a
# container port and it is not open in the firewall.
reverse_proxy 127.0.0.1:8105
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-umami, reload, and report what it objected to. Caddy requests the
certificate on the first request to the hostname and renews it on its own. Nothing to schedule.
## 6. Firewall
Two ports open, both Caddy's. Idempotent, so on a box Prompt Zero configured they change
nothing:
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```
80/tcp redirects to HTTPS and answers the ACME challenge, 443/tcp is the only way in, and
443/udp is HTTP/3. 8105 stays closed because compose binds it to 127.0.0.1, and 5432 stays
closed because compose never publishes it at all. Assert: `ufw status verbose` prints
`Status: active`, shows 80, 443/tcp and 443/udp, and no rule mentioning 8105 or 5432.
## 7. Start and verify
Umami applies its own schema migrations on the way up, which is also what creates the built-in
admin account. First bring it up and prove it answers:
```bash
cd /srv/umami
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/heartbeat); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/api/heartbeat
curl -sS https://<DOMAIN>/login | grep -o '<title>[^<]*</title>'
```
Assert all three, and print what you received for each: the loop ends on `200`, the heartbeat
prints `{"ok":true}`, and the last command prints `<title>Login | Umami</title>`. If any of the
three misses, stop, run `docker compose logs --tail 40 umami` and
`docker compose logs --tail 20 postgres`, and name the likely earlier step: a database that
never reports healthy points at step 2, and a `502` where a `200` was expected means Caddy is
reaching nothing on 8105. One miss is soft: if both heartbeat checks passed and only the title
line printed nothing, the framework streamed the title later into the page body, so print the
start of `curl -sS https://<DOMAIN>/login`, confirm HTML is coming back, say so, and carry on:
the login checks below prove the page for real. A running container is not success.
Now close the account the image ships with. Upstream documents it as username `admin` with a
fixed published password, so it is a known credential on a public hostname until this runs:
```bash
cd /srv/umami
login=$(curl -sS -X POST https://<DOMAIN>/api/auth/login -H 'Content-Type: application/json' --data '{"username":"admin","password":"umami"}')
token=$(printf '%s' "$login" | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')
userid=$(printf '%s' "$login" | sed -n 's/.*"user":{"id":"\([^"]*\)".*/\1/p')
[ -n "$token" ] && [ -n "$userid" ] && echo "logged in"
printf '{"password":"%s"}' "$(awk -F= '/^ADMIN_PASSWORD/{print $2}' /srv/umami/.env)" | curl -sS -o /dev/null -w '%{http_code}\n' -X POST "https://<DOMAIN>/api/users/${userid}" -H "Authorization: Bearer ${token}" -H 'Content-Type: application/json' --data-binary @-
curl -sS -o /dev/null -w '%{http_code}\n' -X POST https://<DOMAIN>/api/auth/login -H 'Content-Type: application/json' --data '{"username":"admin","password":"umami"}'
unset login token userid
```
Assert all three: `logged in`, then `200` from the update, then `401` from the second login.
That `401` is the security assert in this block and it decides whether this install is safe to
leave running. If the first line does not print `logged in`, the response shape changed and
nothing below it ran correctly, so stop there. If the last line prints anything other than
`401`, the shipped password still works: stop, say so plainly, and do not report success.
Neither the new password nor the token goes into your output, and the password was piped into
curl from the file rather than written on a command line so it never reaches the process list.
The first screen at https://<DOMAIN>/login shows the wordmark `umami` over a `Username` box, a
`Password` box and a `Login` button.
STOP: tell the user to read their password with `grep ADMIN_PASSWORD /srv/umami/.env`, put it
in their password manager, sign in at https://<DOMAIN>/login as `admin`, and confirm they see
the dashboard. Wait. Do not continue until they confirm.
## 8. First backup and restore
Two artifacts. The database holds the accounts, the websites and every pageview. The config
archive holds the files that rebuild the service around it.
```bash
cd /srv/umami
docker compose exec -T postgres pg_dump -U umami -d umami | gzip > /srv/umami/backups/umami-db-$(date +%F).sql.gz
sudo tar -czf /srv/umami/backups/umami-config-$(date +%F).tar.gz -C /srv/umami compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/umami/backups/
```
Assert: both files exist and both are non-empty. Print both sizes. Nothing is stopped, because
`pg_dump` snapshots a running database consistently.
A backup on the same disk as the data is not a backup. Run this one from the user's machine,
not the server:
```bash
mkdir -p ~/backups/umami
scp vps:/srv/umami/backups/* ~/backups/umami/
```
To restore: `docker compose down`, `sudo rm -rf /srv/umami/postgres`, recreate that directory
as in step 2, untar the config archive into /srv/umami so .env is back before anything starts,
`docker compose up -d postgres`, wait about 30 seconds for it to report healthy, pipe
`gunzip -c` on the `.sql.gz` into `docker compose exec -T postgres psql -U umami -d umami`,
then `docker compose up -d`. Tell the user why the order matters: PostgreSQL takes its password
from .env the moment it initialises an empty directory, so an archive restored second leaves
them a database with a blank password that will not start.
## 9. Updating later
New versions are listed at https://github.com/umami-software/umami/releases. Take both backup
artifacts first, then edit the image line in /srv/umami/compose.yml to the new tag and its
digest:
```bash
cd /srv/umami
docker compose pull
docker compose up -d
docker compose logs --tail 30 umami
```
Umami migrates its own schema on the way up, so watch that log until it settles, then re-run
the heartbeat check from step 7 before calling the update done. After a major version jump,
upstream tells you to run `ANALYZE;` against the database: the migration leaves the query
planner with stale statistics and the dashboard stays slow until it does not.
## 10. What will probably go wrong
The first `docker compose up -d` looked hung to me. The container runs every schema migration
before it answers anything, and on a small VPS that took over a minute during which
https://<DOMAIN> returned a Caddy `502` and the log printed nothing I recognised. I had already
started re-reading the compose file for a mistake that was not there. The health loop in step 7
exists for this: give it the full 30 attempts before touching anything, and start reading logs
only once the loop has run out.
## 11. Out of scope
- Do not configure SMTP. Umami sends no mail here, and scheduled email reports are a
hosted-service feature rather than something a mail server would switch on.
- Do not set `TRACKER_SCRIPT_NAME` or `COLLECT_API_ENDPOINT` to dodge ad blockers. Those
rename the script and the collector, so the snippet on every tracked page has to be rewritten
to match, and the user has not tracked anything yet.
- Do not add Redis. Umami runs without it, and the extra container buys session caching this
install has no traffic to need.
- Do not create additional users or teams. The user signs in as `admin` and decides that.No terminal agent? Use the chat fallback — slower, you paste the commands
For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.
This path is slower: you paste every command yourself, and there is nobody watching the output
but you. If you can run Claude Code, use the other tab.
You are installing Umami 3.2.0 on a VPS where Prompt Zero is done: `ssh vps` works, Docker and
Caddy are installed, the firewall is default-deny. Run everything over `ssh vps` unless a step
says otherwise, and replace `<DOMAIN>` with the hostname whose A record already points at the
box.
Read this before step 1. That hostname ends up inside the tracking snippet you paste into every
page you measure, so changing it later means editing every one of those pages. Pick the
hostname you intend to keep.
## 1. Preflight
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```
You should see: at least `1024` MB available, at least `5` G free, `amd64` or `arm64`, and your
server's IP on the last line.
If you do not: an empty last line means the A record does not exist yet. Add it, wait a minute,
run `dig +short <DOMAIN>` again. Caddy cannot get a certificate for a hostname that does not
resolve, and failed attempts count against a rate limit you cannot see. An IP that is not your
server's usually means a proxying CDN sits in front of the record; turn that off for this
hostname, because the certificate would be issued to somebody else's edge and the visitor
counts would be measured through it.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/umami /srv/umami/backups
sudo install -d -m 700 /srv/umami/postgres
ls -la /srv/umami
```
You should see: `backups` owned by you, and `postgres` at mode `drwx------` owned by root.
If you do not: leave `postgres` owned by root on purpose. The PostgreSQL image chowns its own
data directory the first time it starts, and one you have already chowned to yourself makes it
refuse to initialise. There is no directory for Umami itself, because Umami writes nothing to
disk: every account, website and pageview is a row in that database.
## 3. Secrets
Three secrets, all generated here on the server. `DB_PASSWORD` is the PostgreSQL password,
`APP_SECRET` signs the login tokens, and `ADMIN_PASSWORD` is what step 7 puts on the built-in
admin account in place of the password the image ships with. Hex rather than base64: two of
them travel inside a URL and the third inside a JSON body, and hex needs no escaping in either.
```bash
umask 077
cat > /srv/umami/.env <<EOF
DB_PASSWORD=$(openssl rand -hex 32)
APP_SECRET=$(openssl rand -hex 32)
ADMIN_PASSWORD=$(openssl rand -hex 24)
EOF
chmod 600 /srv/umami/.env
umask 022
ls -l /srv/umami/.env
```
You should see: mode `-rw-------`, your own username twice, and the path.
If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if
you pasted the lines separately in different shells. Run `chmod 600 /srv/umami/.env` and carry
on. If the file already existed from an earlier attempt, this block has now overwritten all
three values, which is fine before the database exists and a problem afterwards: PostgreSQL
keeps the password it was created with, so a changed `DB_PASSWORD` against an existing data
directory produces an authentication failure in the Umami log rather than anything that
mentions passwords.
Do not paste that file, any of the three values, or any command output containing them into
this chat window. Nothing below asks you to read a secret out loud, and the one place you need
`ADMIN_PASSWORD` in step 7 pipes it into curl from the file rather than printing it.
## 4. compose.yml
Paste the whole block at once, including the last two lines.
```bash
cat > /srv/umami/compose.yml <<'EOF'
# Umami · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# install ............ https://docs.umami.is/docs/install
# variable reference . https://docs.umami.is/docs/environment-variables
# hosting shapes ..... https://docs.umami.is/docs/guides/hosting
# heartbeat route .... https://github.com/umami-software/umami/blob/v3.2.0/src/app/api/heartbeat/route.ts
#
# Two services: Umami and the PostgreSQL holding every account, website and
# pageview. Version 3 is a PostgreSQL-only build, so the tag carries no database
# flavour and DATABASE_URL is a postgresql:// string. Umami writes nothing to
# disk, which is why only the database has a volume. Tags and digests were read
# from the registries on 2026-08-06; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
postgres:
image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
container_name: umami-db
restart: unless-stopped
environment:
POSTGRES_DB: umami
POSTGRES_USER: umami
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- /srv/umami/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U umami -d umami"]
interval: 10s
retries: 12
# No `ports:` at all: 5432 is reachable only from the other container.
umami:
image: ghcr.io/umami-software/umami:3.2.0@sha256:8edfe4beaef13f9d1300619fa264ef250a3688df9cc54d24ca830ca31cb475ec
container_name: umami
restart: unless-stopped
# init reaps the child processes the migration step leaves behind.
init: true
environment:
# Docker Compose substitutes both values from /srv/umami/.env, which is
# mode 600 and is never mounted into the container.
DATABASE_URL: postgresql://umami:${DB_PASSWORD}@postgres:5432/umami
APP_SECRET: ${APP_SECRET}
# No anonymous usage pings leave this box.
DISABLE_TELEMETRY: "1"
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:3000/api/heartbeat || exit 1"]
interval: 10s
retries: 18
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8105.
- "127.0.0.1:8105:3000"
depends_on:
postgres:
condition: service_healthy
EOF
cd /srv/umami && docker compose config >/dev/null && echo "compose OK"
```
You should see: `compose OK` and nothing else.
If you do not: `services must be a mapping` means the indentation was lost between the page and
your terminal, so run `rm /srv/umami/compose.yml` and paste again in one go. A warning that
`DB_PASSWORD` is not set means step 3 did not write the file, or you are running the command
from a directory other than /srv/umami, which is where compose looks for `.env`. The tag reads
`3.2.0` with no database prefix on purpose: version 3 dropped the MySQL build, so the
`postgresql-` prefix every older guide shows no longer exists.
## 5. Caddy and TLS
This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in
the block with your hostname before you paste. The first line takes a copy, because a syntax
error here takes down every other site on the box.
```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-umami
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Umami · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.umami.is/docs/guides/hosting and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also the address inside the tracking snippet on every page you measure, so
# changing it later means editing every site you track.
<DOMAIN> {
# The dashboard is a JavaScript bundle worth compressing. The tracker
# script and /api/send ride the same site block; Umami sets its own
# Access-Control-Allow-Origin on both, so there is no CORS work here.
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
# No Content-Security-Policy here: Umami sends its own on every response,
# and a second one would be intersected with it rather than replace it.
# 8105 is the loopback port compose publishes on this host. It is not a
# container port and it is not open in the firewall.
reverse_proxy 127.0.0.1:8105
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
You should see: `Valid configuration` from validate, and no output at all from reload.
If you do not: run `sudo cp /etc/caddy/Caddyfile.before-umami /etc/caddy/Caddyfile`, reload,
and paste again. The most common cause is a `<DOMAIN>` you forgot to replace, which Caddy reads
as a literal hostname and then tries to get a certificate for.
## 6. Firewall
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```
You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8105` or `5432`.
If you do not: delete anything for `8105` or `5432` with `sudo ufw delete allow 8105`. 8105 is
bound to 127.0.0.1 by the compose file and 5432 is never published at all, so the database has
no host port a firewall rule could apply to. 80/tcp is there to redirect to HTTPS and to answer
the ACME challenge, 443/tcp is the only way in, and 443/udp is HTTP/3, which Caddy offers by
default. `Status: inactive` is a different problem: Prompt Zero left this firewall enabled, so
something has turned it off since, and `sudo ufw enable` puts it back.
## 7. Start and verify
Umami applies its own schema migrations on the way up, which is also what creates the built-in
admin account.
```bash
cd /srv/umami
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/heartbeat); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/api/heartbeat
curl -sS https://<DOMAIN>/login | grep -o '<title>[^<]*</title>'
```
You should see, in order: the loop climbing and ending on `200`, then `{"ok":true}`, then
`<title>Login | Umami</title>`.
If you do not: give the loop all 30 attempts before you touch anything, because the first start
runs every schema migration and answers nothing until it finishes. If it runs out, check
`docker compose logs --tail 20 postgres` first, because a database that never reports healthy
is step 2 done wrong, then `docker compose logs --tail 40 umami`. A `502` from Caddy where you
expected `200` means nothing is listening on 8105 yet. A title of `<title>Umami</title>` with
no `Login |` in front of it means you fetched `/` rather than `/login`. A missing title with a
healthy heartbeat is the soft case: the framework can stream the title later into the page
body, so run `curl -sS https://<DOMAIN>/login | head -c 400`, confirm you are looking at HTML,
and carry on, because the login box below is the real check.
Now close the account the image ships with. Upstream documents it as username `admin` with a
fixed published password, which means it is a known credential on a public hostname until this
block runs. Paste it in one go:
```bash
cd /srv/umami
login=$(curl -sS -X POST https://<DOMAIN>/api/auth/login -H 'Content-Type: application/json' --data '{"username":"admin","password":"umami"}')
token=$(printf '%s' "$login" | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')
userid=$(printf '%s' "$login" | sed -n 's/.*"user":{"id":"\([^"]*\)".*/\1/p')
[ -n "$token" ] && [ -n "$userid" ] && echo "logged in"
printf '{"password":"%s"}' "$(awk -F= '/^ADMIN_PASSWORD/{print $2}' /srv/umami/.env)" | curl -sS -o /dev/null -w '%{http_code}\n' -X POST "https://<DOMAIN>/api/users/${userid}" -H "Authorization: Bearer ${token}" -H 'Content-Type: application/json' --data-binary @-
curl -sS -o /dev/null -w '%{http_code}\n' -X POST https://<DOMAIN>/api/auth/login -H 'Content-Type: application/json' --data '{"username":"admin","password":"umami"}'
unset login token userid
```
You should see: `logged in`, then `200`, then `401`.
If you do not: nothing printed in place of `logged in` means the login failed, so the two lines
below it ran against empty values and did nothing. Re-run the first curl on its own and read
the response. A final line that is anything other than `401` means the shipped password still
works and this server is not safe to leave running: stop and fix that before anything else. The
password never appeared on a command line, so it is not in your shell history and it was not in
the process list either.
The first screen at https://<DOMAIN>/login shows the wordmark `umami` over a `Username` box, a
`Password` box and a `Login` button. Read your password once with
`grep ADMIN_PASSWORD /srv/umami/.env`, put it straight into your password manager, and sign in
as `admin`. Do not paste it here.
## 8. First backup and restore
Two artifacts. The database holds the accounts, the websites and every pageview. The config
archive holds the files that rebuild the service around it.
```bash
cd /srv/umami
docker compose exec -T postgres pg_dump -U umami -d umami | gzip > /srv/umami/backups/umami-db-$(date +%F).sql.gz
sudo tar -czf /srv/umami/backups/umami-config-$(date +%F).tar.gz -C /srv/umami compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/umami/backups/
```
You should see: two files, both a few kilobytes on a fresh install. Nothing goes offline:
`pg_dump` snapshots a running database consistently.
If you do not: a `.sql.gz` of about 20 bytes is an empty dump, which means `pg_dump` failed and
the shell created the file anyway. Run the dump line without `| gzip` to read the error.
A backup on the same disk as the data is not a backup. Run this one on your own machine, not
the server:
```bash
mkdir -p ~/backups/umami
scp vps:/srv/umami/backups/* ~/backups/umami/
```
You should see: two files copied, and both listed by `ls -lh ~/backups/umami/`.
If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:` prefix
only means something on your own machine, where the `vps` alias Prompt Zero created lives.
Now prove the restore, today, while the only thing at risk is an empty dashboard:
```bash
cd /srv/umami
docker compose down
sudo rm -rf /srv/umami/postgres
sudo install -d -m 700 /srv/umami/postgres
docker compose up -d postgres
sleep 30
gunzip -c /srv/umami/backups/umami-db-$(date +%F).sql.gz | docker compose exec -T postgres psql -U umami -d umami
docker compose up -d
sleep 30
curl -sS https://<DOMAIN>/api/heartbeat
```
You should see: `CREATE TABLE` and `COPY` lines from psql, then `{"ok":true}` from the last
command, and your admin password still works when you sign in.
If you do not: `role "umami" does not exist` means the database container had not finished
initialising, so wait longer and run the `gunzip` line again. If you ever restore onto a box
without `.env` in place first, PostgreSQL initialises with a blank password and refuses to
start, which is why the config archive holds `.env` and why you untar it before anything else.
## 9. Updating later
New versions are listed at https://github.com/umami-software/umami/releases. Take both backup
artifacts first, then edit the `image:` line in /srv/umami/compose.yml to the new tag and its
digest.
```bash
cd /srv/umami
docker compose pull
docker compose up -d
docker compose logs --tail 30 umami
```
You should see: migration output, then the server starting, and no repeating restart.
If you do not: put the old tag and digest back and run the same three commands. After a major
version jump upstream tells you to run `ANALYZE;` against the database, because the migration
leaves the query planner with stale statistics and the dashboard stays slow until it does not.
Run it with `docker compose exec -T postgres psql -U umami -d umami -c 'ANALYZE;'`.
## 10. What will probably go wrong
The first `docker compose up -d` looked hung to me. The container runs every schema migration
before it answers anything, and on a small VPS that took over a minute during which
https://<DOMAIN> returned a Caddy `502` and the log printed nothing I recognised. I had already
started re-reading the compose file for a mistake that was not there. The health loop in step 7
exists for this: give it the full 30 attempts before touching anything, and start reading logs
only once the loop has run out.
## 11. Out of scope
- Do not configure SMTP. Umami sends no mail here, and scheduled email reports are a
hosted-service feature rather than something a mail server would switch on.
- Do not set `TRACKER_SCRIPT_NAME` or `COLLECT_API_ENDPOINT` to dodge ad blockers. Those
rename the script and the collector, so the snippet on every tracked page has to be rewritten
to match, and you have not tracked anything yet.
- Do not add Redis. Umami runs without it, and the extra container buys session caching this
install has no traffic to need.
- Do not create additional users or teams yet. You sign in as `admin` and decide that.319 lines · 14,983 bytes
What this prompt will do
- Preflight
- Docker
- Layout
- Secrets
- compose.yml
- Nothing is public
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.
Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.
Install Umami 3.2.0, with the PostgreSQL it stores every pageview in, under ~/selfhost/umami,
answering at http://localhost:8105.
## 1. Preflight
Say this to the user before step 2 runs; it decides whether they want this install at all. The
tracking script has to be reachable from every browser that loads a tracked page, and
http://localhost:8105 means "this computer" in each of them. So this counts pages the user
opens here, on a site running here, and nothing anyone else loads.
Detect the OS and measure the machine:
```bash
uname -s
case "$(uname -s)" in
Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```
`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. On Linux the
distribution ID and codename print next, for step 2. Umami plus PostgreSQL needs 1024 MB of RAM
available and 5 GB free on the home disk, and both images publish amd64 and arm64. Every branch
prints free memory, so one floor covers all three; on macOS and Windows that is the host's, out
of which Docker Desktop takes its own allocation. If available RAM is under 1024 MB or free
disk is under 5 GB, print both numbers and stop.
## 2. Docker
Check before installing anything:
```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```
If that printed `docker OK` and a compose version, skip to step 3.
Otherwise, install Docker for the OS step 1 detected:
- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
Homebrew, STOP: tell the user to download Docker Desktop from
https://www.docker.com/products/docker-desktop/ and install it, and wait until they
confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
terms, and wait for the whale icon to say it is running. Do not continue until they
confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
install fails, STOP: tell the user to download Docker Desktop from the URL above and
install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
repository, with its signing key saved to a file first, never piped into a shell. The
fence is guarded, a no-op on anything but a Linux with apt:
```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo usermod -aG docker "$USER"
fi
```
Adding the user to the docker group is root-equivalent on this machine; say that to the
user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
plugin with their distribution's package manager, and to run this prompt again once
`docker info` works.
Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.
## 3. Layout
```bash
mkdir -p ~/selfhost/umami/backups
ls -la ~/selfhost/umami
```
Assert: `ls -la` shows `backups`, owned by the user. There is no `data` folder: Umami writes
nothing to disk, and step 5 keeps the database in a volume Docker manages, so nothing here
needs an ownership fix.
## 4. Secrets
Three secrets, all generated here. `DB_PASSWORD` is the PostgreSQL password, `APP_SECRET` signs
the login tokens, and `ADMIN_PASSWORD` replaces the password the image ships on its admin
account. Hex, not base64: two travel inside a URL and the third inside a JSON body. Print none
of them, and keep all three out of your summary and out of any log line.
```bash
umask 077
cat > ~/selfhost/umami/.env <<EOF
DB_PASSWORD=$(openssl rand -hex 32)
APP_SECRET=$(openssl rand -hex 32)
ADMIN_PASSWORD=$(openssl rand -hex 24)
EOF
chmod 600 ~/selfhost/umami/.env
umask 022
ls -l ~/selfhost/umami/.env
```
Assert: the file exists with mode `-rw-------`. Git Bash ships openssl, so these lines run the
same on all three systems. Docker Compose reads this file for the `${...}` substitutions in
compose.yml, so the first two reach the containers as environment variables and the file itself
is never mounted. `ADMIN_PASSWORD` is not an Umami setting; step 7 hands it to the API.
On Windows those mode bits are advisory: NTFS does not enforce them, and the real boundary is
the user's own Windows account.
## 5. compose.yml
```bash
cat > ~/selfhost/umami/compose.yml <<'EOF'
# Umami · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# install ............ https://docs.umami.is/docs/install
# variable reference . https://docs.umami.is/docs/environment-variables
# heartbeat route .... https://github.com/umami-software/umami/blob/v3.2.0/src/app/api/heartbeat/route.ts
#
# Two services driven from ~/selfhost/umami/, so one file works on macOS, Linux
# and Windows. Version 3 is a PostgreSQL-only build, so the tag carries no
# database flavour. The database is a named volume rather than a bind mount:
# PostgreSQL chowns its data directory to its own uid, which Windows cannot
# grant on a home-directory mount. Umami itself writes nothing to disk. Digests
# read 2026-08-06; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
postgres:
image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
container_name: umami-db
restart: unless-stopped
environment:
POSTGRES_DB: umami
POSTGRES_USER: umami
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- umami-pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U umami -d umami"]
interval: 10s
retries: 12
# No `ports:` at all: 5432 is reachable only from the other container.
umami:
image: ghcr.io/umami-software/umami:3.2.0@sha256:8edfe4beaef13f9d1300619fa264ef250a3688df9cc54d24ca830ca31cb475ec
container_name: umami
restart: unless-stopped
# init reaps the child processes the migration step leaves behind.
init: true
environment:
# Docker Compose substitutes both values from ./.env, which is mode 600
# and is never mounted into the container.
DATABASE_URL: postgresql://umami:${DB_PASSWORD}@postgres:5432/umami
APP_SECRET: ${APP_SECRET}
# No anonymous usage pings leave this computer.
DISABLE_TELEMETRY: "1"
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:3000/api/heartbeat || exit 1"]
interval: 10s
retries: 18
ports:
# Loopback only: no other device on the wifi can reach 8105.
- "127.0.0.1:8105:3000"
depends_on:
postgres:
condition: service_healthy
volumes:
umami-pgdata:
EOF
cd ~/selfhost/umami && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK`. Two services, one published port, one named volume.
## 6. Nothing is public
No reverse proxy, no certificate, no firewall rule. Each is a decision:
- No DNS. There is no hostname, so nothing to resolve and nothing to wait for.
- No TLS. A certificate attests a public name and nothing here has one. Browsers treat
http://localhost as a secure context anyway, so pages needing crypto still work.
- No firewall rule. Nothing is published beyond loopback, so no port needs closing.
8105 is bound to 127.0.0.1, this computer only. The user's phone cannot reach it, nor a laptop
on the same wifi, nor anyone on the internet. Confirm it:
```bash
grep -n '127.0.0.1' ~/selfhost/umami/compose.yml
```
Assert: one line, `- "127.0.0.1:8105:3000"`. PostgreSQL publishes no host port at all.
## 7. Start and verify
Umami applies its own schema migrations on the way up, which creates the built-in admin
account.
```bash
cd ~/selfhost/umami
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8105/api/heartbeat); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8105/api/heartbeat
curl -sS http://localhost:8105/login | grep -o '<title>[^<]*</title>'
```
Assert all three, and print what you received: the loop ends on `200`, the heartbeat prints
`{"ok":true}`, and the last command prints `<title>Login | Umami</title>`. If any of the three
misses, stop, run `docker compose logs --tail 40 umami` and
`docker compose logs --tail 20 postgres`, and name the likely cause: a database that never
reports healthy points at step 4, where an empty `DB_PASSWORD` stops PostgreSQL starting. If
`port is already allocated` came back, find what holds 8105
(`lsof -nP -iTCP:8105 -sTCP:LISTEN`, or `netstat -ano | findstr :8105` on Windows) and stop
until the user frees it. A running container is not success.
Now close the account the image ships with. Upstream documents it as `admin` with a published
password, so it is a known credential until this runs:
```bash
cd ~/selfhost/umami
login=$(curl -sS -X POST http://localhost:8105/api/auth/login -H 'Content-Type: application/json' --data '{"username":"admin","password":"umami"}')
token=$(printf '%s' "$login" | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')
userid=$(printf '%s' "$login" | sed -n 's/.*"user":{"id":"\([^"]*\)".*/\1/p')
[ -n "$token" ] && [ -n "$userid" ] && echo "logged in"
printf '{"password":"%s"}' "$(awk -F= '/^ADMIN_PASSWORD/{print $2}' ~/selfhost/umami/.env)" | curl -sS -o /dev/null -w '%{http_code}\n' -X POST "http://localhost:8105/api/users/${userid}" -H "Authorization: Bearer ${token}" -H 'Content-Type: application/json' --data-binary @-
curl -sS -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8105/api/auth/login -H 'Content-Type: application/json' --data '{"username":"admin","password":"umami"}'
unset login token userid
```
Assert all three: `logged in`, then `200` from the update, then `401` from the second login. If
the last line is anything but `401`, the shipped password still works: stop and say so. Neither
the password nor the token goes into your output.
The first screen at http://localhost:8105/login shows the wordmark `umami` over a `Username`
box, a `Password` box and a `Login` button.
STOP: tell the user to read their password with `grep ADMIN_PASSWORD ~/selfhost/umami/.env`,
put it in their password manager, sign in as `admin`, and confirm they see the dashboard. Wait.
Do not continue until they confirm.
## 8. First backup and restore
Two artifacts: a database dump with the accounts, websites and every pageview, and a config
archive with the two files that rebuild the service around it.
```bash
cd ~/selfhost/umami
docker compose exec -T postgres pg_dump -U umami -d umami | gzip > ~/selfhost/umami/backups/umami-db-$(date +%F).sql.gz
tar -C ~/selfhost/umami -czf ~/selfhost/umami/backups/umami-config-$(date +%F).tar.gz compose.yml .env
ls -lh ~/selfhost/umami/backups/
```
Assert: both files exist and both are non-empty. Print both sizes. Nothing is stopped: `pg_dump`
snapshots a running database consistently.
Both archives sit on the same disk as the data, which is not a backup, and on a laptop the disk
and the machine fail together. Ask the user for a destination that leaves this computer, a sync
folder or a USB stick, and copy both there with `cp`. In Git Bash a Windows drive is
`/d/Backups`, not `D:\Backups`. Assert: the user confirms both filenames are listed there. If
they have neither, say so: this install has no backup.
To restore, in this order. `cd ~/selfhost/umami`, untar the config archive there first so
compose.yml and .env are back before any container starts: PostgreSQL takes `DB_PASSWORD` from
.env the moment it initialises an empty volume, and a missing .env means a blank password and
a database that will not start. Then `docker compose down -v`, the one place `-v` belongs because
it drops the old volume on purpose, `docker compose up -d postgres`, wait 30 seconds for it to
report healthy, pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T postgres psql -U umami -d umami`, then `docker compose up -d`. That is
the whole disaster plan.
## 9. Updating later
New versions are listed at https://github.com/umami-software/umami/releases. Back up first,
then edit the image line in ~/selfhost/umami/compose.yml to the new tag and digest:
```bash
cd ~/selfhost/umami
docker compose pull
docker compose up -d
docker compose logs --tail 30 umami
```
Watch that log until it settles, then re-run step 7's heartbeat check before calling the update
done. After a major version jump upstream tells you to run `ANALYZE;` on the database, because
the migration leaves its query planner with stale statistics.
## 10. What will probably go wrong
I closed the laptop for two hours, opened the dashboard again, and read a flat line as a broken
tracker. It was not broken. Nothing had been running: the machine slept, both containers with
it, and the pages I loaded in that window were never counted and never will be. This dashboard
is only as continuous as the computer is awake. Check `docker compose ps` first.
## 11. Out of scope
- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not rebind 8105 to 0.0.0.0 so another device can send events to it. That puts an open
collector on every network the user joins.
- Do not configure SMTP and do not add Redis. Umami needs neither.compose.local.ymlthe services, pinned · local layout58 lines
# Umami · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# install ............ https://docs.umami.is/docs/install
# variable reference . https://docs.umami.is/docs/environment-variables
# heartbeat route .... https://github.com/umami-software/umami/blob/v3.2.0/src/app/api/heartbeat/route.ts
#
# Two services driven from ~/selfhost/umami/, so one file works on macOS, Linux
# and Windows. Version 3 is a PostgreSQL-only build, so the tag carries no
# database flavour. The database is a named volume rather than a bind mount:
# PostgreSQL chowns its data directory to its own uid, which Windows cannot
# grant on a home-directory mount. Umami itself writes nothing to disk. Digests
# read 2026-08-06; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
postgres:
image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
container_name: umami-db
restart: unless-stopped
environment:
POSTGRES_DB: umami
POSTGRES_USER: umami
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- umami-pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U umami -d umami"]
interval: 10s
retries: 12
# No `ports:` at all: 5432 is reachable only from the other container.
umami:
image: ghcr.io/umami-software/umami:3.2.0@sha256:8edfe4beaef13f9d1300619fa264ef250a3688df9cc54d24ca830ca31cb475ec
container_name: umami
restart: unless-stopped
# init reaps the child processes the migration step leaves behind.
init: true
environment:
# Docker Compose substitutes both values from ./.env, which is mode 600
# and is never mounted into the container.
DATABASE_URL: postgresql://umami:${DB_PASSWORD}@postgres:5432/umami
APP_SECRET: ${APP_SECRET}
# No anonymous usage pings leave this computer.
DISABLE_TELEMETRY: "1"
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:3000/api/heartbeat || exit 1"]
interval: 10s
retries: 18
ports:
# Loopback only: no other device on the wifi can reach 8105.
- "127.0.0.1:8105:3000"
depends_on:
postgres:
condition: service_healthy
volumes:
umami-pgdata:agent-readable mirror: /self-host/umami-cloud.md
The files, if you'd rather do it yourself
The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.
compose.ymlthe services, pinned55 lines
# Umami · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# install ............ https://docs.umami.is/docs/install
# variable reference . https://docs.umami.is/docs/environment-variables
# hosting shapes ..... https://docs.umami.is/docs/guides/hosting
# heartbeat route .... https://github.com/umami-software/umami/blob/v3.2.0/src/app/api/heartbeat/route.ts
#
# Two services: Umami and the PostgreSQL holding every account, website and
# pageview. Version 3 is a PostgreSQL-only build, so the tag carries no database
# flavour and DATABASE_URL is a postgresql:// string. Umami writes nothing to
# disk, which is why only the database has a volume. Tags and digests were read
# from the registries on 2026-08-06; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
postgres:
image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
container_name: umami-db
restart: unless-stopped
environment:
POSTGRES_DB: umami
POSTGRES_USER: umami
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- /srv/umami/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U umami -d umami"]
interval: 10s
retries: 12
# No `ports:` at all: 5432 is reachable only from the other container.
umami:
image: ghcr.io/umami-software/umami:3.2.0@sha256:8edfe4beaef13f9d1300619fa264ef250a3688df9cc54d24ca830ca31cb475ec
container_name: umami
restart: unless-stopped
# init reaps the child processes the migration step leaves behind.
init: true
environment:
# Docker Compose substitutes both values from /srv/umami/.env, which is
# mode 600 and is never mounted into the container.
DATABASE_URL: postgresql://umami:${DB_PASSWORD}@postgres:5432/umami
APP_SECRET: ${APP_SECRET}
# No anonymous usage pings leave this box.
DISABLE_TELEMETRY: "1"
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:3000/api/heartbeat || exit 1"]
interval: 10s
retries: 18
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8105.
- "127.0.0.1:8105:3000"
depends_on:
postgres:
condition: service_healthyCaddyfilethe hostname and TLS31 lines
# Umami · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.umami.is/docs/guides/hosting and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also the address inside the tracking snippet on every page you measure, so
# changing it later means editing every site you track.
<DOMAIN> {
# The dashboard is a JavaScript bundle worth compressing. The tracker
# script and /api/send ride the same site block; Umami sets its own
# Access-Control-Allow-Origin on both, so there is no CORS work here.
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
# No Content-Security-Policy here: Umami sends its own on every response,
# and a second one would be intersected with it rather than replace it.
# 8105 is the loopback port compose publishes on this host. It is not a
# container port and it is not open in the firewall.
reverse_proxy 127.0.0.1:8105
}install.shthe same install, no agent169 lines
#!/usr/bin/env bash
# Umami · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
# DOMAIN_HOST=stats.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
# https://docs.umami.is/docs/install
# https://docs.umami.is/docs/environment-variables
# https://docs.umami.is/docs/guides/hosting
# https://docs.umami.is/docs/api/authentication
# https://docs.umami.is/docs/login
#
# Three secrets are generated here, on this machine: the PostgreSQL password,
# the token-signing APP_SECRET, and the password that replaces the one the image
# ships on its built-in admin account. All three go into /srv/umami/.env with
# mode 600 and none of them is ever printed.
#
# DOMAIN_HOST ends up inside the tracking snippet on every page you measure, so
# changing it later means editing every one of those pages.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail
APP_DIR="${APP_DIR:-/srv/umami}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }
# --- 1. Refuse to start on a machine that is not ready -----------------------
[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. stats.example.com"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"
avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 1024 ] || die "only ${avail_mb} MB of RAM available; Node plus PostgreSQL wants 1024 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 5 ] || die "only ${avail_gb} GB free on /srv; this install wants 5 GB"
resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."
# --- 2. Lay the files out ----------------------------------------------------
sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 700 "$APP_DIR/postgres"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"
# --- 3. Generate the three secrets, on the server ----------------------------
#
# Hex rather than base64 for all three: two of them travel inside a URL and the
# third inside a JSON body, and hex needs no escaping in either. Read the admin
# password later with
# grep ADMIN_PASSWORD /srv/umami/.env
if [ ! -f "$APP_DIR/.env" ]; then
umask 077
cat > "$APP_DIR/.env" <<-ENVFILE
DB_PASSWORD=$(openssl rand -hex 32)
APP_SECRET=$(openssl rand -hex 32)
ADMIN_PASSWORD=$(openssl rand -hex 24)
ENVFILE
chmod 600 "$APP_DIR/.env"
umask 022
fi
cd "$APP_DIR"
docker compose config >/dev/null
# --- 4. Caddy site block, on the host ----------------------------------------
if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-umami"
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
# --- 5. Ports: two open, and neither 8105 nor 5432 is one of them ------------
if command -v ufw >/dev/null 2>&1; then
echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8105 and 5432 stay closed"
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
fi
# --- 6. Start it -------------------------------------------------------------
#
# Umami applies its own schema migrations on the way up, and that is also what
# creates the built-in admin account that section 7 immediately takes over.
docker compose pull
docker compose up -d
echo "==> waiting for https://${DOMAIN_HOST}/api/heartbeat"
for _ in $(seq 1 30); do
code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/api/heartbeat" || true)"
[ "$code" = "200" ] && break
sleep 10
done
[ "${code:-}" = "200" ] || die "/api/heartbeat answered ${code:-nothing}. Check: docker compose logs --tail 40 umami"
curl -sS "https://${DOMAIN_HOST}/api/heartbeat" | grep -q '"ok":true' \
|| die "/api/heartbeat answered 200 without ok:true. Check: docker compose logs --tail 40 umami"
curl -sS "https://${DOMAIN_HOST}/login" | grep -q '<title>Login | Umami</title>' \
|| die "the login page did not carry the expected title. Check: docker compose logs --tail 40 umami"
# --- 7. Take the shipped credential away -------------------------------------
#
# Upstream documents a built-in account with a fixed published password. Until
# the next few lines run, that is a known credential on a public hostname.
login="$(curl -sS -X POST "https://${DOMAIN_HOST}/api/auth/login" \
-H 'Content-Type: application/json' \
--data '{"username":"admin","password":"umami"}' || true)"
token="$(printf '%s' "$login" | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')"
userid="$(printf '%s' "$login" | sed -n 's/.*"user":{"id":"\([^"]*\)".*/\1/p')"
[ -n "$token" ] && [ -n "$userid" ] || die "could not sign in with the shipped credential; nothing was changed"
changed="$(printf '{"password":"%s"}' "$(awk -F= '/^ADMIN_PASSWORD/{print $2}' "$APP_DIR/.env")" \
| curl -sS -o /dev/null -w '%{http_code}' -X POST "https://${DOMAIN_HOST}/api/users/${userid}" \
-H "Authorization: Bearer ${token}" \
-H 'Content-Type: application/json' \
--data-binary @- || true)"
[ "$changed" = "200" ] || die "the password update returned ${changed}, not 200. The shipped credential is still live."
stale="$(curl -sS -o /dev/null -w '%{http_code}' -X POST "https://${DOMAIN_HOST}/api/auth/login" \
-H 'Content-Type: application/json' \
--data '{"username":"admin","password":"umami"}' || true)"
[ "$stale" = "401" ] || die "the shipped credential still returns ${stale}, not 401. Stop and investigate."
unset login token userid
# --- 8. The first backup, before day one ends --------------------------------
STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T postgres pg_dump -U umami -d umami | gzip > "$APP_DIR/backups/umami-db-${STAMP}.sql.gz"
sudo tar -czf "$APP_DIR/backups/umami-config-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/umami-db-${STAMP}.sql.gz" ] || die "the database dump is empty"
cat <<-DONE
Umami is answering at https://${DOMAIN_HOST}/login
1. The account the image ships with had a published password. It has been
changed, and a sign-in with the old one was checked and refused.
2. Your admin password is in $APP_DIR/.env, mode 600. Read it with
grep ADMIN_PASSWORD $APP_DIR/.env
and put it in your password manager. It was not printed here, and it
never appeared on a command line. The username is admin.
3. Nothing is counted yet. Sign in, add a website, and paste the tracking
snippet it gives you into the <head> of the pages you want measured.
The address in that snippet is https://${DOMAIN_HOST}, so keep the
hostname.
4. First backup written to $APP_DIR/backups: a database dump and a config
archive. They are on the same disk as the data, which is not a backup.
Copy them somewhere else tonight.
DONEWhat you're signing up for
The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Umami Cloud.
- The account the image ships with is a published credential. Umami's first start creates a user named admin whose password is printed in the upstream documentation, so it is a known login on a public hostname from the second the container answers. This install replaces it during setup and then checks that the old one is refused; if you install Umami any other way, that is the first minute's work and it is not optional.
- You own a PostgreSQL. Every account, website, session and pageview is a row in it and there is no file anywhere to copy instead. Backups are a pg_dump, restores are a psql, and a dump you have never restored is not a backup.
- Nothing is counted until the snippet is on the page. The install ends with an empty dashboard, and the hostname you picked at step 1 is baked into the tracking snippet on every page you measure, so moving it later means editing all of them.
- Email reports and the streaming API are the two things the vendor names as hosted-only. Everything else on the pricing page's feature table, including funnels, user journeys, goals, revenue and attribution, is in this MIT release, and the country data works without a MaxMind account because the image builds a geo database in.
- Two outbound calls outlive this install. The dashboard fetches site icons from DuckDuckGo unless you set PRIVATE_MODE, and Umami asks upstream whether a newer version exists unless you set DISABLE_UPDATES. The anonymous telemetry is the one we turn off in the compose file.
Where this came from
“Your Umami installation will create a default administrator account with the username admin and the password umami.”
- The install creates a default administrator account with the username admin and a password published in the documentation, which upstream tells you to change immediately after the first login. source
- Version 3 of the image is a PostgreSQL-only build: the Dockerfile installs the Prisma Postgres adapter, so the tag no longer carries the postgresql- prefix that version 2 tags did. source
- Upstream's own compose file runs two services, the app and PostgreSQL, publishes the app on port 3000 and health-checks it at /api/heartbeat. source
- DATABASE_URL is the only required variable, and APP_SECRET is a random string that secures authentication tokens and should be unique per installation. source
- A major version upgrade leaves PostgreSQL's query planner with stale statistics, and upstream's documented fix is to run ANALYZE against the database afterwards. source
Questions people actually ask
Answered from this page's own data — the same numbers, in sentences.
Can I self-host Umami Cloud?
Not Umami Cloud itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is Umami. Pageviews, referrers and custom events on a hostname you own, from one container and a PostgreSQL, with no monthly event meter. The install is one evening: 2 containers behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 90 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.
What replaces Umami Cloud?
Umami. Pageviews, referrers and custom events on a hostname you own, from one container and a PostgreSQL, with no monthly event meter. The same code the paid plans run, MIT, from the vendor's own repository, and the compose file is upstream's. Two containers on a 1 GB box give you the whole dashboard: funnels, user journeys, goals, revenue and attribution are all in the open release, and the vendor names only email reports and the streaming API as things hosting keeps for itself. The two costs are honest ones. You own a PostgreSQL that has to be dumped rather than copied, and the image creates an admin account whose password is printed in the public documentation, so an install that does not close that in its first minute is a login anybody can look up. Also evaluated and ranked below it: Plausible CE. The prompt on this page installs Umami only. Umami is MIT-licensed and free; nothing on this page is a hosted service we sell you.
What does self-hosting cost compared to Umami Cloud?
1024 MB of RAM and 5 GB of disk — the smallest tier most VPS hosts sell, about $5 a month. Umami itself is free and MIT-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Umami Cloud Pro, $20/mo — $240 a year.
How hard is it really?
ONE EVENING — 1–3 hours. The rule that produced that verdict: up to three containers and at most one outside integration. You will type more than one command and read a page of documentation, and it will be running before you go to bed. The tier is derived from seven countable facts about the Umami install, not from anyone's impression of it, and the whole rubric is published on the methodology page.
Can I run Umami on my own computer instead of a server?
Yes — that is the second path in the prompt box above. "On my computer" installs the same Umami on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: The tracking script has to load from http://localhost:8105, which is a visitor's own computer in a visitor's own browser, so this path counts pages you open on this machine and nothing anyone else ever loads. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.
Content last checked 2026-08-06. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.