SSD Nodes Learn 🎉 VPS từ $5.50/tháng
Hướng dẫn Matt ConnorBởi Matt Connor · Cập nhật ngày 2026-08-15

Tự host LinkBreeze thay Linktree bằng Docker

Chạy LinkBreeze trên VPS với Docker Compose và Caddy, dùng image tag cố định, tracking không cookie và một volume chứa toàn bộ site trong SQLite.

LinkBreeze là gì

LinkBreeze là một lựa chọn tự host thay cho Linktree: một Docker container cung cấp trang link-in-bio công khai và dashboard quản trị, với toàn bộ state được lưu trong một file SQLite duy nhất. Phần mềm được cấp phép theo MIT, viết bằng TypeScript trên Next.js và phát hành dưới dạng ghcr.io/manak-hash/linkbreeze. Để chạy phần mềm, bạn cần một VPS, một domain có bản ghi A trỏ đến VPS đó, mở các cổng 80 và 443, cùng Docker Engine có plugin Compose.

Hướng dẫn này trình bày cách triển khai mà repository thực sự hỗ trợ: Docker Compose chạy phía sau reverse proxy, với reverse proxy tự lấy certificate. Hướng dẫn cũng nêu các lỗi có thể xảy ra, vì link trong bio là một URL công khai mà người khác sẽ nhấp vào, và link bị hỏng sẽ khiến bạn mất lượt nhấp.

Trước hết, bạn cần hiểu rõ dự án này còn mới đến mức nào.

LinkBreeze đã đủ trưởng thành để dùng làm liên kết hồ sơ công khai chưa?

Tính đến tháng 8 năm 2026, repository có 178 stars, 17 forks và chỉ có một maintainer. Bản release đầu tiên được tag, v1.0.0, có ngày phát hành là 1 tháng 7 năm 2026. Đây là một project mới vài tuần, không phải vài năm.

ChartLinkBreeze tagged releases per week, v1.0.0 to v1.2.7
The data behind this chart
[
  {
    "week": "2026-06-29",
    "releases": 3,
    "cumulative": 3
  },
  {
    "week": "2026-07-06",
    "releases": 3,
    "cumulative": 6
  },
  {
    "week": "2026-07-13",
    "releases": 1,
    "cumulative": 7
  },
  {
    "week": "2026-07-20",
    "releases": 2,
    "cumulative": 9
  },
  {
    "week": "2026-07-27",
    "releases": 3,
    "cumulative": 12
  },
  {
    "week": "2026-08-03",
    "releases": 2,
    "cumulative": 14
  },
  {
    "week": "2026-08-10",
    "releases": 3,
    "cumulative": 17
  }
]

Kể từ v1.0.0, project đã phát hành 17 bản release được tag trong 7 tuần theo lịch. Tuần cuối trong biểu đồ vẫn đang diễn ra khi viết hướng dẫn này và đã có 3 bản trong số đó.

Hãy xem đây là hai sự thật riêng biệt. Maintainer vẫn đang tích cực và các bug được sửa trong vài ngày. Schema và các giá trị mặc định cũng vẫn đang thay đổi, vì vậy một instance được deploy rồi bỏ đó sẽ ngày càng khác xa code đang được phát triển.

License bảo vệ bạn trước tình huống xấu nhất. MIT, kết hợp với một container image và một file SQLite trên disk của chính bạn, có nghĩa là nếu quá trình phát triển dừng lại thì hệ thống bạn đang có vẫn tiếp tục chạy. Nhưng license không bảo vệ bạn trước một web app public-facing ngừng nhận các bản sửa bảo mật và dần trở thành trách nhiệm rủi ro. Hãy deploy hệ thống này với kế hoạch tiếp tục cập nhật, đồng thời duy trì routine backup bên dưới ngay từ ngày đầu.

Ghim image tag và không chạy latest

Quy trình release đẩy chính xác 2 tag cho mỗi phiên bản: latest và số phiên bản sau khi bỏ v ở đầu. Vì vậy, tag cố định cho release v1.2.7 là ghcr.io/manak-hash/linkbreeze:1.2.7. Viết :v1.2.7 sẽ không pull được gì và Docker báo manifest unknown vì tag đó chưa từng được đẩy.

Hãy cố định tag vì latest thay đổi. Với tần suất trong biểu đồ ở trên, một docker compose pull đối với latest là một lần nâng cấp chưa được review cho trang mà người dùng của bạn đang truy cập. Khi dùng tag cố định, việc nâng cấp chỉ xảy ra khi bạn sửa file.

Còn một điểm nữa về image. Quy trình release build mà không đặt platforms:, vì vậy image được publish chỉ là linux/amd64. Trên host arm64, việc pull sẽ thất bại với no matching manifest for linux/arm64/v8 in the manifest list entries. Nếu bạn chạy một ARM VPS thay vì x86, hãy build image ngay trên máy đó:

git clone --branch v1.2.7 --depth 1 https://github.com/Manak-hash/LinkBreeze.git
cd LinkBreeze
docker build -t linkbreeze:1.2.7 .

Sau đó dùng linkbreeze:1.2.7 làm tên image trong compose file bên dưới.

Triển khai LinkBreeze phía sau Caddy với TLS tự động

Caddy tự yêu cầu và gia hạn chứng chỉ từ Let's Encrypt, nên không cần bước cấu hình chứng chỉ riêng cho TLS (bảo mật tầng truyền tải). Toàn bộ triển khai chỉ gồm 3 file trong một thư mục.

Trước tiên, tạo secret:

mkdir -p ~/linkbreeze && cd ~/linkbreeze
printf 'SECRET_KEY=%s\n' "$(openssl rand -hex 32)" > .env
chmod 600 .env

SECRET_KEY dùng để ký cookie phiên quản trị và thêm salt vào hash visitor của analytics. File compose được phát hành trong repository đặt giá trị mặc định là ${SECRET_KEY:-changeme-in-production}. Vì vậy, nếu bỏ qua bước này, instance sẽ chạy với session signing key được công khai trên GitHub. Hãy đặt giá trị này trước lần khởi động đầu tiên, vì thay đổi nó sau đó sẽ đăng xuất bạn và reset analytics salt.

Tạo docker-compose.yml:

services:
  linkbreeze:
    image: ghcr.io/manak-hash/linkbreeze:1.2.7
    restart: unless-stopped
    volumes:
      - linkbreeze-data:/app/data
    environment:
      - DATABASE_PATH=/app/data/linkbreeze.db
      - SECRET_KEY=${SECRET_KEY}
      - BASE_URL=https://links.example.com
    networks:
      - linkbreeze-net

  caddy:
    image: caddy:2-alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy-data:/data
      - caddy-config:/config
    networks:
      - linkbreeze-net

networks:
  linkbreeze-net:

volumes:
  linkbreeze-data:
  caddy-data:
  caddy-config:

BASE_URL là tùy chọn nhưng nên đặt. Nó cho app biết địa chỉ public thực của app, để request chứa header Host giả mạo không thể khiến app tạo link đến domain của người khác.

Tạo Caddyfile cạnh đó và đặt domain của bạn:

links.example.com {
    encode zstd gzip
    reverse_proxy linkbreeze:3000
}

Mặc định, Caddy đặt X-Forwarded-ForX-Forwarded-Proto trên các request được proxy. Analytics phụ thuộc vào các header này. Khởi động stack:

docker compose up -d
docker compose ps
docker compose logs -f caddy

docker compose ps phải hiển thị container LinkBreeze ở trạng thái healthy. Image đã tích hợp healthcheck riêng là wget --spider -q http://127.0.0.1:3000/api/health, nên bạn không cần thêm healthcheck. Không sao chép healthcheck từ ví dụ Caddy trong repository: healthcheck đó gọi curl, trong khi image được build trên node:22-alpine, có busybox wget và không có curl. Container đó báo trạng thái unhealthy dù vẫn phục vụ trang bình thường.

Mở https://links.example.com trong trình duyệt. Lần truy cập đầu tiên sẽ mở setup wizard tại /setup để tạo tài khoản admin duy nhất. Sau đó, dashboard ở /dashboard và form đăng nhập ở /login. Tài khoản này chỉ thuộc instance hiện tại và app không có hook cho single sign-on. Vì vậy, nếu muốn dashboard dùng cùng thông tin đăng nhập với các dịch vụ khác mà bạn host, bạn phải đặt một forward auth proxy phía trước, chẳng hạn như AuthentiK tự host.

Lưu ý những gì file compose không làm: file không bao giờ publish port 3000. Chỉ Caddy lắng nghe trên public interface. Nếu cú pháp file Compose còn mới với bạn, bài viết cơ bản về Docker Compose trên VPS giải thích các phần mà file này giả định bạn đã biết. Nếu bạn đã có một thành phần khác chạy phía trước, so sánh Nginx, Caddy và Traefik giải thích những điểm cần thay đổi. Repository có sẵn các ví dụ hoạt động cho Nginx với Certbot, Traefik và Cloudflare tunnel.

Vị trí dữ liệu và những gì backup phải chứa

DATABASE_PATH trỏ đến /app/data/linkbreeze.db. Avatar đã upload và thumbnail của link được ghi cùng với nó trong /app/data/uploads. Cả hai đều nằm trong named volume linkbreeze-data, nên đơn vị backup là volume, không phải chỉ riêng file database. Nếu khôi phục file mà thiếu thư mục uploads, mọi image trên trang sẽ trả về 404.

Mọi dữ liệu còn lại thực sự nằm trong database duy nhất đó: page, link, setting, theme, người đăng ký email và các row analytics.

Tạo bản sao khi container đã dừng:

docker compose stop linkbreeze
docker compose cp linkbreeze:/app/data ./backup-$(date +%F)
docker compose start linkbreeze

Phải dừng trước vì việc sao chép database SQLite trong lúc một process đang ghi có thể lấy phải transaction chưa hoàn tất. Khi đó bản sao sẽ mở như một file bị corrupt. Trang sẽ offline trong lúc sao chép. Khôi phục cũng thực hiện ngược lại:

docker compose stop linkbreeze
docker compose cp ./backup-2026-08-14/. linkbreeze:/app/data
docker compose start linkbreeze
docker compose logs -f linkbreeze

Dashboard cũng cung cấp chức năng export JSON, được phục vụ từ /api/backup dưới dạng linkbreeze-backup-YYYY-MM-DD.json. File này chứa profile, link, setting và theme đã lưu. Nó không chứa lịch sử analytics, người đăng ký email hoặc image đã upload. Khi khôi phục, chức năng này sẽ xóa các row hiện tại trong bốn table đó trước khi chèn các row từ file. Hãy xem đây là config snapshot để chuyển sang host khác hoặc hoàn tác một lỗi chỉnh sửa. Bản sao của volume mới là backup.

Ở đây áp dụng hai quy tắc lưu trữ giống như mọi nơi khác khi bạn chạy SQLite trên VPS trong môi trường production. Giữ database trên local disk vì cơ chế locking của SQLite không đáng tin cậy trên network filesystem, và bạn thường chỉ phát hiện điều đó khi một page bị corrupt. Nếu thay named volume bằng host bind mount, trước tiên hãy chown thư mục trên host: container chạy bằng user không phải root node, có uid 1000 trong node:22-alpine. Thư mục do root tạo sẽ không cho user này ghi, nên app không thể mở database và container sẽ thoát khi khởi động. Bind mount thay cho named volume trong Compose giải thích đầy đủ về lựa chọn này.

Đây là tính năng biện minh cho việc tự host một trang mà bạn có thể dùng miễn phí ở nơi khác.

Analytics không dùng cookie. Không có cookie nào được đặt cho visitor và không có script bên thứ ba nào được tải trên trang public. Một visitor được nhận diện bằng hash SHA-256 của địa chỉ IP, chuỗi user agent và salt, sau đó được cắt còn 16 ký tự hệ thập lục phân. Bản thân salt là hash của ngày UTC hiện tại và SECRET_KEY, nên thay đổi vào 00:00 UTC. Vì vậy, không thể đối chiếu hash của ngày hôm qua với hash của ngày hôm nay. Địa chỉ IP gốc không bao giờ được ghi vào database.

Click được đếm trên server. Mọi liên kết http trên trang public đều trỏ đến /go/<id> trên domain của bạn. Endpoint này ghi nhận click rồi trả về redirect 302 đến đích thực. Vì vậy, việc đếm vẫn hoạt động với reader đã tắt JavaScript và trong các in-app browser chặn background request. Page view được ghi nhận thông qua /api/track.

Có hai trường hợp loại trừ cần biết. Request có admin session hợp lệ sẽ bị bỏ qua, nên việc bạn tự chỉnh sửa trang không làm tăng số liệu. User agent của các crawler đã biết cũng bị bỏ qua.

Về consent: không có dữ liệu nào được lưu trên thiết bị của reader. Cookie lưu trên thiết bị của reader chính là thứ mà cookie banner yêu cầu cấp quyền. Nghĩa vụ của bạn vẫn phụ thuộc vào nơi reader sinh sống, vì vậy hãy kiểm tra các yêu cầu áp dụng. Tuy nhiên, ở đây không có tracking cookie nào cần công bố và không có bên thứ ba nào nhận dữ liệu.

Có một điểm cần lưu ý: khi rotate SECRET_KEY, daily salt cũng thay đổi theo. Từ thời điểm đó, mọi visitor quay lại sẽ được tính như visitor mới.

Vì sao cột quốc gia trong analytics bị trống?

Vì không có thành phần nào trong stack của bạn thiết lập header quốc gia. LinkBreeze xác định quốc gia từ các proxy header như cf-ipcountryx-vercel-ip-country. Nếu VPS chạy phía sau Caddy hoặc Nginx của riêng bạn, các header này không tồn tại, nên quốc gia được ghi là null và phần phân tích chi tiết vẫn trống. Container không có cơ sở dữ liệu GeoIP.

Có 2 cách để điền cột này. Đặt Cloudflare phía trước domain để Cloudflare thêm cf-ipcountry vào mọi request được proxy. Hoặc thiết lập một trong các header đó trong reverse proxy của bạn bằng kết quả tra cứu GeoIP cục bộ.

Có một lỗi liên quan nghiêm trọng hơn, nên kiểm tra luôn. Các handler click và view đọc địa chỉ client trước tiên từ X-Forwarded-For, sau đó từ X-Real-IP, và dùng 0.0.0.0 nếu cả hai header đều không tồn tại. Nếu publish port 3000 trực tiếp ra Internet mà không có proxy phía trước, mọi visitor sẽ được hash thành cùng một giá trị. Khi đó số unique visitor luôn là 1, còn per-IP rate limit 60 event mỗi phút sẽ áp dụng cho toàn bộ audience cùng lúc. Khi chạy phía sau directive reverse_proxy ở trên, Caddy tự thiết lập header này cho bạn và cả hai vấn đề sẽ biến mất.

Nhập từ Linktree và những gì không được chuyển sang

Trình hướng dẫn migration trong dashboard chấp nhận URL profile công khai hoặc file đã export. Công cụ nhận diện các trang linktr.ee, bento.me, lnk.bio, tap.link, hopp.bio, beacons.ai, solo.to, linkfly, mssg.me và LittleLink, cùng các bản export HTML và JSON thông thường. Với URL Linktree hoặc Bento, công cụ đọc JSON __NEXT_DATA__ được nhúng trong các trang đó. Với trang tĩnh, công cụ đọc các thẻ anchor.

Những dữ liệu được chuyển sang gồm title, URL, description và image của từng link, việc link đó có phải social profile hay không, cùng display name, bio và avatar của bạn. Bạn chọn những link được tìm thấy muốn giữ lại trước khi bất kỳ dữ liệu nào được ghi vào database.

Những dữ liệu không được chuyển sang gồm lịch sử analytics, theme và layout, email subscriber, ngày publish đã lên lịch, cùng mọi dữ liệu mà nền tảng cũ yêu cầu đăng nhập riêng. Bạn nên chuẩn bị dựng lại giao diện thủ công và chấp nhận rằng lịch sử click cũ vẫn nằm trên service cũ.

Importer fetch URL từ server của bạn thay vì từ browser, nên sẽ từ chối các địa chỉ không public. Private/local URLs are not allowed có nghĩa là bạn đã cung cấp một địa chỉ bên trong network của chính mình. Việc từ chối là có chủ ý: nếu không có cơ chế này, bất kỳ ai có quyền truy cập dashboard cũng có thể dùng server của bạn để thăm dò các máy chỉ server của bạn mới truy cập được. Các thông báo khác bạn có thể thấy là Only http and https URLs are allowed, Request timed outResponse too large.

Việc scrape phụ thuộc vào markup của nền tảng khác. Nếu trình hướng dẫn không tìm thấy gì trên một trang rõ ràng có các link, nền tảng đó đã thay đổi HTML kể từ khi parser được viết. Hãy thêm các link thủ công thay vì chờ bản sửa. Nếu mục tiêu thực sự của bạn là các short link có thể đo lường thay vì một profile page, một URL shortener tự host như Shlink sẽ phù hợp và chạy ổn định trên cùng máy chủ.

Cập nhật bản triển khai đã ghim

# edit the image tag in docker-compose.yml, then
docker compose pull
docker compose up -d
docker compose logs -f linkbreeze

Migration schema tự động chạy khi container khởi động. Không có cách được ghi nhận để chạy migration theo chiều ngược lại, vì vậy hãy sao lưu bản copy của volume trước. Một lần nâng cấp không thể hoàn tác chỉ an toàn khi bạn có thể khôi phục đúng trạng thái trước đó.

Dashboard hiển thị banner khi có release mới hơn. Dashboard kiểm tra bằng cách tải một file version nhỏ từ GitHub repository của project mỗi 24 giờ một lần và không gửi thông tin nào về instance của bạn. Hãy đọc release notes trước khi thay đổi tag, vì ở giai đoạn hiện tại của project, một minor version có thể thay đổi các giá trị mặc định mà bạn đang sử dụng.

Các lỗi thường gặp và chuỗi bạn sẽ thấy

manifest unknown khi pull. Tag được ghi là :v1.2.7. Registry tag không có v, vì vậy hãy dùng :1.2.7.

no matching manifest for linux/arm64/v8 in the manifest list entries. Image đã publish chỉ hỗ trợ amd64. Hãy build image trên máy chủ ARM từ source đã gắn tag.

Container báo unhealthy trong khi trang vẫn tải bình thường. Healthcheck trong file compose của bạn đang gọi curl, nhưng image không chứa lệnh này. Xóa healthcheck đó để healthcheck wget của image tự chạy.

Caddy trả về lỗi certificate hoặc hoàn toàn không phản hồi. Kiểm tra docker compose logs caddy. Nguyên nhân thường gặp là A record chưa trỏ đến VPS này, hoặc firewall đang đóng port 80. Khi đó ACME (automatic certificate management environment) HTTP challenge mà Caddy dùng để xác minh quyền kiểm soát domain sẽ bị chặn.

Số visitor duy nhất bị kẹt ở 1. Không có proxy nào đặt X-Forwarded-For, nên mọi visitor đều cho cùng một giá trị băm.

Container thoát ngay sau khi khởi động, dù hôm qua vẫn hoạt động. Nếu bạn chuyển từ named volume sang host bind mount, thư mục dữ liệu thuộc về root còn app chạy bằng uid 1000, nên app không thể mở file database. Hãy sudo chown -R 1000:1000 thư mục trên host.

Các request tracking nhận phản hồi HTTP 429. Đã chạm giới hạn theo từng IP trên /api/track/go/<id>. Visitor vẫn được redirect đến đích; chỉ là click không được ghi nhận.

FAQ

Đây vẫn là một dự án mới. Tính đến tháng 8 năm 2026, repository có 178 stars, 17 forks và 1 maintainer; bản release đầu tiên có ngày 1 tháng 7 năm 2026. Trung bình mỗi tuần có hơn 2 lần release, nên bug được sửa nhanh nhưng hành vi của phần mềm cũng thay đổi nhanh. MIT license và file SQLite cục bộ giúp bạn vẫn giữ được một trang hoạt động ngay cả khi quá trình phát triển dừng lại. Tuy nhiên, một web app công khai không còn nhận security fix sẽ trở thành rủi ro, nên hãy xem đây là phần mềm cần được update liên tục, không phải thứ chỉ cần install một lần.

Nên chạy image tag nào của LinkBreeze?

Hãy chạy version tag, ví dụ ghcr.io/manak-hash/linkbreeze:1.2.7, và thay đổi tag một cách có chủ đích. Release workflow chỉ push latest và số version thuần, nên :v1.2.7 với v không tồn tại và Docker sẽ trả về manifest unknown. Image chỉ được build cho linux/amd64, nên trên VPS arm64 bạn phải clone tag rồi build local.

Vì sao phần phân tích theo quốc gia trong LinkBreeze vẫn trống?

LinkBreeze đọc quốc gia của visitor từ các proxy header như cf-ipcountry hoặc x-vercel-ip-country và không có GeoIP database riêng. VPS chạy sau Caddy hoặc Nginx của bạn sẽ không tự set các header này, nên quốc gia được lưu là null. Hãy đặt Cloudflare trước domain hoặc cấu hình reverse proxy set một trong các header đó bằng local GeoIP lookup.

Cụ thể cần backup những gì và restore như thế nào?

Hãy backup toàn bộ volume linkbreeze-data, không chỉ file database. /app/data/linkbreeze.db chứa mọi link, page, setting, subscriber và analytics row; /app/data/uploads chứa avatar và thumbnail image được page tham chiếu. Dừng container, chạy docker compose cp linkbreeze:/app/data ./backup-$(date +%F), rồi khởi động lại container. Để restore, hãy copy directory trở lại container đang dừng rồi start container. JSON export từ dashboard chỉ là config snapshot của profile, link, setting và theme; file này không chứa analytics hoặc image.

Import từ Linktree có giữ lại analytics và theme không?

Không. Migration wizard đọc link title, URL, description và image từ public profile cũ, cùng display name, bio và avatar của bạn. Lịch sử analytics, theme, email subscriber và lịch publish đã lên lịch sẽ không được chuyển sang. Sau khi import, hãy tạo lại giao diện trong theme editor và lưu ý rằng lịch sử click vẫn nằm trên platform cũ.

#linkbreeze#linktree-alternative#docker-compose#sqlite#tự lưu trữ#analytics