Last active
July 23, 2026 17:33
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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