SSD Nodes Learn Hosting plans →
Hướng dẫn Matt ConnorBởi Matt Connor · Cập nhật ngày 2026-08-22

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, HTTPS, Postgres, API key, web client, QR code 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 một liên kết dài thành một liên kết ngắn do bạn sở hữu và đếm từng lượt click 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ộ công việc trong một container cùng với một database. Hướng dẫn này triển khai Shlink trên một VPS phía sau một short domain thực, có HTTPS, API key, QR code và thống kê lượt click.

Có 2 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ả 2 thành phần hoặc chỉ chạy API rồi điều khiển bằng command line.

Các version trong bài là những version hiện hành vào tháng 7 năm 2026: Shlink 5.1 và shlink-web-client 4.8.

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

Domain là phần người dùng nhìn thấy. s.example.com/abc123 là link được chia sẻ, nên hãy chọn một tên ngắn và quyết định trước khi cài bất kỳ thứ gì. Shlink lưu domain cùng với từng short URL. Nếu đổi domain sau này, mọi link bạn đã chia sẻ sẽ ngừng hoạt động.

Tạo một DNS A record cho domain ngắn và trỏ record đó đến địa chỉ IPv4 public của VPS. Nếu server có IPv6, hãy thêm cả một AAAA record. 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ả rỗng, record chưa propagate. Khi đó mọi bước tiếp theo sẽ fail theo cách khó xác định, 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 database. SQLite phù hợp để test, nhưng Postgres là lựa chọn đúng cho mọi thứ bạn dự định lưu lâu dài, vì số dòng visit sẽ tăng dần và Postgres xử lý index cũng như các thao tác 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 có gì truy cập được từ Internet cho đến khi reverse proxy ở phần tiếp theo được cấu hình. Docker tạo các rule forwarding riêng trước firewall của host. Vì vậy, chỉ một dòng 8080:8080 đơn giản cũng có thể expose app, ngay cả trên máy có firewall 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 pattern cho mọi app chạy theo cách này. Pattern này được giải thích chi tiết hơn trong hướng dẫn Docker Compose trên VPS.

Mật khẩu database được đọc 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 app và monitor quá trình API khởi động.

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 database migration, nên mất nhiều thời gian hơn những lần sau. Khi service đã ổn định, hãy kiểm tra service có trả lời cục bộ hay không.

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

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

Đặt HTTPS ở reverse proxy phía trước

Shlink phục vụ HTTP thuần trên cổng 8080. TLS phải được xử lý tại reverse proxy. Thiết lập quan trọng nhất là truyền hostname ban đầu qua proxy. Shlink xác định một short code thuộc domain nào bằng cách đọc header Host. Vì vậy, nếu proxy ghi đè header này, các link hợp lệ sẽ trả về 404 và thống kê lượt truy cập sẽ bị gắn vào 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. Hãy để false phía sau một HTTPS proxy. Khi đó, mọi link mà API trả về đều là link http:// rồi mới redirect, 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

Không thành phần nào có thể gọi API 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 đều 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 JSON object 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 dùng trong 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 dùng sẽ fail 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ê những gì đang 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 country và city sẽ để trống nếu bạn chưa đặt biến môi trường GEOLITE_LICENSE_KEY. Đây là key MaxMind miễn phí mà Shlink dùng để tải database GeoLite2. Nếu 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í.

Client web và mã QR

Client web hiện có tại 127.0.0.1:8081 và cần một proxy entry riêng, hoặc một SSH tunnel nếu bạn không muốn publish nó. Khi tải lần đầu, client yêu cầu server URL và API key. Nhập https://s.example.com cùng key bạn đã tạo. Client lưu cả hai trong browser storage và gọi trực tiếp API của bạn, nên không có dữ liệu nào đi qua bên thứ ba. Tách interface khỏi API là một pattern đáng chú ý, vì đây cũng là cách Halcyon biến thư viện Jellyfin thành một cửa hàng cho thuê đĩa kiểu thập niên 1990 mà không cần thay đổi media server phía sau.

Mã QR không cần cấu hình. Thêm /qr-code vào bất kỳ short URL nào và 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 ảnh hoàn chỉnh bằng kích thước mã cộng với hai lần margin. 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ể lỗi mà không hiển thị thông báo. Các liên kết ngừng redirect và không ai báo cho bạn, vì người nhấp vào thường nghĩ rằng liên kết đã hỏng. Hãy cấu hình uptime check kiểm tra một short URL đang hoạt động thay vì trang chủ, rồi cảnh báo khi phản hồi không phải là redirect. Một instance Uptime Kuma tự host làm việc này tốt và có thể kiểm tra một status code cụ thể.

Hãy backup database, không phải container. Một command 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. Mỗi app trên máy cần có một bản riêng của cặp file này. Thư viện ảnh là trường hợp phức tạp, vì PhotoPrism và Immich đều lưu file gốc trên disk cùng với các row trong database. Vì vậy, chỉ dump database thì không khôi phục được gì. 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. Nếu proxy gửi tên của chính nó hoặc một địa chỉ nội bộ, Shlink sẽ 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. 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 public các link quan trọng, vì số bản ghi visit tăng sau mỗi lượt click và SQLite serialize 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 copy không?

Không. Shlink chỉ lưu hash của key, nên api-key:list hiển thị tên và trạng thái nhưng không bao giờ hiển thị giá trị key. Tạo key thay thế bằng shlink api-key:generate, paste 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.

Vì sao các cột quốc gia trong thống kê visit bị trống?

Geolocation cần database GeoLite2. Shlink chỉ download database này khi bạn cung cấp GEOLITE_LICENSE_KEY. Key được cấp miễn phí từ MaxMind. Thêm key vào phần environment, recreate container, rồi các visit mới sẽ được xác định vị trí. Các visit được ghi nhận 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 file dump và file compose sang server mới, start stack, rồi restore dump vào database trống trước khi có network traffic thực tế. Chỉ thay đổi bản ghi DNS sau cùng. Short code và lịch sử visit vẫn được giữ nguyên vì toàn bộ dữ liệu nằm trong database.

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