Tự host OpenAnalytics trên VPS: cần bao nhiêu tài nguyên?
Bạn cần 4 GB RAM, 25 GB trống và 4 bản ghi DNS để chạy OpenAnalytics. Xem cách cài ClickHouse, Postgres, Valkey và nguyên nhân làm đầy ổ đĩa.
Bản footprint trước bước đầu tiên
Để self-host OpenAnalytics, bạn cần một Linux VPS có khoảng 4 GB RAM, 25 GB dung lượng đĩa trống, Docker với Compose plugin và 4 bản ghi DNS đã trỏ đến máy chủ. Đây là yêu cầu thực tế cần nêu trước command đầu tiên, không phải bổ sung sau đó.
Stack gồm 6 application service và 3 data store. Postgres lưu control plane: tài khoản, site, API key và share link. ClickHouse lưu event thô và các bản tổng hợp mà dashboard đọc. Valkey chạy 2 lần: một instance làm hàng đợi event bền vững, instance còn lại làm cache có thể mất, vì hai tác vụ này cần chính sách eviction trái ngược nhau. Chỉ query gateway được phép đọc ClickHouse. Gateway xác minh chữ ký Ed25519 trong từng query envelope trước khi thực thi.
Nếu bạn cần một binary và một file cấu hình duy nhất thì đây không phải lựa chọn đó. GoatCounter là lựa chọn single-binary trong nhóm này: một Go executable, mặc định dùng SQLite và hoàn toàn không cần database bên ngoài. Stack nặng hơn cung cấp funnel, web vitals, revenue attribution từ chính tài khoản Stripe của bạn và một MCP (model context protocol) server. Lựa chọn giữa các công cụ analytics self-hosted là bài viết phân tích đánh đổi này. Hướng dẫn này giả định bạn đã quyết định xong.
Trỏ 4 bản ghi DNS đến máy chủ trước
4 subdomain phải phân giải đến IP public của máy chủ trước khi bạn bắt đầu, vì Caddy yêu cầu chứng chỉ Let's Encrypt ngay lần khởi chạy đầu tiên và challenge sẽ thất bại nếu hostname chưa phân giải được.
app.example.comphục vụ dashboard.api.example.comphục vụ API và các OAuth callback.c.example.comphục vụ collector và tracker script.rt.example.comphục vụ realtime stream.
Dùng 4 bản ghi A, hoặc 1 bản ghi A và 3 bản ghi CNAME trỏ đến bản ghi đó. Xác nhận bằng dig +short app.example.com trước khi tiếp tục. Tên bạn vừa thêm một phút trước vẫn có thể bị resolver mà Let's Encrypt sử dụng cache dưới dạng NXDOMAIN. Vì vậy, nếu lần yêu cầu chứng chỉ đầu tiên thất bại, hãy chờ và đọc log của Caddy. Chạy lại quá trình cài đặt không làm DNS propagate nhanh hơn.
Cách tự host OpenAnalytics bằng Docker Compose
Checkout một release tag. Nhánh mặc định là nơi diễn ra quá trình phát triển, còn release tag khớp với các image đã được publish. Các lệnh dưới đây giả định Docker và Compose plugin đã được cài đặt. Hướng dẫn chạy các service Docker Compose trên VPS trình bày việc này.
git clone https://github.com/OpenLabs-so/openanalytics
cd openanalytics
git checkout "$(git tag -l 'v*' --sort=-v:refname | sed '/-/d' | head -1)"
cd infra/selfhost
./generate-secrets.sh --domain example.com --email you@example.com --with-geoip
docker compose pull && docker compose up -dsed '/-/d' trong lệnh checkout loại bỏ các tag pre-release, để bạn lấy phiên bản stable mới nhất thay vì release candidate. --with-geoip tải cơ sở dữ liệu thành phố DB-IP trong quá trình tạo dữ liệu. Nếu bỏ qua bước này, mọi event sẽ có country là null, nên chế độ xem theo địa lý không hiển thị gì. Bạn có thể thêm dữ liệu này sau bằng cách chạy infra/selfhost/geoip/fetch-dbip.sh, đặt GEOIP_DB_PATH=/geoip/dbip-city-lite.mmdb trong env/collector.env, rồi tạo lại collector bằng docker compose up -d --force-recreate collector. Cơ sở dữ liệu này được làm mới hằng tháng. Hãy tải lại hằng tháng để dữ liệu thành phố không bị lệch.
Sao lưu các secret đã tạo trước khi tiếp tục
Generator ghi ra ba loại tệp. .env chứa tên miền và các image reference. env/*.env chứa một tệp secret cho mỗi service. docker-compose.override.yml chứa ba cặp key Ed25519 dưới dạng YAML block scalar, vì PEM nhiều dòng không thể nằm trong env file. Tất cả các tệp này đều nằm trong git-ignored, và không thể tạo lại với cùng các giá trị.
Hãy sao chép các tệp đó ra khỏi máy ngay bây giờ. Mất từng loại tệp sẽ gây ra hậu quả cụ thể:
- Mất password của store thì bạn bị khóa khỏi Postgres và ClickHouse. Chỉ có thể reset password từ bên trong các container.
- Mất
OA_CREDENTIAL_KEYRINGthì mọi credential của bên thứ ba đã lưu đều không thể khôi phục. Bất kỳ ai đã kết nối tài khoản Stripe đều phải kết nối lại. - Mất
ANONYMOUS_IDENTITY_SECRETthì danh tính visitor được thiết lập lại: toàn bộ visitor của ngày hôm qua được tính là visitor mới, và khoảng gián đoạn này sẽ hiển thị trên các biểu đồ. - Mất
AUTH_SECRETthì mọi session bị vô hiệu hóa, nên tất cả người dùng phải đăng nhập lại. - Mất private key dùng để signing thì bạn chỉ cần rotate cặp key. Không có dữ liệu nào bị mất.
Hai secret phải giống hệt từng byte trong mỗi hai tệp. ANONYMOUS_IDENTITY_SECRET xuất hiện trong collector.env và worker.env, vì collector tính visitor hash còn worker ghi hash đó. OA_CREDENTIAL_KEYRING xuất hiện trong api.env và worker.env. Mọi secret khác được cố ý giới hạn cho đúng một service, và service nào nhận một secret mà nó không được phép giữ sẽ thoát thay vì khởi động.
Khởi động stack và kiểm tra
grep OA_IMAGE .env
docker compose pull
docker compose up -d
docker compose logs -f migrate
docker compose psmigrate áp dụng schema cho Postgres và ClickHouse rồi thoát, nên container migrate ở trạng thái stopped là trạng thái cuối đúng. tracker-build biên dịch oa.js vào một volume được Caddy phục vụ rồi cũng thoát. Các thành phần còn lại phải đọc healthy trong docker compose ps. Một service restart liên tục gần như luôn bị lỗi khi kiểm tra biến môi trường. Log in tất cả lỗi trong cùng một danh sách thay vì chỉ in một lỗi cho mỗi lần restart. Hai nguyên nhân thường gặp là một biến bị để trống; trường hợp này bị từ chối thay vì được coi là chưa đặt, và một secret được đặt trong sai file của service.
Trên arm64 hoặc khi dùng một branch, không có image được publish nên bạn phải build cục bộ bằng docker compose up -d --build. Host có 4 GB sẽ hết memory giữa chừng trong quá trình build đó. Trước tiên hãy thêm swap. Swap chỉ cần trong lúc build:
fallocate -l 4G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstabBuild mất khoảng mười phút. Pull chỉ mất vài phút, đó là lý do các release image tồn tại.
Xác nhận tài khoản đầu tiên ngay lập tức
Mở https://app.example.com. Một deployment chưa có ai đăng nhập sẽ không hiển thị biểu mẫu đăng nhập mà cho phép tạo tài khoản đầu tiên. Tài khoản này vĩnh viễn là tài khoản có đặc quyền và là tài khoản duy nhất có thể xem màn hình cài đặt deployment. Sau khi tài khoản này được tạo, route sẽ trả về 409, vì vậy không ai có thể truy cập sau bạn. Hãy làm việc này ngay khi stack hoạt động ổn định, không để sang tuần sau.
Cài tracker
Thêm một site trong dashboard, rồi dashboard sẽ cung cấp tag cho bạn. Cấu trúc này là cố định:
<script
async
src="https://c.example.com/oa.js"
data-key="YOUR_TRACKING_KEY"
data-collector="https://c.example.com"
></script>Đặt tag này trong phần head của trang. Tracking key được thiết kế để public, nên nó nằm trong HTML và bất kỳ ai cũng có thể đọc được. Script cài đặt window.oa. Các lệnh gọi như oa("track", ...) được một stub xếp hàng và xử lý sau khi file tải xong, vì vậy custom event phát ra sớm sẽ không bị bỏ mất. Nếu một thành phần khác trên trang đã sở hữu window.oa, tracker sẽ cài đặt dưới tên window.openanalytics.
Sau đó kiểm tra toàn bộ luồng từ đầu đến cuối:
curl -s https://c.example.com/oa.js -o /dev/null -w '%{http_code} %{size_download}\n'
curl -s https://api.example.com/health | head -c 200
docker compose logs --tail=50 worker | grep -i batchLệnh đầu tiên phải in ra 200 và một vài kilobyte. Mở một trang trên site của bạn, rồi tìm một dòng batch trong log của worker trong vòng vài giây. Collector trả về 202 ngay khi chấp nhận một event. 202 có nghĩa là đã được xếp hàng, không phải đã lưu. Worker là thành phần chuyển event vào ClickHouse. Nếu event được chấp nhận nhưng dashboard không hiển thị gì, worker đang bị chặn. Valkey queue depth liên tục tăng sẽ xác nhận điều này. Nguyên nhân thường gặp là thông tin xác thực ClickHouse trong worker.env không đúng hoặc thiếu grant trên một table mà migration vừa thêm.
Giữ collector public và đặt dashboard sau lớp xác thực
Caddy được tích hợp trong file compose và tự lấy chứng chỉ cho cả bốn hostname, nên đường triển khai mặc định không cần bạn cấu hình proxy. Nếu máy chủ đã chạy reverse proxy nginx, hãy đặt stack phía sau infra/selfhost/nginx.conf.example được cung cấp và giữ nguyên cách xử lý header của nó:
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header CF-Connecting-IP "";
proxy_set_header True-Client-IP "";
proxy_set_header Fly-Client-IP "";Collector tạo hash visitor hằng ngày từ IP client, vì vậy phải lấy địa chỉ đó từ connection và không bao giờ lấy từ header. Chuyển tiếp CF-Connecting-IP từ một hop không đáng tin cậy cho phép bất kỳ caller nào giả mạo địa chỉ bất kỳ. Điều này đồng thời làm sai geolocation và thổi phồng số lượng visitor.
Quyền truy cập được phân tách rõ ràng theo hostname. c. và rt. phải cho mọi visitor của mọi site bạn đo lường truy cập, vì vậy không được đặt basic auth hoặc IP allowlist phía trước hai hostname này. app. và api. chỉ cần cho những người đã sign in truy cập. Auth của ứng dụng là lớp bảo vệ dashboard: đăng nhập bằng password được bật mặc định thông qua AUTH_PASSWORD_SIGNIN=enabled trong env/api.env, còn các nút Google hoặc GitHub chỉ xuất hiện khi provider tương ứng có cả client ID và client secret. Magic link cần mail transport. Nếu không có mail transport, API chỉ ghi yêu cầu gửi vào outbox, nên không có email nào được gửi và cũng không phát sinh lỗi.
Một setting quyết định dashboard có hoạt động hay không. AUTH_TRUSTED_ORIGINS trong env/api.env phải khớp chính xác với origin của dashboard. Nếu giá trị sai hoặc bị thiếu, API không gửi các header CORS (cross-origin resource sharing), trình duyệt từ chối mọi call, và dashboard chỉ render layout nhưng không hiển thị dữ liệu, trong khi docker compose ps vẫn báo mọi thứ healthy.
Khi đang chỉnh proxy config, hãy xử lý traffic tự động. Crawler truy cập collector như mọi client khác, nên page view của chúng được ghi vào ClickHouse và đi vào các số liệu của bạn. Chặn AI crawler tại server giúp loại một phần traffic đó trước khi nó làm giảm độ chính xác và chiếm disk.
Ý nghĩa của cookieless trong trường hợp này và cái giá phải trả
Không có cookie. Danh tính khách truy cập là một salted hash, salt được thay đổi mỗi ngày và địa chỉ IP thô không bao giờ được lưu. Geolocation được tra cứu cục bộ từ file DB-IP trên chính ổ đĩa của bạn, nên không có thông tin tra cứu nào về khách truy cập rời khỏi host.
Điều bạn có được là không có identifier nào được lưu trên thiết bị của khách truy cập. Đây chính là yếu tố khiến một tracker thuộc phạm vi áp dụng của quy định đồng ý ePrivacy tại EU. Vì lý do đó, các hệ thống chỉ thu thập dữ liệu tổng hợp như hệ thống này thường được chạy mà không có consent banner. GDPR vẫn áp dụng cho mọi dữ liệu bạn lưu và thời gian lưu dữ liệu. Trường hợp cụ thể của bạn do luật sư của bạn quyết định, không phải README.
Cái giá phải trả là không có identity xuyên ngày. Việc thay đổi salt khiến một người truy cập vào thứ Hai rồi quay lại vào thứ Tư được tính là hai khách truy cập, theo thiết kế và không có cách khắc phục. Số khách truy cập duy nhất trong ngày là đáng tin cậy. Số khách truy cập duy nhất theo tuần và tháng được tính từ dữ liệu hằng ngày nên sẽ phóng đại reach. Vì vậy, mọi số liệu "khách truy cập quay lại" trong khoảng thời gian dài đều không đo đúng nội dung mà nhãn của nó mô tả. Sessions và journeys đáng tin cậy trong phạm vi một ngày. Việc xoay vòng ANONYMOUS_IDENTITY_SECRET có tác động giống như một ranh giới ngày, nên hãy xem lần xoay vòng đó là một thay đổi dữ liệu thay vì việc vệ sinh định kỳ.
Collector tuân thủ Do Not Track và Global Privacy Control, là tín hiệu của browser cho website biết không được bán hoặc chia sẻ dữ liệu cá nhân. Thẻ script có các switch riêng cho cùng mục đích: data-respect-gpc, data-respect-dnt và data-require-consent. Tùy chọn này giữ toàn bộ hoạt động thu thập cho đến khi có consent và ghi nhớ câu trả lời trong localStorage dưới key oa.consent. Bật data-storage="none" sẽ tắt hoàn toàn browser storage.
Vì sao ổ đĩa đầy sau sáu tháng
Đây là nguyên nhân làm máy chủ analytics tự host bị dừng hoạt động, và các event thường không phải thủ phạm.
Hãy bắt đầu với các image. Mỗi bản release tạo ra 10 image, chiếm tổng cộng khoảng 13 GB trên ổ đĩa. Khi upgrade, hệ thống pull thế hệ image mới trước khi xóa thế hệ cũ, nên trong một khoảng thời gian máy chủ phải giữ cả 2 thế hệ. Đây là phần lớn dung lượng 25 GB cần có, ngay cả khi chưa có một lượt xem trang nào.
Tiếp theo là các snapshot. snapshot.sh dừng stack, archive cả 2 data volume cùng với mọi secret, rồi khởi động lại. Trong trường hợp này, chỉ bản sao cold mới an toàn, vì ClickHouse merge các part ở background và bản sao được tạo trong lúc merge sẽ không nhất quán. upgrade.sh tự động tạo một snapshot trước mỗi lần upgrade, nên các archive sẽ tích tụ trên cùng ổ đĩa cho đến khi bạn giới hạn số lượng.
./snapshot.sh create --label before-something-risky
./snapshot.sh list
./snapshot.sh --keep 3Trên host gần đầy ổ đĩa, hãy reclaim thế hệ image cũ trước khi upgrade. Việc này an toàn khi stack đang chạy, vì các image được container đang chạy sử dụng vẫn còn được tham chiếu:
docker image prune -a -fTiếp theo là chính các event. ClickHouse nén dữ liệu dạng cột rất tốt, nên dung lượng dữ liệu event thô tăng chậm hơn nhiều so với dự kiến của đa số người dùng, còn các bảng rollup mà dashboard đọc nhỏ hơn nhiều so với bảng raw. Hãy đo thay vì phỏng đoán:
docker system df -v
docker compose exec clickhouse df -h /var/lib/clickhouseĐể xem dung lượng theo từng bảng, hãy chạy lệnh này với thông tin xác thực ClickHouse mà generator đã ghi trong infra/selfhost/env/:
SELECT table, formatReadableSize(sum(bytes_on_disk)) AS size, sum(rows) AS row_count
FROM system.parts
WHERE active
GROUP BY table
ORDER BY sum(bytes_on_disk) DESC;Hãy ghi lại số liệu trong tuần đầu tiên và lần nữa vào tuần thứ tư. 2 điểm đo cho biết tốc độ tăng trưởng, và tốc độ tăng trưởng cho biết khi nào volume cần được resize. Tính đến August 2026, tài liệu self-hosting chưa ghi nhận tùy chọn retention hoặc time-to-live cho event thô, nên hãy tính dung lượng ổ đĩa dựa trên tốc độ bạn đo được, thay vì giả định các row cũ sẽ tự hết hạn.
Có một bẫy khi xóa mà bạn nên biết trước khi gặp phải. Khi xóa một site hoặc account, hệ thống sẽ đưa công việc vào queue cho worker xử lý. Worker đó cần CLICKHOUSE_MAINTENANCE_USER và CLICKHOUSE_MAINTENANCE_PASSWORD được thiết lập, đồng thời phải có user oa_maintenance tương ứng trong ClickHouse. Nếu thiếu các giá trị này, công việc xóa sẽ nằm trong queue vô thời hạn. Site biến mất khỏi dashboard nhưng mọi row vẫn nằm trên ổ đĩa, nên bạn chỉ thấy có vẻ như dữ liệu đã được dọn dẹp mà không lấy lại được dung lượng nào.
Nâng cấp và ba loại chi phí
git fetch --tags
git checkout "$(git tag -l 'v*' --sort=-v:refname | sed '/-/d' | head -1)"
cd infra/selfhost
./upgrade.shupgrade.sh in ra ba loại chi phí trước khi thực hiện. Downtime là chi phí thực tế: các event được gửi khi collector đang dừng sẽ bị mất vì tracker không thử gửi lại. Rollback làm mất dữ liệu vì rollback.sh --to backups/<snapshot> thay thế toàn bộ cả hai store và loại bỏ mọi row được ghi sau thời điểm snapshot đó được tạo. Disk là chi phí thứ ba, chính là chồng snapshot được mô tả ở trên.
Có hai quy tắc restart dễ làm sai. Hãy khởi động query gateway trước API, vì API mới hơn sẽ gửi các query field mà gateway cũ từ chối. ClickHouse cũng cần recreate thay vì restart, vì docker compose restart dùng lại environment ban đầu của container và âm thầm bỏ qua thay đổi bạn đã sửa:
docker compose up -d --force-recreate clickhouseDashboard cũng có cùng dạng bẫy này. Ba origin NEXT_PUBLIC_* trong env/web.env được compile vào browser bundle và được thay thế khi container khởi động, nên dashboard gọi sai hostname phải được sửa bằng docker compose up -d --force-recreate web và không bao giờ sửa bằng restart. Log của web container in ra các origin mà nó khởi động cùng, đây là cách nhanh nhất để xác nhận thay đổi đã được áp dụng.
Nếu ClickHouse không khởi động sau khi sửa config, hãy đọc dòng đầu tiên trong log. Dòng bắt đầu bằng oa-entrypoint: cho biết entrypoint đã từ chối một giá trị bạn đặt. Các trường hợp khác thường có nghĩa là file config chứa XML không hợp lệ. Nguyên nhân phổ biến nhất là có double hyphen bên trong XML comment, điều này không hợp lệ trong XML.
AGPL-3.0 và tên gọi
Mã nguồn được cấp phép theo AGPL-3.0. Việc chạy nguyên trạng mã này cho các website của bạn hoàn toàn không tạo ra nghĩa vụ công bố nào. Nghĩa vụ bắt đầu khi bạn sửa đổi mã và chạy phiên bản đã sửa đổi đó như một network service: khi đó license yêu cầu bạn cung cấp source đã sửa đổi cho người dùng của service. Điều này bao gồm việc cung cấp dashboard cho client trên instance của bạn, cũng như đóng gói mã vào sản phẩm bạn bán. Lưu các thay đổi trong một fork công khai là đủ để đáp ứng yêu cầu này mà không cần quy trình bổ sung.
Brand tách biệt với mã nguồn. Tên "OpenAnalytics" và domain mà project vận hành xác định instance do các tác giả quản lý, và không thuộc phạm vi cấp phép. Deployment của bạn chạy software nhưng không sử dụng brand đó, vì vậy hãy đặt tên riêng cho service trước khi cung cấp service cho khách hàng trả phí.
FAQ
Tôi có thể chạy OpenAnalytics trên VPS 1 GB không?
Không. Dự án yêu cầu khoảng 4 GB RAM và 25 GB dung lượng đĩa trống, vì một deployment chạy sáu application service cùng với Postgres, ClickHouse và hai instance Valkey. Riêng ClickHouse đã không phải là một process nhỏ. Trên máy 1 GB, các container khởi động rồi kernel out-of-memory killer sẽ dừng một container, thường là ClickHouse. Nếu gói 1 GB là giới hạn bắt buộc, hãy dùng công cụ single-binary như GoatCounter. Công cụ này chạy trên SQLite và không cần database bên ngoài.
Tôi có cần cookie banner với OpenAnalytics không?
Đó là vấn đề bạn nên hỏi luật sư, còn các dữ kiện kỹ thuật đều có lợi cho bạn. Không có cookie, danh tính visitor là một salted hash được xoay vòng mỗi ngày, và raw IP address không bao giờ được lưu. Vì vậy, hệ thống không ghi dữ liệu bền vững để xác định visitor. GDPR vẫn quy định dữ liệu bạn lưu và thời gian lưu giữ. Nếu muốn yêu cầu consent rõ ràng trước khi thu thập, đặt data-require-consent trên thẻ script: tracker sẽ không thu thập gì cho đến khi có consent và lưu lựa chọn trong localStorage dưới oa.consent.
Tại sao event trả về 202 nhưng không bao giờ xuất hiện trên dashboard?
202 có nghĩa là collector đã nhận và xếp event vào queue, không phải đã lưu event. Worker đưa event từ queue vào ClickHouse, vì vậy dashboard trống trong khi request thành công cho thấy vấn đề nằm ở worker. Đọc docker compose logs --tail=50 worker và theo dõi độ dài queue của Valkey. Queue liên tục tăng nghĩa là worker đang bị chặn. Các nguyên nhân thường gặp là thông tin xác thực ClickHouse trong worker.env bị sai hoặc thiếu grant trên một table được tạo bởi migration gần đây.
Tại sao dashboard trống khi mọi container đều healthy?
Trước tiên, hãy kiểm tra AUTH_TRUSTED_ORIGINS trong env/api.env. Giá trị này phải khớp chính xác với origin của dashboard. Nếu không khớp, API sẽ không gửi CORS header, nên browser từ chối mọi call và bạn chỉ thấy layout hoạt động nhưng không có dữ liệu. Tiếp theo, hãy kiểm tra ba giá trị NEXT_PUBLIC_* trong env/web.env. Các giá trị này được thay thế khi web container khởi động. Sửa chúng cần docker compose up -d --force-recreate web, vì restart thông thường vẫn giữ các giá trị cũ.
AGPL-3.0 có ngăn tôi cung cấp dịch vụ này cho khách hàng không?
Không, giấy phép chỉ đặt ra một điều kiện. Nếu chạy code không sửa đổi, bạn không phải cung cấp gì cho bất kỳ ai. Nếu sửa code rồi chạy phiên bản đã sửa đó dưới dạng service cho người khác sử dụng, bạn phải cung cấp source đã sửa cho những người dùng đó. Một public fork đáp ứng yêu cầu này. Ngoài ra, tên "OpenAnalytics" không được cấp phép cùng với code, vì vậy mọi sản phẩm bạn bán phải dùng tên riêng.