SSD Nodes Learn 8GB RAM — $66/năm
Hướng dẫn Matt ConnorBởi Matt Connor · Cập nhật ngày 2026-08-01

Tự host URL shortener với Shlink trên VPS và Docker

Tự host URL shortener với Shlink 5.1 trên VPS: cấu hình DNS, Postgres, API key, web client, HTTPS, mã QR và thống kê click bằng Docker Compose.

Bạn sẽ xây dựng gì

Một URL shortener tự host là một server nhỏ biến liên kết dài thành liên kết ngắn do bạn sở hữu và đếm từng lượt nhấp vào liên kết đó. Shlink là lựa chọn phù hợp: đây là phần mềm mã nguồn mở, được phát hành dưới dạng Docker image và thực hiện toàn bộ chức năng trong một container cùng một database. Hướng dẫn này triển khai Shlink trên VPS, phía sau một short domain thực tế, với HTTPS, API key, mã QR và thống kê lượt nhấp.

Hai thành phần giúp hệ thống hoạt động giống một dịch vụ rút gọn URL thương mại. API server xử lý redirect và lưu dữ liệu. Web client là một static app riêng, giao tiếp với API đó từ trình duyệt của bạn. Bạn có thể chạy cả hai hoặc chỉ chạy API rồi điều khiển bằng command line.

Các số phiên bản trong bài là phiên bản hiện tại vào tháng 7 năm 2026: Shlink 5.1 và shlink-web-client 4.8.

Trước tiên, trỏ một domain ngắn đến server

Domain chính là sản phẩm. s.example.com/abc123 là liên kết mọi người nhìn thấy, vì vậy hãy chọn một tên ngắn và quyết định tên đó trước khi cài đặt bất cứ thứ gì. Shlink lưu domain cùng mỗi short URL. Nếu đổi domain sau này, mọi liên kết bạn đã phát hành sẽ ngừng hoạt động.

Tạo một bản ghi DNS A cho short domain, trỏ đến địa chỉ IPv4 public của VPS. Nếu server có IPv6, hãy thêm bản ghi AAAA. Sau đó, xác nhận domain phân giải được trước khi tiếp tục.

dig +short s.example.com A

Kết quả phải là địa chỉ của server. Nếu kết quả trống, bản ghi chưa được propagate. Mọi bước sau sẽ thất bại theo cách khó chẩn đoán, vì không thể cấp chứng chỉ TLS (transport layer security) cho một tên không phân giải được.

Tệp compose

Shlink cần một cơ sở dữ liệu. SQLite phù hợp để kiểm thử, nhưng Postgres là lựa chọn đúng cho mọi thứ bạn dự định duy trì lâu dài, vì số bản ghi lượt truy cập sẽ tăng dần và Postgres xử lý index cũng như các lần ghi đồng thời tốt hơn. Đặt nội dung sau vào /opt/shlink/compose.yaml.

services:
  shlink:
    image: shlinkio/shlink:stable
    restart: unless-stopped
    ports:
      - "127.0.0.1:8080:8080"
    environment:
      DEFAULT_DOMAIN: s.example.com
      IS_HTTPS_ENABLED: "true"
      DB_DRIVER: postgres
      DB_HOST: database
      DB_NAME: shlink
      DB_USER: shlink
      DB_PASSWORD: ${DB_PASSWORD}
    depends_on:
      - database

  database:
    image: postgres:17-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: shlink
      POSTGRES_USER: shlink
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - shlink_db:/var/lib/postgresql/data

  web-client:
    image: shlinkio/shlink-web-client:stable
    restart: unless-stopped
    ports:
      - "127.0.0.1:8081:8080"

volumes:
  shlink_db:

Cả hai port được publish đều bind vào 127.0.0.1, vì vậy chưa thể truy cập từ internet cho đến khi reverse proxy ở phần tiếp theo được thiết lập. Docker ghi các rule chuyển tiếp của riêng nó trước firewall của host. Vì vậy, một dòng 8080:8080 đơn giản vẫn có thể expose ứng dụng ngay cả khi firewall của máy trông như đang đóng. Bind vào địa chỉ loopback sẽ tránh được việc này. Bạn có thể áp dụng cùng mẫu này cho mọi ứng dụng chạy theo cách này. Nội dung này được giải thích chi tiết hơn trong hướng dẫn về Docker Compose trên VPS.

Mật khẩu cơ sở dữ liệu lấy từ một file .env nằm cạnh file compose, nên không được ghi vào YAML.

sudo mkdir -p /opt/shlink
printf 'DB_PASSWORD=%s\n' "$(openssl rand -base64 24)" | sudo tee /opt/shlink/.env
sudo chmod 600 /opt/shlink/.env

Khởi động dịch vụ và theo dõi API khởi chạy.

cd /opt/shlink
sudo docker compose up -d
sudo docker compose logs -f shlink

Lần khởi động đầu tiên sẽ chạy các migration của cơ sở dữ liệu, nên mất nhiều thời gian hơn những lần sau. Khi dịch vụ đã ổn định, kiểm tra xem dịch vụ có trả lời cục bộ không.

curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/rest/health

200 cho biết API đang hoạt động và kết nối đến cơ sở dữ liệu thành công. Mã 500 ở đây hầu như luôn liên quan đến cơ sở dữ liệu: DB_PASSWORD trong .env không khớp với giá trị Postgres được tạo cùng, vì image Postgres chỉ đọc POSTGRES_PASSWORD khi khởi tạo một thư mục dữ liệu trống. Việc chỉnh sửa mật khẩu sau đó không có tác dụng cho đến khi bạn xóa volume và khởi động lại.

Shlink phục vụ HTTP thuần trên port 8080. TLS phải được xử lý trong reverse proxy. Thiết lập quan trọng là chuyển tiếp tên host ban đầu. Shlink xác định một short code thuộc domain nào bằng cách đọc header Host. Vì vậy, proxy ghi đè header này sẽ trả về phản hồi 404 cho các link đang tồn tại và gắn thống kê lượt truy cập với sai domain.

server {
    server_name s.example.com;
    listen 80;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Sau đó, hãy cấp certificate. Hướng dẫn đầy đủ, bao gồm cả timer gia hạn, có trong hướng dẫn Certbot cho nginx trên Ubuntu 24.04.

sudo certbot --nginx -d s.example.com

IS_HTTPS_ENABLED: "true" trong file compose khiến Shlink in https:// trong các short URL mà nó trả về. Thiết lập này không tự bật TLS. Nếu để false phía sau một proxy HTTPS, mọi link mà API trả về sẽ là link http:// rồi mới redirect. Điều này làm phát sinh thêm một round trip và hiển thị không đúng trong web client.

Tạo API key

API không thể nhận yêu cầu nếu không có key. Tạo key bằng CLI bên trong container.

sudo docker compose exec shlink shlink api-key:generate --name "web client"

Lệnh chỉ hiển thị key một lần. Hãy sao chép ngay, vì key được lưu dưới dạng hash và không thể hiển thị lại. shlink api-key:list hiển thị tên và trạng thái bật/tắt của từng key, không bao giờ hiển thị chính key. Thu hồi một key bằng shlink api-key:disable và tên của key đó.

Mỗi REST call gửi key trong header X-Api-Key.

curl -H "X-Api-Key: YOUR_KEY" https://s.example.com/rest/v3/short-urls

Một đối tượng JSON có key shortUrls cho biết key hoạt động. 401 chứa INVALID_API_KEY cho biết key không đúng, đã bị tắt hoặc đã hết hạn.

CLI là cách nhanh nhất để tạo link và phù hợp với việc tự động hóa bằng script.

sudo docker compose exec shlink shlink short-url:create https://example.com/a/very/long/path
sudo docker compose exec shlink shlink short-url:create https://example.com/docs --custom-slug docs --tag reference

--custom-slug tạo link dễ đọc thay vì mã được sinh tự động. Slug là duy nhất trong từng domain, nên lần thử thứ hai với slug đã được sử dụng sẽ thất bại thay vì âm thầm ghi đè link đầu tiên. Có thể lặp lại --tag, còn tag dùng để nhóm các link mà sau này bạn muốn xem thống kê tổng hợp.

Liệt kê các link hiện có, sau đó xem traffic của một link.

sudo docker compose exec shlink shlink short-url:list
sudo docker compose exec shlink shlink short-url:visits docs

short-url:visits in một dòng cho mỗi lượt click, gồm ngày, referrer và user agent. Các cột quốc gia và thành phố sẽ để trống nếu bạn chưa đặt biến môi trường GEOLITE_LICENSE_KEY. Đây là MaxMind key miễn phí mà Shlink dùng để tải cơ sở dữ liệu GeoLite2. Không có key này, các lượt truy cập vẫn được ghi nhận nhưng không được xác định vị trí.

Web client và mã QR

Web client hiện chạy tại 127.0.0.1:8081 và cần một entry proxy riêng, hoặc một SSH tunnel nếu bạn không muốn public nó. Trong lần tải đầu tiên, client yêu cầu server URL và API key. Nhập https://s.example.com và key bạn đã tạo. Client lưu cả hai trong browser storage và gọi trực tiếp đến API của bạn, nên không có dữ liệu nào đi qua bên thứ ba.

Mã QR không cần cấu hình. Thêm /qr-code vào bất kỳ short URL nào, API sẽ trả về hình ảnh.

https://s.example.com/docs/qr-code?size=500&format=svg&margin=20

size là chiều rộng tính bằng pixel và nhận giá trị từ 50 đến 1000, mặc định là 300. formatpng hoặc svg. margin là khoảng trống xung quanh mã, tính bằng pixel. Kích thước hình ảnh hoàn chỉnh bằng kích thước mã cộng với hai lần lề. Thêm errorCorrection=Q để mã vẫn quét được khi được in nhỏ hoặc bị che một phần.

Duy trì hoạt động

Một dịch vụ rút gọn URL có thể âm thầm bị lỗi. Các liên kết ngừng chuyển hướng nhưng không ai báo cho bạn, vì người nhấp vào cho rằng liên kết đã hỏng. Cấu hình kiểm tra uptime với một short URL thực tế thay vì trang chủ, rồi cảnh báo khi kết quả không phải là redirect. Một instance Uptime Kuma tự lưu trữ thực hiện tốt việc này và có thể kiểm tra một status code cụ thể.

Hãy sao lưu database, không phải container. Một lệnh sẽ dump database.

sudo docker compose exec -T database pg_dump -U shlink shlink | gzip > shlink-$(date +%F).sql.gz

File đó cùng với compose file có thể dựng lại toàn bộ service trên server mới. Quy trình nâng cấp là sudo docker compose pull rồi đến sudo docker compose up -d, và Shlink sẽ chạy mọi migration mới khi khởi động. Hãy dump trước khi pull, vì không thể rollback migration.

FAQ

Shlink đối chiếu short code với domain trong header Host. Proxy gửi tên của chính nó hoặc một địa chỉ nội bộ khiến Shlink tìm code đó dưới một domain không có link, nên trả về 404. Đặt proxy_set_header Host $host; trong block location của nginx rồi reload proxy. Các link sẽ hoạt động ngay, không cần restart container.

Tôi có cần Postgres không, hay SQLite là đủ?

SQLite phù hợp để thử Shlink và không cần container thứ hai. Hãy chuyển sang Postgres trước khi publish các link quan trọng, vì số dòng lượt truy cập tăng sau mỗi click và SQLite tuần tự hóa các thao tác ghi. Nếu chuyển đổi sau này, bạn phải export rồi import lại các link. Chọn Postgres ngay từ đầu sẽ tránh lần migration đó.

Tôi có thể khôi phục API key đã quên sao chép không?

Không. Shlink lưu hash của key, nên api-key:list chỉ hiển thị tên và trạng thái, không bao giờ hiển thị giá trị key. Tạo key thay thế bằng shlink api-key:generate, dán key đó vào web client, rồi disable key cũ bằng shlink api-key:disable để key cũ không còn hoạt động.

Tại sao các cột quốc gia trong thống kê lượt truy cập của tôi bị trống?

Tính năng định vị địa lý cần database GeoLite2. Shlink chỉ download database này khi bạn cung cấp GEOLITE_LICENSE_KEY. Key được MaxMind cấp miễn phí. Thêm key vào phần environment, recreate container, rồi các lượt truy cập mới sẽ được định vị. Các lượt truy cập được ghi trước đó vẫn để trống cho đến khi bạn chạy shlink visit:locate.

Giữ nguyên domain và chuyển dữ liệu. Dump database bằng pg_dump, copy dump và file compose sang server mới, start stack, rồi restore dump vào database trống trước khi có traffic thực tế. Thay đổi bản ghi DNS sau cùng. Các short code và lịch sử lượt truy cập vẫn được giữ nguyên vì mọi dữ liệu đều nằm trong database.

#shlink#url-shortener#tự lưu trữ#Docker#postgres