GDPR compliant website analytics with Umami (incl. OpenTofu/Ansible in Hetzner Cloud setup)

Dive into Umami, an open-source and GDPR-compliant Google Analytics alternative, and explore web data analytics with dashboards and reports. A detailed setup guide with OpenTofu and Ansible in Hetzner Cloud is included for self-hosted environments.

GDPR compliant website analytics with Umami (incl. OpenTofu/Ansible in Hetzner Cloud setup)

I have used Google Analytics for many years to get an insight into page views and visitor geography. With the importance of GDPR compliance, cookie banners have become a requirement. I often asked myself: What am I doing with the collected data, and is it worth the cookie banner effort?

Short answer: Not much, and no. Long answer: I'm interested in learning the topic interest from my readers in different regions, for example, me learning AI in public.

After Daniel Bodky shared alternatives to Google Analytics, I was sold on trying out a more lightweight approach: Umami.

Umami makes it easy to collect, analyze, and understand your web data — while maintaining visitor privacy and data ownership.

This article shares how to get started with Umami, dashboard and configuration insights, and integrations into MkDocs, Ghost and Hugo based websites. If you are interested in self-hosting Umami, the setup in Hetzner Cloud using OpenTofu and Ansible is described in detail at the bottom.

Umami Overview

The next sections describe how to get started with Umami, add websites to collect data, explore the dashboards and detail view, and more configuration and integration options.

Get Started with Umami

To get started quickly, you can start with the SaaS free tier, or run the docker-compose setup in a cloud VM. Embedding Umami into public website code requires a public web server host where it connect to.

Open Umami in your browser, and log in. You are greeted with an empty dashboard, and need to configure data collection first.

Add a website and collect data

Navigate into Settings and click on the Add Website button. Fill the name and domain entries and save.

Settings to add a new website

Next, click on Edit and navigate into Tracking code to get the JavaScript integration snippets for your website.

Edit a website to get its tracking code for the website's <head> section.

Add the tracking code into your website's <head> section, and save. Open the website in a second browser window, or run a few curl commands on the command line to simulate users.

Navigate back to the Umami dashboard to see the new website, and its data.


The dashboard provides an overview of all configured websites and quick range selectors. Click on Edit to enable a view to change the order with drag and drop.

Umami dashboard, showing status for, with opened range selector dropdown.
Full dashboard with four configured websites, all time view (started on 2024-01-27).

You can also toggle the charts to only see numbers and trends.

Dashboard without charts.

Detailed view

The detail view for each website provides chart range, and filter options. You can also switch to the Realtime view.

There are multiple sections to explore: Pages and Views, Referrers.

Detail view with charts, pages/views, referrers.

Browsers/Visitors, OS/Visitors, Devices/Visitors and geographical location.

Detail view at the bottom with browsers, OS, devices and countries.

Each section has a More action which provides more granular filter options.

More options to analyze and filter data

Based on the data inspection, you can create view filters, for example filter by all macOS users with Chrome.

Filtered detail view.


You can create custom reports based on the filtered data: Insights, Funnel, Retention.

Reports start page.

For example, create insights into Page titles and views in the country: Austria.

Custom report for - page views in Austria.


  1. Tracker configuration: Different send location, Google Tag Manager support, etc.
  2. Team management: Create teams and assign users to specific website data only. Authentication

Automation API

Any operation through the UI can also be performed through the API. That way you can export the report data as JSON for external analytics systems, or integrate Observability workflows.

More Umami Integrations

Umami provides plugins for Vuepress, Gatsby, Nuxt, Docusaurs, Wordpress and more. The websites shown in the screenshots above use different web applications. The next sections describe them specifically, in case you are using them :)

Ghost (this blog)

Ghost supports custom Javascript through Code injection in the settings. Log into Ghost, and select the settings icon at the left bottom, path is /ghost/#/settings. Navigate into Code injection, and add the Umami snippet.

Ghost settings with code injection for Umami Tracking Code.

MkDocs with Material theme

Material for MkDocs requires specific overrides with extrahead in the main section, called "overriding blocks" in the docs.

  1. Create a new directory overrides and file main.html
  2. Copy the content below, and modify the Umami integration. The code extends the base.html and adds an extrahead block. I have added additional code for setting the page title correctly. You can also use the {{ super() }} method explained in the docs.
{% extends "base.html" %}

{% block extrahead %}
  {% set title = config.site_name %}
  {% if page and page.meta and page.meta.title %}
    {% set title = title ~ " - " ~ page.meta.title %}
  {% elif page and page.title and not page.is_homepage %}
    {% set title = title ~ " - " ~ page.title %}
  {% endif %}

  <!-- Umami analytics, -->
<script async src="" data-website-id="ad71bbd0-1f16-40e5-bb0d-0a11cfa50a45"></script>
{% endblock %}

3. Modify the .mkdocs.yml configuration file, add the custom_dir path.

  name: material
  custom_dir: overrides   

See this MR example. More working examples are available in



The meetup website uses Hugo with a custom theme. Inside the theme directory, the path layouts/partials/html-header.html or otherwise should exist with <head> section. Copy paste the Umami snippet there. Alternatively, create a custom analytics partial following this tutorial.

Hugo template partial with Umami Tracking Code.

Umami Setup

You can subscribe to their SaaS cloud version and start using Umami. Alternatively, you can self-host the open source project. Fortunately, there is a containerized docker compose setup available, defaulting to PostgreSQL.

Before automating the setup, I created a blank Ubuntu 22 VM, installed docker and docker compose, cloned the Git repository and started the default configuration.

git clone
cd umami
docker compose up -d
docker ps 

After inspecting the web interface on port 3000 (<IP address>:3000), and the running containers, I started looking into how to setup an Nginx TLS proxy in front of Docker. Either managed through Ansible, or in Docker itself with automated renewal from Lets Encrypt through ACME DNS or HTTP challenges.

The next sections describe the final working setup with OpenTofu/Ansible in a Hetzner Cloud VM. You can modify the configuration for your own use case, or only use the parts with Ansible templates and docker provisioning. TLS certificates are managed with Nginx-proxy and its ACME companion for Lets Encrypt.

Disclaimer: I'm still learning OpenTofu and Ansible. The implementation might not be perfect, and requires your review for production usage. The configuration is tested against IaC security scanners, see the .gitlab-ci.yml configuration.

Hetzner Cloud VM with OpenTofu

Note: The OpenTofu setup is specific to Hetzner Cloud, while the Ansible playbooks below should work with any Ubuntu 22.04 LTS VM.

Note 2: I decided to use OpenTofu on the CLI (tofu) instead of Terraform. HashiCorp changed the Terraform to not-open-source BuSL as license, and recently sent a cease&desist letter to OpenTofu.

Create a new directory called terraform/ or clone my project and modify for your environment. Follow best practices for directory and file layout:

  1. to specify the required provider hetznercloud/hcloud
terraform {
  required_providers {
    hcloud = {
      source = "hetznercloud/hcloud"
      version = "1.45.0"

2. to configure the hcloud_token variable marked as sensitive. Its value will be provided by terraform.tfvars which is not added to the Git repository.

variable "hcloud_token" {
    sensitive = true

3. terraform.tfvars to specify the hcloud_token variable value (added to .gitignore).

hcloud_token = "xxx"

4. user-data-michi-fyi.yml for cloud-init configuration for Hetzner Cloud. The file name is intentional for my use case, rename it to your environment.

cloud-init helps make the initial VM provision more secure: Set up a local user with SSH keys, harden SSH login (no root), install and configure fail2ban and ufw as firewall. Replace <fqdn>, <sshkey> and <username> in the example below.

fqdn: <fqdn>
hostname: <fqdn>
locale: en_US.UTF-8
timezone: Europe/Berlin
  - name: <username>
    groups: users, admin
    shell: /bin/bash
      - <sshkey>
package_update: true
package_upgrade: true
package_reboot_if_required: true
  - fail2ban
  - ufw
  - ufw allow 'Nginx HTTP'
  - printf "[sshd]\nenabled = true\nbanaction = iptables-multiport" > /etc/fail2ban/jail.local
  - systemctl enable fail2ban
  - systemctl start fail2ban
  - ufw allow 'OpenSSH'
  - ufw enable
  - sed -ie '/^PermitRootLogin/s/^.*$/PermitRootLogin no/' /etc/ssh/sshd_config
  - sed -ie '/^PasswordAuthentication/s/^.*$/PasswordAuthentication no/' /etc/ssh/sshd_config
  - sed -ie '/^X11Forwarding/s/^.*$/X11Forwarding no/' /etc/ssh/sshd_config
  - sed -ie '/^#MaxAuthTries/s/^.*$/MaxAuthTries 2/' /etc/ssh/sshd_config
  - sed -ie '/^#AllowTcpForwarding/s/^.*$/AllowTcpForwarding no/' /etc/ssh/sshd_config
  - sed -ie '/^#AllowAgentForwarding/s/^.*$/AllowAgentForwarding no/' /etc/ssh/sshd_config
  - sed -ie '/^#AuthorizedKeysFile/s/^.*$/AuthorizedKeysFile .ssh/authorized_keys/' /etc/ssh/sshd_config
  - sed -i '$a AllowUsers <username>' /etc/ssh/sshd_config
  - systemctl restart ssh

5. to configure the provider with the token attribute.

provider "hcloud" {
    token = var.hcloud_token

Additionally, create a new resource for hcloud_server and specify the Hetzner Cloud specific attributes: name (add your FQDN), image (ubuntu-22.04 is LTS), server_type (cx41 = Shared vCPU x86, 4 vCPUs, 16 GB RAM), location (nbg1 = Nuremberg), public_net (enable IPv4 and IPv6), labels, user_data (reference the cloud-init config file).

resource "hcloud_server" "web" {
    name = "<fqdn>"
    image = "ubuntu-22.04"
    server_type = "cx41" # 
    location    = "nbg1"

    public_net {
        ipv4_enabled = true
        ipv6_enabled = true

    labels = { "os" = "ubuntu" }

    # Hetzner cloud-init 
    user_data = "${file("user-data-michi-fyi.yml")}"

6. to parse the plan execution results in a readable way.

output "web_server_status" {
  value = hcloud_server.web.status

output "web_server_ips" {
  value = {
    name =
    ipv4 = hcloud_server.web.ipv4_address
    ipv6 = hcloud_server.web.ipv6_address 

Apply plan and provision cloud resources

tofu plan -out tf.plan
tofu apply tf.plan 

Example output:

hcloud_server.web: Creating...
hcloud_server.web: Creation complete after 8s [id=42593855]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.


web_server_ips = {
  "ipv4" = ""
  "ipv6" = "2a01:4f8:c2c:481b::1"
  "name" = ""
web_server_status = "running"

DNS Setup

I'm using Namecheap for DNS registration and management, and there is an API and Terraform provider available, but it requires whitelisting an IP address. Coming from a dynamic range ISP (DTAG), I decided to not bother and manage the IPv4 and IPv6 address manually in the web dashboard only once.

While testing different Umami setups, I also decided to use a dedicated umami.fqdn sub domain. I will explore automation in future blog posts.

DNS records in Namecheap for the Umami setup.

Ansible (Docker Compose, Nginx proxy, Umami)

The Ansible configuration takes care of

  1. Base system packages (git, vim, wget, curl, htop) installation.
  2. Unattended upgrade configuration on Ubuntu, using the hifis.unattended_upgrades Ansible role.
  3. Setup Docker and Docker Compose, using the geerlingguy.docker Ansible role.
  4. Deploy Docker compose assets and configuration

Create a new directory called ansible/ or clone my project and modify it for your environment.

Ansible requirements

Create a new file requirements.yml and specify the roles and collections.

- name: cloudalchemy.prometheus
- name: geerlingguy.docker 
- name: hifis.unattended_upgrades 

- name: community.general

Install the requirements from Ansible Galaxy.

cd ansible
ansible-galaxy install -r requirements.yml 

Default configuration playbook

The default configuration takes care of installing a list of base packages (git, vim, wget, curl, htop), and enables unattended upgrades.

Replace <user> with your SSH login user the following example, source here.

- name: Base Server Setup
  hosts: all
  remote_user: <user>
  become: true
  become_method: ansible.builtin.sudo
  - role: hifis.unattended_upgrades
    - 'origin=Ubuntu,archive=${distro_codename}-security'
    - 'o=Ubuntu,a=${distro_codename}'
    - 'o=Ubuntu,a=${distro_codename}-updates'
    - 'o=Ubuntu,a=${distro_codename}-proposed-updates'
    unattended_package_blacklist: [cowsay, vim]

      - git
      - vim
      - wget
      - curl
      - htop
    - name: Ensure a list of packages installed
        name: "{{ packages }}"
        state: present

    - name: All done!
        msg: Packages have been successfully installed

Umami and Nginx proxy playbook

You can manage Umami and the Nginx proxy with Ansible. If you have an Nginx proxy setup already, you can skip ahead into the Umami section.

The setup requires Docker, using the Geerlingguy.docker role. The playbook provisions docker-compose configuration, and required assets, as Jinja templates. This allows to provision variables managed with Ansible.

    default_vhost: "fqdn"
    umami_vhost: "umami.fqdn"
    acme_default_email: ""

Docker compose: Nginx proxy

The Nginx proxy setup puts a container in front of all HTTP/80 and 443 traffic. It requires outside volumes for configuration, vhost, html data, dhparam, and certificate access. The Docker socket access is needed for reading the environment variables from spawned containers. You only need to specify the VIRTUAL_HOST environment variable in new containers to automatically route the traffic.

Since Hetzner Cloud fully supports IPv6, the environment variable ENABLE_IPV6 enables this specifically.

    image: nginxproxy/nginx-proxy
    container_name: nginx-proxy
    restart: always 
      - "80:80"
      - "443:443"
      - conf:/etc/nginx/conf.d
      - vhost:/etc/nginx/vhost.d
      - html:/usr/share/nginx/html
      - dhparam:/etc/nginx/dhparam
      - certs:/etc/nginx/certs:ro
      - /var/run/docker.sock:/tmp/docker.sock:ro
      - ENABLE_IPV6=true
      - nginx-proxy



Nginx proxy network

Communication between Nginx proxy contains requires the nginx-proxy network. This network is created in the nginx-proxy docker-compose.yml and can be used for any other docker compose setup on the same host.

    name: nginx-proxy

The container service definition requires

      - nginx-proxy

Nginx proxy ACME companion for Lets Encrypt certificates

The acme-companion container takes care of TLS certificate requests and renewals. It needs write access to an outside certs volume, and requires read-only access to the docker socket to inspect newly started containers and their environment.

As long as you specify VIRTUAL_HOST and LETSENCRYPT_HOST  for all docker compose containers, the ACME companion will take care of TLS certificate requests and renewal with Lets Encrypt. This also works for sub-domains.

    image: nginxproxy/acme-companion
    container_name: nginx-proxy-acme
    restart: always
      - DEFAULT_EMAIL={{ acme_default_email }}
      - nginx-proxy
      - certs:/etc/nginx/certs:rw
      - acme:/etc/
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - nginx-proxy

The Ansible Jinja template for docker-compose.yml uses the default_vhost and acme_default_email variables to specify them as Ansible variables.

Test the setup with a default website

The Ansible playbook provisions some sample data into /docker/website-default which should be served. You can test it by visiting ;-)

The default website needs a TLS certificate for www and root domain, specified with VIRTUAL_HOST and LETSENCRYPT environment variables for the Nginx Proxy ACME compantion. The default_vhost variable is provisioned from Ansible into the docker-compose configuration. Communication is done through the nginx-proxy network.

  # default website 
    image: nginx:alpine
    container_name: website
    restart: always
      - /docker/website-default:/usr/share/nginx/html:ro
      - VIRTUAL_HOST=www.{{ default_vhost }},{{ default_vhost }}  # your domain
      - LETSENCRYPT_HOST=www.{{ default_vhost }},{{ default_vhost }}
      - nginx-proxy

You can inspect the full docker-compose configuration and Ansible playbook to learn more. is available here.

Docker Compose: Umami

The upstream Umami docker-compose setup uses PostgreSQL, and comes with two services. You can test and run this setup on any host with Docker compose.

Adding Umami into an Nginx proxy setup requires the following changes:

  1. Change the ports with 3000 to (does not need to listen on
  2. Read the .env file for secrets and connection handling
  3. Add VIRTUAL_HOST and LETSENCRYPT_HOST as environment variables for Nginx proxy ACME companion for automated TLS certificates.
  4. Remove the APP_SECRET, POSTGRES_DB, POSTGRES_USER, POSTGRES_PASSWORD  environment variables, this is provisioned and exported through .env.
  5. Add the proxy network as external network, using nginx-proxy to route all traffic. The default network is local only between the Umami and database container.

The docker-compose Jinja template is located here.

Umami container

      - ""
    env_file: .env
      VIRTUAL_HOST: {{ umami_vhost }}
      LETSENCRYPT_HOST: {{ umami_vhost }}
        condition: service_healthy
      test: ["CMD-SHELL", "curl http://localhost:3000/api/heartbeat"]
      interval: 5s
      timeout: 5s
      retries: 5
      - default
      - proxy  
    restart: always

Umami database container

    image: postgres:15-alpine
      - ""    
    env_file: .env
      - umami-db-data:/var/lib/postgresql/data
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 5s
      timeout: 5s
      retries: 5      
      - default 
    restart: always     




    name: default

    name: nginx-proxy
    external: true

The docker-compose Jinja template is located here.

Before provisioning with Ansible, the setup also requires secrets and database connection handling setup in the next sections. If you want to iterate faster, you can hardcode the secrets first and add the advanced automation steps in a second step. This is also my learning curve.

Umami secrets

The database username requires a password. I strongly recommend to not use the default value, and instead use the Ansible vault to manage the secret.

  1. Store the postgres_password variable value safely in your password manager (1Password, etc.)
  2. Ansible Vault encryption will ask for a password. Store it safely in your password manager, too.
cd ansible 

vim secrets_file.enc

postgres_password: XXX

ansible-vault encrypt secrets_file.enc  

The encrypted secrets_file needs to be passed to the Ansible CLI command like this:

ansible-playbook -i inventory.ini -e @secrets_file.enc --ask-vault-pass analytics.yml  

Umami app secret

Umami requires an application secret which can either be provisioned through the Ansible vault, or regenerated at provision time. I chose to generate the secret on every Ansible provision run because it only is used internally, and I am a fan of rotating secrets often.

    - name: Generate random string for umami secret
        secret_key: "{{ lookup('community.general.random_string', upper=false, numbers=false, special=false, length=16) }}"
      no_log: true

Umami database setup

For connection handling, Ansible provisions an .env file with connection and authentication environment variables. The .env file is stored in a Jinja template to replace the Ansible variables secret_key (from playbook) and umami_postgres_password (from vault).


DATABASE_URL=postgresql://umami:{{ umami_postgres_password }}@umami-db:5432/umami
APP_SECRET={{ secret_key }}

POSTGRES_PASSWORD={{ umami_postgres_password }}

# Force auth method to scam-sha-256 instead of md5 

The .env file is read by docker-compose containers using the env_file: .env setting in docker-compose.yml.

    image: postgres:15-alpine
      - ""    
    env_file: .env

The database will be located in /var/lib/docker/volumes/umami_umami-db-data/ depending on the container service name.

If you need to debug database connections, you can use a local PostgreSQL client image:

docker run -dit --network=umami_default --name=pgclient codingpuss/postgres-client

source .env

docker exec -it pgclient psql $DATABASE_URL -c 'select * from pg_catalog.pg_tables'
psql: error: FATAL:  password authentication failed for user "umami"

Ansible provision, finally

You should have the following directory structure for Ansible, following this blog post. If not, inspect

Ansible directory tree.

If you have an Nginx proxy setup already, please remove the sections with nginx-proxy-letsencrypt in the Ansible playbook.

Run the playbook to provision the docker-compose configuration and assets, and provide the vault password when asked.

ansible-playbook -i inventory.ini -e @secrets_file.enc --ask-vault-pass analytics.yml

SSH into the host, and become root.

ssh fqdn
sudo -i

Docker compose up  

Navigate into the /docker directory, and the respective docker-compose tree.

Umami server, provisioned directory tree for docker compose

Start nginx-proxy-letsencrypt and umami

cd nginx-proxy-letsencrypt/ 
docker compose up -d

# wait until the containers show up as healthy
docker compose ps 
cd ..

cd umami 
docker compose up -d

# wait until the containers show up as healthy
docker compose ps 
cd ..

More help Docker compose commands below:

# Start services in background
docker compose up -d

# Status
docker compose status

# Restart all containers
docker compose restart

# Stop all containers 
docker compose down 

# Update container images 
docker compose pull

Service start after reboot

Reboots after Kernel upgrades require the containers to be restarted. The restart: always attribute for services in the docker-compose.yml file takes care after running docker compose up -d for the first time.

Verify Umami working

Open a webbrowser and navigate to your Umami FQDN or sub domain. You should be greeted by the account creation form. Create a strong password with your password manager. Close the browser, and open it again to login.

Umami login form.

Next, start exploring Umami. Navigate back to the top of this blog post to learn about website integrations with MkDocs, Ghost and Hugo.


Umami is a lightweight web analytics platform, fully open source and GDPR compliant without cookie banners. You can either use Umami as SaaS service, or self-host. The containerized setup can be automated with docker compose and Ansible. Provisioning a Linux host VM can be achieved using OpenTofu.

If you want to contribute to Umami, dive into the source code and create a pull request. The maintainers are working with a stable master , next dev Git branch workflow.

After running Umami for 4 weeks now, I see AI and CI/CD topics trending at the top 56% of views (my two main work focus areas in 2024). This helps refine my public learning strategy on my personal blog, when not publishing on the GitLab blog :)

Page views for, showing the top 56% of traffic with AI and CI/CD.

Overall, my impression is very positive, and I will continue using Umami in production. Next to exploring Google Analytics alternatives, I was able to expand my knowledge with Ansible and OpenTofu, and how TLS certificate renewal can be done with containers.

PS: An alternative approach for traffic routing and TLS handling with Nginx proxy can be Traefik, described in this blog post by Daniel Bodky:

How to Reverse-Proxy Applications on Subpaths with Traefik
Reverse-proxying applications on subpaths with Traefik should be straight-forward, but sometimes it isn’t. Here’s how to do it anyways.