3 min read

Goodbye Counter.dev: Self-Hosting Umami Web Analytics on a VPS

Goodbye Counter.dev: Self-Hosting Umami Web Analytics on a VPS

Introduction

For a long time I'd been using Counter.dev to track my blog's visitor stats — it's simple, free, and privacy-focused. But recently I noticed the dashboard had suddenly stopped counting, and even after logging in I couldn't see any new data.

A bit of digging revealed that Counter.dev is an open-source project run by a single person on a $5/month VPS with Redis. It's had several outages over the years (back in 2023 someone even asked "Is Counter.dev down?" on Hacker News). That's the fate of the freeloader — the service can vanish at any moment.

Since I already have my own VPS, why not just self-host something more stable? After looking around for a while, I settled on Umami — open source, lightweight, with an API, 35k+ stars on GitHub, and blazing fast to deploy with Docker.

Environment

  • VPS: Ubuntu 24.04 (already running Ghost CMS + Nginx)
  • Docker + Docker Compose
  • Domain managed through Cloudflare

Installing Docker

If your VPS doesn't have Docker yet:

curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker $USER

Once it's installed, log out and back in so the group change takes effect.

Deploying Umami

Create a project directory

mkdir ~/umami && cd ~/umami

Generate a password and salt

DB_PASS=$(openssl rand -hex 16)
APP_SECRET=$(openssl rand -hex 32)
echo "DB_PASS: $DB_PASS"
echo "APP_SECRET: $APP_SECRET"

Jot down these two values — you'll need them shortly.

Create docker-compose.yml

services:
  umami:
    image: ghcr.io/umami-software/umami:postgresql-latest
    ports:
      - "3001:3000"
    environment:
      DATABASE_URL: postgresql://umami:<your DB_PASS>@db:5432/umami
      APP_SECRET: <your APP_SECRET>
    depends_on:
      db:
        condition: service_healthy
    restart: always

  db:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: umami
      POSTGRES_USER: umami
      POSTGRES_PASSWORD: <your DB_PASS>
    volumes:
      - umami-db:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U umami"]
      interval: 5s
      timeout: 5s
      retries: 5
    restart: always

volumes:
  umami-db:

Start it up

docker compose up -d

Wait about 15 seconds for the Next.js build to finish, then confirm the service is alive:

curl -s -o /dev/null -w "%{http_code}" http://localhost:3001
# should return 200

Setting up the Nginx reverse proxy

Add an Nginx config file

sudo tee /etc/nginx/sites-available/analytics.yourdomain.com.conf << 'EOF'
server {
    listen 80;
    server_name analytics.yourdomain.com;

    location / {
        proxy_pass http://localhost:3001;
        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;
        proxy_set_header Host $host;
    }
}
EOF

Enable + test

sudo ln -s /etc/nginx/sites-available/analytics.yourdomain.com.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

DNS setup

Head to your DNS management panel (I use Cloudflare) and add an A record:

Name Type Value Proxy
analytics A Your VPS IP DNS only (grey cloud)
⚠️ Turn off the Cloudflare proxy first (grey cloud), because certbot needs a direct connection to the VPS to perform HTTP validation.

Confirm the DNS has propagated:

dig analytics.yourdomain.com +short
# should return your VPS IP

Adding SSL

sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d analytics.yourdomain.com

Just say Yes to everything — certbot will automatically update the Nginx config.

Logging in to Umami

Open https://analytics.yourdomain.com

Default credentials:

  • Username: admin
  • Password: umami

Change the password immediately after logging in! (Settings → Profile)

Add a website + get the tracking code

  1. Settings → Websites → Add website
  2. Enter your site's domain
  3. Grab the tracking script:
<script defer src="https://analytics.yourdomain.com/script.js" data-website-id="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"></script>

Integrating with Ghost CMS

Ghost Admin → Settings → Code injection → Site Header

  1. Delete the old Counter.dev script (if you have one)
  2. Paste in Umami's tracking script
  3. Save

Open an incognito window and visit your site, then head back to the Umami dashboard to check whether the data came through.

Backups

Since you're self-hosting, backups are now your own responsibility. Umami's data lives in PostgreSQL, and a single command will dump it:

docker compose -f ~/umami/docker-compose.yml exec -T db pg_dump -U umami umami > ~/umami-backup-$(date +%F).sql

If you already have an automated backup script (like mine, which uses cron + rclone to sync to Google Drive), just add one more line to it.

Updating Umami

When a new version comes out, two lines will do it:

cd ~/umami
docker compose pull && docker compose up -d

Counter.dev vs Umami

Counter.dev Umami
Cost Free Free (self-hosted)
Stability ⚠️ Small project maintained by one person ✅ Active open-source community, 20k+ stars
API ❌ No public API ✅ Full REST API
Data control Stored on someone else's Redis Stored on your own PostgreSQL
Ad blocking Easily blocked Self-hosted subdomain won't get blocked
Dashboard Ultra-minimal Full-featured, supports multiple sites

Conclusion

The whole process took about 15 minutes. If you already have a VPS running other services (like Ghost CMS), spinning up one more Docker container for Umami takes almost no extra resources. Compared to relying on a free third-party service that could go down at any time, self-hosting a stable, fully controllable analytics tool is absolutely worth it.