Nicholas Clooney

用 Umami、Docker Compose 和 Ansible 搭建私有分析系统

所属系列

我想给博客加上第一方访问分析,又不想把流量数据交给 SaaS 厂商。Umami 完全符合需求:开源、可自托管、尊重隐私。我本来就有一台全天在线的小型 VPS,分出一点资源给 Umami,感觉正合适。


为什么是 Umami,为什么是现在

关掉常见的追踪器后,访问分析就成了盲区。我需要一个这样的方案:

  • 自托管,让数据始终留在自己的基础设施内。
  • 足够轻量,能和其他服务一起运行在同一台机器上。
  • 适合我的工作流,最好像其他服务一样由 Ansible 管理。

Umami 是一个简单的 Node 应用,数据存储在 Postgres 中。官方文档让本地或云端运行都很容易,但我想要的是一套可重复、适合生产环境的配置,能先在 Mac 上测试,再一口气通过 Ansible 部署。


简单认识 Umami

如果你还没接触过,Umami 是一个开源分析平台,提供类似 Google Analytics 的基础功能,但没有那些臃肿的部分。它是一个以 Postgres 为后端的 Node 应用,给网站提供一小段 <script>,再通过漂亮的仪表盘查看数据。没有第三方 Cookie,没有隐藏追踪器,就是一个直截了当了解访客的工具。


我考虑过的部署方式

部署 Umami 有三种显而易见的方式:

  1. 在服务器上直接安装 Node、pnpm 和 PM2。
  2. 用一个 Docker 容器运行应用,单独管理 Postgres。
  3. 用 Docker Compose 定义这两个服务及其关系。

第三个方案立刻胜出。Compose 带来:

  • 本地环境一致。 我可以用 Colima 在 macOS 上启动整套服务,就像这篇在 macOS 上使用 Docker 的文章里一样。
  • 可复现的组合。 compose 文件描述确切的镜像、健康检查和所需卷,非常适合基础设施即代码。
  • 不污染宿主机。 VPS 保持为干净的 Docker 主机,不会残留 Node/npm/PM2 软件包。
  • 明确的依赖关系。 Compose 编排 Postgres + Umami,等待数据库健康检查通过后再启动应用。
  • 严格的网络边界。 服务通过私有桥接网络通信,只有我发布的端口才会暴露到宿主机。
  • 适合 Ansible 自动化。 Ansible 可以在同一个 role 中放置 compose 文件、渲染 .env,并运行 docker compose up -d。

核心 Ansible Role

我把所有东西封装在 ansible-role-umami 中,以便跨机器复用。在提交 f31f9b9a1c71039311a71ece3c8c8162de84316c 中,compose 模板如下:

		
  1. services:
  2. db:
  3. image: {{ umami_postgres_image }}
  4. restart: unless-stopped
  5. environment:
  6. POSTGRES_DB: ${POSTGRES_DB}
  7. POSTGRES_USER: ${POSTGRES_USER}
  8. POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
  9. TZ: ${TZ}
  10. healthcheck:
  11. test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""]
  12. interval: 5s
  13. timeout: 5s
  14. retries: 10
  15. volumes:
  16. - umami-db-data:/var/lib/postgresql/data
  17. networks:
  18. - umami-net
  19. logging:
  20. driver: json-file
  21. options:
  22. max-size: "10m"
  23. max-file: "3"
  24. umami:
  25. image: {{ umami_image }}
  26. restart: unless-stopped
  27. depends_on:
  28. db:
  29. condition: service_healthy
  30. ports:
  31. - "{{ umami_bind_address }}:${UMAMI_PORT:-{{ umami_listen_port }}}:3000"
  32. environment:
  33. DATABASE_TYPE: postgresql
  34. DATABASE_URL: ${DATABASE_URL}
  35. APP_SECRET: ${APP_SECRET}
  36. HOSTNAME: "0.0.0.0"
  37. PORT: "3000"
  38. TZ: ${TZ}
  39. healthcheck:
  40. test: ["CMD-SHELL", "curl -fsS http://localhost:3000/api/heartbeat || exit 1"]
  41. interval: 10s
  42. timeout: 5s
  43. retries: 10
  44. networks:
  45. - umami-net

几个重点:

  • Postgres 使用命名卷持久化数据,并提供健康检查。
  • Umami 等待健康检查通过后再启动。
  • ports 指令绑定到 {{ umami_bind_address }},因此我可以将它限制在 127.0.0.1,而不是公开接口上。

默认值与模板放在一起,因此每次安装默认都只监听回环地址的 3000 端口,除非我主动覆盖:

		
  1. ---
  2. umami_timezone: Europe/London
  3. # Config storage (remote)
  4. umami_base_dir: "/opt/umami"
  5. # Secrets storage (local controller)
  6. umami_secrets_dir: "{{ playbook_dir }}/.secrets/{{ inventory_hostname }}"
  7. # Container images
  8. umami_image: ghcr.io/umami-software/umami:postgresql-latest
  9. umami_postgres_image: postgres:18-alpine
  10. # Networking
  11. umami_listen_port: 3000
  12. umami_bind_address: 127.0.0.1
  13. # Database
  14. umami_db_name: umami
  15. umami_db_user: umami
  16. umami_db_password: ""
  17. umami_app_secret: ""
  18. # Compose options
  19. umami_compose_project_name: umami
  20. umami_compose_pull: always
  21. umami_compose_recreate: auto

主任务文件把这些串起来。Ansible 在控制端生成强密钥,让它们在多次运行之间保持不变;渲染 .env 和 docker-compose.yml;然后通过社区模块执行 docker compose up:

		
  1. ---
  2. - name: Ensure base directory exists (remote)
  3. become: true
  4. ansible.builtin.file:
  5. path: "{{ umami_base_dir }}"
  6. state: directory
  7. owner: root
  8. group: root
  9. mode: "0755"
  10. # --- Local secrets handling ---
  11. - name: Ensure local secrets dir exists (controller)
  12. ansible.builtin.file:
  13. path: "{{ umami_secrets_dir }}"
  14. state: directory
  15. mode: "0700"
  16. delegate_to: localhost
  17. run_once: false
  18. - name: Generate DB password if needed (local)
  19. ansible.builtin.set_fact:
  20. umami_db_password: >-
  21. {{ lookup('ansible.builtin.password',
  22. umami_secrets_dir ~ '/.db_password chars=ascii_letters,digits length=32') }}
  23. when: (umami_db_password | default('') | length) == 0
  24. delegate_to: localhost
  25. run_once: false
  26. - name: Generate app secret if needed (local)
  27. ansible.builtin.set_fact:
  28. umami_app_secret: >-
  29. {{ lookup('ansible.builtin.password',
  30. umami_secrets_dir ~ '/.app_secret chars=hexdigits length=64') }}
  31. when: (umami_app_secret | default('') | length) == 0
  32. delegate_to: localhost
  33. run_once: false
  34. # --- Remote config + deployment ---
  35. - name: Render .env file
  36. become: true
  37. ansible.builtin.template:
  38. src: env.j2
  39. dest: "{{ umami_base_dir }}/.env"
  40. owner: root
  41. group: root
  42. mode: "0640"
  43. notify: Restart umami stack
  44. - name: Render docker-compose.yml
  45. become: true
  46. ansible.builtin.template:
  47. src: docker-compose.yml.j2
  48. dest: "{{ umami_base_dir }}/docker-compose.yml"
  49. owner: root
  50. group: root
  51. mode: "0644"
  52. notify: Restart umami stack
  53. - name: Ensure Umami stack is running
  54. become: true
  55. community.docker.docker_compose_v2:
  56. project_src: "{{ umami_base_dir }}"
  57. project_name: "{{ umami_compose_project_name }}"
  58. state: present
  59. pull: "{{ umami_compose_pull }}"
  60. recreate: "{{ umami_compose_recreate }}"
  61. register: umami_compose_result
  62. - name: Display docker compose changes
  63. ansible.builtin.debug:
  64. var: umami_compose_result
  65. when: umami_compose_result is defined

任一模板变化时,handler 只需重启整套服务,让升级行为保持可预测。


关于 Docker 与 UFW 的提醒

如果发布端口时没有留意,Docker 会悄悄绕过 UFW,因为它管理自己的 iptables 链。这意味着,即使服务器设置了“拒绝传入”,只要绑定到 0.0.0.0,仍可能把应用暴露给公网。

运行容器并发布端口时,例如 -p 3000:3000,Docker 会直接修改 iptables,而不是通过 ufw。
这些规则先于 ufw 的用户空间规则被求值。
所以,即使 ufw 已启用,一条简单的 docker run -p 3000:3000 umami 仍会在所有接口 0.0.0.0 上暴露 3000 端口。

在 compose 文件中绑定 127.0.0.1,可以让仪表盘保持完全私有,直到我在前面放上反向代理或 Tailscale。


将 Role 接入 Project Lighthouse

我的家庭实验室 playbook ansible-project-lighthouse 只需几行就能使用这个 role:

		
  1. - role: umami_nginx
  2. tags: [umami_nginx]
  3. - role: nicholasclooney.umami
  4. tags: [umami]
  5. vars:
  6. umami_timezone: Europe/London
  7. umami_bind_address: 127.0.0.1
  8. umami_listen_port: 3000
  9. - role: tailscale_serve
  10. tags: [tailscale_serve]

组变量把仪表盘限制在回环网络上,等待前面的反向代理接入:

		
  1. # === Nginx/analytics access control ===
  2. #
  3. # CIDR/IPs allowed to reach the Umami dashboard via umami_nginx
  4. nginx_dashboard_allowlist:
  5. - "127.0.0.1/32"
  6. # === Domains ===
  7. #
  8. # Used by certbot role when requesting site certificates
  9. primary_domain: "example.com"
  10. # Shared by certbot + umami_nginx site template for analytics host
  11. analytics_domain: "analytics.example.com"
  12. # === Certbot ===
  13. #
  14. # Toggle ACME issuance in certbot role
  15. certbot_issue_certificates: false
  16. # Ensures packaged systemd timer stays enabled
  17. certbot_auto_renew: true
  18. # Certbot registration email for expiry notices and ToS
  19. certbot_admin_email: "[email protected]"
  20. # Domains to request via Certbot (include each site explicitly and point DNS to this host)
  21. certbot_domains:
  22. - "example.com"

因为我只信任 tailnet 内的设备访问敏感仪表盘,所以运行了一个很小的 systemd 单元,通过 Tailscale Serve 发布 Umami:

		
  1. ---
  2. - name: Deploy tailscale serve systemd unit
  3. become: true
  4. ansible.builtin.template:
  5. src: tailscale-serve.service.j2
  6. dest: "/etc/systemd/system/{{ tailscale_serve_service_name }}.service"
  7. owner: root
  8. group: root
  9. mode: '0644'
  10. notify:
  11. - Restart tailscale serve
  12. - name: Ensure tailscale serve service is enabled and {{ tailscale_serve_state }}
  13. become: true
  14. ansible.builtin.systemd:
  15. name: "{{ tailscale_serve_service_name }}"
  16. enabled: "{{ tailscale_serve_enabled }}"
  17. state: "{{ tailscale_serve_state }}"
  18. daemon_reload: true

最终得到一个私有的 https://umami.tailXX.ts.net 端点,只有已登录的 tailnet 设备才能访问。没有公开入口,也不必猜测。


用 Nginx 发布追踪脚本

公网仍需要访问 /script.js 和 /api/send,因此我配置了一个 Nginx 站点,只暴露这两个端点,同时让完整仪表盘受允许列表保护:

		
  1. # 1) HTTP → HTTPS redirect
  2. server {
  3. listen 80;
  4. listen [::]:80;
  5. server_name {{ analytics_domain }};
  6. return 301 https://$host$request_uri;
  7. }
  8. # 2) HTTPS site
  9. server {
  10. listen 443 ssl http2;
  11. listen [::]:443 ssl http2;
  12. server_name {{ analytics_domain }};
  13. # Logs
  14. access_log /var/log/nginx/{{ umami_nginx_site_name }}.access.log;
  15. error_log /var/log/nginx/{{ umami_nginx_site_name }}.error.log;
  16. # TLS certs
  17. ssl_certificate /etc/letsencrypt/live/{{ analytics_domain }}/fullchain.pem;
  18. ssl_certificate_key /etc/letsencrypt/live/{{ analytics_domain }}/privkey.pem;
  19. location = /script.js {
  20. proxy_pass http://{{ umami_nginx_upstream_host }}:{{ umami_nginx_upstream_port }};
  21. proxy_set_header Host $host;
  22. proxy_set_header X-Real-IP $remote_addr;
  23. proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  24. proxy_set_header X-Forwarded-Proto $scheme;
  25. }
  26. location = /api/send {
  27. proxy_pass http://{{ umami_nginx_upstream_host }}:{{ umami_nginx_upstream_port }};
  28. proxy_set_header Host $host;
  29. proxy_set_header X-Real-IP $remote_addr;
  30. proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  31. proxy_set_header X-Forwarded-Proto $scheme;
  32. limit_except POST OPTIONS { deny all; }
  33. proxy_hide_header Access-Control-Allow-Origin;
  34. proxy_hide_header Access-Control-Allow-Methods;
  35. proxy_hide_header Access-Control-Allow-Headers;
  36. proxy_hide_header Access-Control-Max-Age;
  37. add_header Access-Control-Allow-Origin "$http_origin" always;
  38. add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
  39. add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
  40. add_header Access-Control-Max-Age 86400 always;
  41. add_header Vary "Origin" always;
  42. if ($request_method = OPTIONS) {
  43. return 204;
  44. }
  45. }
  46. location / {
  47. {% for cidr in nginx_dashboard_allowlist %}
  48. allow {{ cidr }};
  49. {% endfor %}
  50. deny all;
  51. proxy_pass http://{{ umami_nginx_upstream_host }}:{{ umami_nginx_upstream_port }};
  52. proxy_set_header Host $host;
  53. proxy_set_header X-Real-IP $remote_addr;
  54. proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  55. proxy_set_header X-Forwarded-Proto $scheme;
  56. }
  57. }

  • /script.js 和 /api/send 直接代理到 Umami,并附上所需的 CORS 响应头。
  • 其他路径先检查允许列表;生产环境中,我把它设为 tailnet 地址范围,这样只有我能看到界面。

有了这些配置,公开网站可以嵌入 Umami 的脚本标签,而管理界面对其他人仍然没有可达路由。


整套系统如何运行

组合起来,流程如下:

  1. Ansible 渲染 .env + docker-compose.yml,生成密钥,运行 docker compose up -d。
  2. Docker Compose 启动 Postgres + Umami,检查各项健康状态,并把界面绑定到回环地址。
  3. Tailscale Serve 将仪表盘发布到我的 tailnet,让我随处都能查看分析,包括手机上。
  4. Nginx 只把数据上报端点代理到公网,其余部分继续锁住。

这个 role 也能在本地运行,所以我可以克隆仓库、启动 Colima,先在 Mac 上测试完全相同的服务组合,再向上游推送改动。有更新时,ansible-playbook main.yml --tags umami 会拉取新镜像,干净地重启服务。


最后的想法

纸面上看起来复杂,但这套配置最终浓缩成一次可重复的 Ansible 运行:

  • Compose 保持宿主机整洁,让部署可预测。
  • Tailscale 和 Nginx 添加恰到好处的路由,维持默认私有。
  • 密钥始终由我掌控,回滚只需一条 docker compose down。

如果你已经在用 Ansible 自动化服务器,可以拿去用这个 role,调整默认值,先在 Colima 沙箱中试一遍。准备上线时,把 playbook 指向服务器,就能私密地使用 Umami 仪表盘。之后也可以看看关于 Colima、Tailscale 和调试 Umami的相关文章,了解其他部分如何拼在一起。