Skip to content

Instantly share code, notes, and snippets.

@rameerez
Last active July 23, 2026 17:33
Show Gist options
  • Select an option

  • Save rameerez/ec6f61734a40bd64084fd2e58fa976c7 to your computer and use it in GitHub Desktop.

Select an option

Save rameerez/ec6f61734a40bd64084fd2e58fa976c7 to your computer and use it in GitHub Desktop.
Production Umami self-hosted analytics setup (v2) — app layer only: Umami + Postgres (docker compose) + nginx + Let's Encrypt. Run https://setup.railsfast.com first to harden the host and install Docker.
#!/bin/bash
# ─────────────────────────────────────────────────────────────────────────────
# Umami Self-Hosted Analytics — App-Layer Setup Script v2.0.0
# For Ubuntu Server 24.04 LTS (Noble) and 26.04 LTS (Resolute)
#
# COMPANION to the RailsFast host hardening script (https://setup.railsfast.com).
# Run that FIRST — it hardens the host (SSH, ufw, fail2ban, sysctl, journald,
# unattended-upgrades, swap) and installs Docker CE + the compose plugin from
# Docker's official apt repository. This script deliberately does NONE of that;
# it only does the app layer:
# Umami + Postgres (docker compose) + nginx reverse proxy + Let's Encrypt.
#
# Usage (as root, after setup.railsfast.com has completed):
# wget -qO umami-setup.sh https://gist.github.com/rameerez/ec6f61734a40bd64084fd2e58fa976c7/raw
# less umami-setup.sh # always read a script before you run it
# UMAMI_DOMAIN=analytics.example.com CERTBOT_EMAIL=you@example.com bash umami-setup.sh
# When run interactively, missing values are prompted for; when run unattended
# (cloud-init), missing values FAIL the run instead of hanging on a prompt.
#
# Optional knobs (env vars):
# UMAMI_IMAGE_TAG=postgresql-v2.x.y # pin a specific Umami version
# # (default: postgresql-latest at install
# # time; there is NO auto-update cron —
# # update deliberately, see below)
# POSTGRES_IMAGE=postgres:17-alpine # bump when you're ready to migrate majors
#
# Changelog v1 (gist) -> v2:
# - v1 never installed Docker AT ALL — it assumed docker + docker-compose v1
# were already present, and its systemd unit hardcoded
# /usr/local/bin/docker-compose, which doesn't exist on a modern install.
# v2 requires the compose v2 plugin (`docker compose`) that Docker's own
# apt packages ship, and checks for it up front.
# - REMOVED the systemd unit entirely. It ran `docker compose up` in the
# foreground under Restart=always while the containers ALSO had
# restart: always — two supervisors fighting over the same containers.
# Docker's restart policy + an enabled docker.service is the supported way
# to survive reboots (https://docs.docker.com/engine/containers/start-containers-automatically/).
# - REMOVED the 2am cron that blind-pulled :latest and restarted, unattended,
# every night. An analytics dashboard doesn't need same-day updates, and a
# bad upstream tag shouldn't take you down at 2am. Update deliberately:
# /opt/umami/update-umami.sh (same commands, run by a human).
# - postgres:13 -> postgres:17-alpine (13 went EOL in November 2025).
# Existing v1 data dirs are detected and the script STAYS on the old major
# with a warning instead of corrupting the data dir (a major-version jump
# needs pg_dump/restore — see the warning it prints).
# - Re-running v1 REGENERATED DB_PASSWORD and overwrote .env, instantly
# locking the app out of the existing database. v2 preserves .env values
# across re-runs; the whole script is idempotent.
# - APP_SECRET is now generated and set (used to secure auth tokens —
# https://docs.umami.is/docs/environment-variables). v1 left it unset.
# - certbot registration: v1 used admin@$DOMAIN — usually a nonexistent
# address, so expiry warnings went nowhere. v2 requires a real
# CERTBOT_EMAIL.
# - REMOVED v1's /etc/cron.d/certbot-renew: Ubuntu's certbot package ships
# certbot.timer (systemd) which already renews twice daily, and the nginx
# plugin reloads nginx on renewal. A second renew path is just drift.
# - Healthchecks on both containers + depends_on: condition: service_healthy
# (v1 raced Postgres on first boot; Umami would crash-loop until the DB
# finished initializing).
# - nginx: security headers, and the proxy still binds upstream to
# 127.0.0.1:3000 only (that part v1 got right — keep it: ports PUBLISHED
# by Docker bypass ufw, so never publish 0.0.0.0).
# - Telemetry disabled (DISABLE_TELEMETRY=1), .env chmod 600, daily pg_dump
# backups with 14-day retention (analytics data is the one thing you can't
# re-create), and a final verification section that actually checks the
# deployment works before printing success.
# ─────────────────────────────────────────────────────────────────────────────
set -Eeuo pipefail
SCRIPT_VERSION="2.0.0"
UMAMI_DIR="/opt/umami"
UMAMI_IMAGE_TAG="${UMAMI_IMAGE_TAG:-postgresql-latest}"
POSTGRES_IMAGE="${POSTGRES_IMAGE:-postgres:17-alpine}"
export DEBIAN_FRONTEND=noninteractive
export NEEDRESTART_MODE=a
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
print_error() { echo -e "${RED}ERROR: $1${NC}"; }
print_warning() { echo -e "${YELLOW}WARNING: $1${NC}"; }
print_success() { echo -e "${GREEN}SUCCESS: $1${NC}"; }
print_step() { echo -e "${YELLOW}$1${NC}"; }
trap 'print_error "Script failed on line ${LINENO}"; exit 1' ERR
apt_get() {
apt-get \
-o DPkg::Lock::Timeout=600 \
-o Dpkg::Options::=--force-confdef \
-o Dpkg::Options::=--force-confold \
"$@"
}
# --- Pre-flight ---
if [[ $EUID -ne 0 ]]; then
print_error "This script must be run as root"
exit 1
fi
# Hard requirement: Docker CE + compose v2 plugin, i.e. the host was prepared
# (setup.railsfast.com installs both from Docker's official apt repo).
if ! command -v docker >/dev/null 2>&1 || ! docker compose version >/dev/null 2>&1; then
print_error "Docker with the compose plugin is not installed."
print_error "Run the host setup first: wget -qO- https://setup.railsfast.com | less # read it"
print_error " wget -qO railsfast-setup.sh https://setup.railsfast.com && bash railsfast-setup.sh"
exit 1
fi
if ! docker info >/dev/null 2>&1; then
print_error "The Docker daemon is not running (systemctl start docker)"
exit 1
fi
# --- Inputs: env vars first, interactive prompt as fallback, never hang ---
# ([[ -t 0 ]] = stdin is a TTY. Unattended runs must fail fast, not block.)
if [[ -z "${UMAMI_DOMAIN:-}" && -t 0 ]]; then
read -rp "Domain for Umami (e.g. analytics.example.com): " UMAMI_DOMAIN
fi
if [[ -z "${UMAMI_DOMAIN:-}" ]]; then
print_error "UMAMI_DOMAIN is required (e.g. UMAMI_DOMAIN=analytics.example.com bash $0)"
exit 1
fi
if ! [[ "$UMAMI_DOMAIN" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+$ ]]; then
print_error "'${UMAMI_DOMAIN}' does not look like a valid domain name"
exit 1
fi
# A real, monitored address: it's where Let's Encrypt sends expiry warnings.
if [[ -z "${CERTBOT_EMAIL:-}" && -t 0 ]]; then
read -rp "Email for Let's Encrypt expiry notices: " CERTBOT_EMAIL
fi
if [[ -z "${CERTBOT_EMAIL:-}" ]]; then
print_error "CERTBOT_EMAIL is required (v1 registered admin@\$DOMAIN, which usually doesn't exist)"
exit 1
fi
print_step "Umami app-layer setup v${SCRIPT_VERSION} for https://${UMAMI_DOMAIN}"
# --- /opt/umami and secrets (.env preserved across re-runs) ---
mkdir -p "$UMAMI_DIR"
cd "$UMAMI_DIR"
# Read a KEY=value from .env without sourcing it (sourcing executes content).
get_env() { grep -E "^$1=" .env 2>/dev/null | head -1 | cut -d= -f2- || true; }
DB_PASSWORD="$(get_env DB_PASSWORD)"
APP_SECRET="$(get_env APP_SECRET)"
# Password charset deliberately alphanumeric-only: it gets embedded in a
# postgres:// URL, where other chars would need percent-encoding.
[[ -n "$DB_PASSWORD" ]] || DB_PASSWORD=$(openssl rand -base64 48 | tr -d '/=+' | cut -c -32)
# APP_SECRET secures Umami's auth tokens; rotating it just logs sessions out,
# but there's no reason to churn it on re-runs either.
[[ -n "$APP_SECRET" ]] || APP_SECRET=$(openssl rand -hex 32)
cat > .env <<EOF
DB_PASSWORD=${DB_PASSWORD}
APP_SECRET=${APP_SECRET}
DOMAIN_NAME=${UMAMI_DOMAIN}
EOF
chmod 600 .env # world-readable DB credentials (v1's default umask) are not fine
# --- Postgres major-version guard (protects v1-era data dirs) ---
# A Postgres data dir only works with the major that created it. If one
# already exists (v1 used postgres:13 at this same bind-mount path), STAY on
# that major rather than crash-loop / risk the data, and tell the operator how
# to migrate properly.
WANTED_PG_MAJOR=$(echo "$POSTGRES_IMAGE" | sed -E 's/^postgres:([0-9]+).*/\1/')
if [[ -f postgres-data/PG_VERSION ]]; then
EXISTING_PG_MAJOR=$(cat postgres-data/PG_VERSION)
if [[ "$EXISTING_PG_MAJOR" != "$WANTED_PG_MAJOR" ]]; then
print_warning "Existing Postgres ${EXISTING_PG_MAJOR} data found in ${UMAMI_DIR}/postgres-data."
print_warning "Keeping postgres:${EXISTING_PG_MAJOR}-alpine to protect your data (wanted: ${POSTGRES_IMAGE})."
print_warning "To upgrade majors later: dump with pg_dump, move the old data dir aside, re-run this"
print_warning "script, restore the dump. Postgres 13 has been EOL since November 2025 — do schedule it."
POSTGRES_IMAGE="postgres:${EXISTING_PG_MAJOR}-alpine"
fi
fi
# --- docker-compose.yml ---
# Quoted heredoc: ${...} below is interpolated by docker compose from .env in
# this directory (compose reads it automatically), NOT by bash — so secrets
# live in one chmod-600 file instead of being baked into world-readable yaml.
# The umami port binds to 127.0.0.1 ONLY: nginx is the sole public entrance,
# and ports published by Docker bypass ufw entirely (Docker writes its own
# iptables rules — https://docs.docker.com/engine/network/packet-filtering-firewalls/).
cat > docker-compose.yml <<EOF
services:
umami:
image: docker.umami.is/umami-software/umami:${UMAMI_IMAGE_TAG}
container_name: umami
ports:
- "127.0.0.1:3000:3000"
environment:
DATABASE_URL: postgres://umami:\${DB_PASSWORD}@db:5432/umami
DATABASE_TYPE: postgresql
APP_SECRET: \${APP_SECRET}
DISABLE_TELEMETRY: 1
NODE_ENV: production
depends_on:
db:
condition: service_healthy
restart: always
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:3000/api/heartbeat || exit 1"]
interval: 30s
timeout: 5s
retries: 5
start_period: 60s
networks:
- umami_network
db:
image: ${POSTGRES_IMAGE}
container_name: umami_db
environment:
POSTGRES_DB: umami
POSTGRES_USER: umami
POSTGRES_PASSWORD: \${DB_PASSWORD}
volumes:
- ${UMAMI_DIR}/postgres-data:/var/lib/postgresql/data
restart: always
healthcheck:
test: ["CMD-SHELL", "pg_isready -U umami -d umami"]
interval: 10s
timeout: 5s
retries: 10
networks:
- umami_network
networks:
umami_network:
name: umami_network
EOF
# --- Deliberate-update + daily-backup helper scripts ---
# update-umami.sh exists but NOTHING schedules it — run it yourself after
# checking the Umami release notes. (v1 ran this nightly from cron against
# :latest, which is how you wake up to a broken dashboard.)
cat > update-umami.sh <<'EOF'
#!/bin/bash
set -euo pipefail
cd /opt/umami
docker compose pull
docker compose up -d
docker image prune -f
EOF
chmod +x update-umami.sh
# Daily logical backup, 14-day retention. pg_dump through the running
# container needs no client tools on the host and always matches the server
# major. Analytics history is the one thing you cannot regenerate.
cat > backup-umami.sh <<'EOF'
#!/bin/bash
set -euo pipefail
cd /opt/umami
mkdir -p backups
docker compose exec -T db pg_dump -U umami umami | gzip > "backups/umami-$(date +%F).sql.gz"
find backups -name 'umami-*.sql.gz' -mtime +14 -delete
EOF
chmod +x backup-umami.sh
# cron.d (not a user crontab like v1): survives user changes, explicit user
# field, and removing the file removes the job — no crontab surgery.
cat > /etc/cron.d/umami-backup <<'EOF'
30 3 * * * root /opt/umami/backup-umami.sh >/dev/null 2>&1
EOF
# --- Remove v1 footguns if this box was set up with the old gist ---
if [[ -f /etc/systemd/system/umami.service ]]; then
systemctl disable --now umami.service >/dev/null 2>&1 || true
rm -f /etc/systemd/system/umami.service
systemctl daemon-reload
print_warning "Removed v1's umami.service (it double-supervised the containers; Docker restart policies handle boot)."
fi
if crontab -l 2>/dev/null | grep -q 'update-umami.sh'; then
(crontab -l 2>/dev/null | grep -v 'update-umami.sh') | crontab -
print_warning "Removed v1's nightly auto-update cron. Update deliberately with /opt/umami/update-umami.sh."
fi
if [[ -f /etc/cron.d/certbot-renew ]]; then
rm -f /etc/cron.d/certbot-renew
print_warning "Removed v1's certbot cron (Ubuntu's certbot.timer already handles renewals)."
fi
# --- Start the stack ---
# `up -d --wait` blocks until healthchecks pass, so first-boot Prisma
# migrations finish before we put nginx in front of it.
print_step "Starting Umami (first boot runs database migrations — can take a minute)..."
docker compose up -d --wait --wait-timeout 300
# --- nginx + certbot ---
print_step "Installing nginx and certbot..."
apt_get update
apt_get install -y --no-install-recommends nginx certbot python3-certbot-nginx
# Plain-HTTP server block; certbot --redirect rewrites it for 443 + redirect.
cat > /etc/nginx/sites-available/umami <<EOF
server {
server_name ${UMAMI_DOMAIN};
# Umami payloads are tiny; a small cap is cheap DoS hygiene.
client_max_body_size 1m;
# Baseline security headers (Umami's dashboard is same-origin only).
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
}
listen 80;
listen [::]:80;
}
EOF
ln -sf /etc/nginx/sites-available/umami /etc/nginx/sites-enabled/
nginx -t
systemctl enable --now nginx
systemctl reload nginx
# Non-fatal on purpose: the classic first-run failure is DNS not pointing here
# yet, and torching the whole (working) setup over it helps nobody. The site
# serves HTTP meanwhile; re-run the printed command once DNS resolves.
CERT_OK=true
if ! certbot --nginx -d "$UMAMI_DOMAIN" -m "$CERTBOT_EMAIL" --agree-tos --no-eff-email --non-interactive --redirect; then
CERT_OK=false
print_warning "certbot failed — most likely ${UMAMI_DOMAIN} does not resolve to this server (yet)."
print_warning "Once DNS points here, run:"
print_warning " certbot --nginx -d ${UMAMI_DOMAIN} -m ${CERTBOT_EMAIL} --agree-tos --non-interactive --redirect"
fi
# Renewal: certbot.timer ships with Ubuntu's certbot package; assert it's on.
systemctl enable --now certbot.timer >/dev/null 2>&1 || true
# --- Verification (success is printed only if this passes) ---
print_step "Verifying deployment..."
verify_setup() {
local failed=0
if ! curl -sf http://127.0.0.1:3000/api/heartbeat >/dev/null; then
print_error "Umami is not responding on 127.0.0.1:3000 (docker compose logs umami)"
failed=1
fi
if ! docker compose ps --format '{{.Name}} {{.Health}}' | grep -q 'umami_db healthy'; then
print_error "Postgres container is not healthy (docker compose logs db)"
failed=1
fi
if ! systemctl is-active --quiet nginx; then
print_error "nginx is not running"
failed=1
fi
if [[ "$CERT_OK" == "true" ]] && ! curl -sf "https://${UMAMI_DOMAIN}/api/heartbeat" >/dev/null; then
print_warning "https://${UMAMI_DOMAIN} not reachable from this host (DNS/provider firewall?) — check from your machine."
fi
# This script does not manage the firewall (the host script owns it) — but
# an inactive one is worth shouting about.
if command -v ufw >/dev/null 2>&1 && ! ufw status | grep -q "Status: active"; then
print_warning "ufw is NOT active. Did you run the host hardening script (setup.railsfast.com) first?"
fi
return $failed
}
if ! verify_setup; then
print_error "Setup verification FAILED — see the errors above. Safe to re-run this script after fixing."
exit 1
fi
PROTO="https"; [[ "$CERT_OK" == "true" ]] || PROTO="http"
print_success "Umami is up at ${PROTO}://${UMAMI_DOMAIN}"
echo ""
print_step "Next steps:"
print_step "1. Log in with the default credentials: admin / umami"
print_step " >>> CHANGE THIS PASSWORD IMMEDIATELY (Settings -> Profile) — this is a public login page. <<<"
print_step "2. Add your website in the dashboard, then drop the tracking snippet into your app's <head>:"
print_step " <script defer src=\"${PROTO}://${UMAMI_DOMAIN}/script.js\" data-website-id=\"...\"></script>"
print_step "3. Updates are manual and deliberate: /opt/umami/update-umami.sh (check release notes first)"
print_step "4. Daily DB backups land in ${UMAMI_DIR}/backups (14-day retention) — copy them off-box too."
print_step "5. Provider firewall (e.g. Hetzner Cloud Firewall): allow 22, 80, 443 as the outer layer."
exit 0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment