引言

Shlink 是一款基于 PHP 的自托管网址缩短服务。由于个人对 PHP 存在一定心理门槛😅,此前一直未尝试部署。近期阅读项目文档后发现,它实际上支持多种 PHP Runtime(运行时):

  • RoadRunner
  • FrankenPHP
  • 传统方式(Web Server + FastCGI)

其中,RoadRunner 是 Shlink 官方推荐的默认运行方式。它是一款由 Go 编写的 PHP 运行时,通过多个 Worker 将 Shlink 常驻于内存 (RAM) 中运行,在响应速度和整体性能方面表现良好。

Shlink 后端服务与 Web 管理后台是分离的,Web 管理面板有两种解决方案:

  • shlink-web-client :官方提供的 PWA Web 应用,纯静态应用,API Key 存储在浏览器的本地存储中
  • shlink-dashboard :官方的下一代 Web 面板,包含 shlink-web-client 的所有功能,并支持高级用户认证和角色管理,API Key 统一存储在服务器端。

我选择 shlink-dashboard 作为 Web 管理面板,并使用 PostgreSQL 作为后端服务与管理面板共用的数据库。

准备 Docker 模板

新建 compose.yml 模板,写入

services:
  db:
    image: postgres:18-alpine
    container_name: shlink-db
    restart: unless-stopped
    environment:
      - POSTGRES_DB=shlink        # Shlink 数据库名称
      - POSTGRES_USER=shlink      # 数据库用户名
      - POSTGRES_PASSWORD=shlink  # 数据库密码
    volumes:
      - ./data:/var/lib/postgresql
      - ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql:ro  # 初始化阶段创建另一个数据库
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U shlink -d shlink"]
      interval: 10s
      timeout: 5s
      retries: 5

  shlink-backend:
    image: shlinkio/shlink:stable
    container_name: shlink-backend
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
    environment:
      - DB_DRIVER=postgres
      - DB_HOST=db
      - DB_NAME=shlink              # Shlink 数据库名称
      - DB_USER=shlink              # 数据库用户名
      - DB_PASSWORD=shlink          # 数据库密码
      - DEFAULT_DOMAIN=your.domain  # 要绑定的短链接域名
      - IS_HTTPS_ENABLED=true       # 启用 HTTPS
      - TIMEZONE=Asia/Shanghai
      - GEOLITE_LICENSE_KEY=        # [可选] MaxMind GeoLite Key
      - TRUSTED_PROXIES=172.16.0.0/12
      - LOGS_FORMAT=json
      - DEFAULT_SHORT_CODES_LENGTH=4
      - SHORT_URL_TRAILING_SLASH=true
    ports:
      - '127.0.0.1:8080:8080'
  
  shlink-web:
    image: shlinkio/shlink-dashboard:stable
    container_name: shlink-web
    user: '1000:1000'
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
    ports:
     - '127.0.0.1:3005:3005'
    environment:
      SHLINK_DASHBOARD_DB_DRIVER: postgres
      SHLINK_DASHBOARD_DB_HOST: db
      SHLINK_DASHBOARD_DB_PORT: 5432
      SHLINK_DASHBOARD_DB_NAME: dashboard   # shlink-dashboard 数据库名称
      SHLINK_DASHBOARD_DB_USER: shlink      # 数据库用户名
      SHLINK_DASHBOARD_DB_PASSWORD: shlink  # 数据库密码
      SHLINK_DASHBOARD_SESSION_SECRETS: secret1,secret2...  # 修改会话机密,设置多个高强度随机值,半角逗号隔开

Shlink 与 shlink-dashboard 将共用同一个 PostgreSQL 容器。由于该镜像在初始化阶段默认仅创建单一数据库,我们的预期是分别为两者提供独立的数据库。

准备一个 init-db.sql,并在其中定义相应的初始化逻辑,这会在 PostgreSQL 首次启动时执行并最终创建两个可用的数据库。

CREATE DATABASE dashboard;

启动服务

似乎没有必要,但为了稳妥起见,先启动数据库

sudo docker compose up -d db

低配置的 VPS 建议手动检查健康状况,确保数据库准备就绪

sudo docker compose ps db
# 或者
sudo docker compose logs -f db

现在可以启动所有服务

sudo docker compose up -d

生成访问凭据

使用 Shlink CLI 工具生成访问密钥,用于后续 Web 管理面板添加服务器

sudo docker exec -it shlink-backend shlink api-key:generate

看起来是 UUID 格式,复制保留备用

gen-api-key.webp

[附] Shlink CLI 命令手册建议保留备用

用法:
  command [options] [arguments]

选项:
  -h, --help            显示指定命令的帮助信息;如果未指定命令,则显示命令列表的帮助
      --silent          不输出任何信息
  -q, --quiet           仅显示错误信息,其他所有输出都会被抑制
  -V, --version         显示当前应用程序的版本
      --ansi|--no-ansi  强制启用(或禁用)ANSI 输出
  -n, --no-interaction  不询问任何交互式问题
  -v|vv|vvv, --verbose  提高输出详细程度:1 为普通输出,2 为更详细输出,3 为调试输出

可用命令:
  completion                      输出 shell 自动补全脚本
  help                            显示某个命令的帮助信息
  list                            列出所有可用命令
 api-key
  api-key:delete                  按名称删除一个 API Key
  api-key:disable                 按名称或明文 key 禁用一个 API Key(使用明文 key 的方式已被弃用)
  api-key:generate                生成一个新的有效 API Key
  api-key:initial                 尝试创建初始 API Key
  api-key:list                    列出所有可用的 API Key
  api-key:rename                  按名称重命名一个 API Key
 domain
  domain:list                     列出曾被用于任意短链接的所有域名
  domain:redirects                为指定域名设置特定的“未找到(404)”重定向规则
  domain:visits                   返回指定域名的访问记录列表
 integration
  integration:matomo:send-visits  将现有访问记录发送到已配置的 Matomo 实例
 short-url
  short-url:create                为提供的长链接生成一个短链接并返回
  short-url:delete                删除一个短链接
  short-url:delete-expired        删除所有被视为已过期的短链接(有效时间早于当前时间)
  short-url:edit                  编辑一个已有的短链接
  short-url:import                允许从第三方来源导入短链接
  short-url:list                  列出所有短链接
  short-url:manage-rules          为某个短链接设置重定向规则
  short-url:parse                 返回某个短代码对应的原始长链接
  short-url:visits                返回指定短代码的详细访问记录信息
  short-url:visits-delete         删除某个短链接的访问记录
 tag
  tag:delete                      删除一个或多个标签
  tag:list                        列出现有的标签
  tag:rename                      重命名一个已有的标签
  tag:visits                      返回指定标签的访问记录列表
 visit
  visit:download-db               检查 GeoLite2 数据库文件是否过旧或不存在,如是则尝试下载最新版本
  visit:locate                    解析访问记录的来源地理位置;如有需要会自动下载或更新 GeoLite2 数据库
  visit:non-orphan                返回非孤立访问记录的列表
  visit:orphan                    返回孤立访问记录的列表
  visit:orphan-delete             删除所有孤立访问记录

设置反向代理

为了方便管理,我将 Shlink 和 shlink-dashboard 放到同一个 Nginx 虚拟主机反向代理配置内:

server {
    listen 80;
    listen [::]:80;
    server_name your.domain shlink-dash.your.domain;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name your.domain;

    ssl_certificate /path/to/cert/your.domain.pem;
    ssl_certificate_key /path/to/cert/your.domain.key;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ecdh_curve X25519:P-256:P-384;
    ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-CHACHA20-POLY1305:ECDHE+AES128:RSA+AES128:ECDHE+AES256:RSA+AES256';
    ssl_prefer_server_ciphers off;
    ssl_session_timeout 1d;
    ssl_session_cache shared:MozSSL:10m;

    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    access_log /var/log/nginx/your.domain.access.log;
    error_log /var/log/nginx/your.domain.error.log;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_http_version 1.1;

        proxy_set_header Host $http_host;
        proxy_set_header X-Forwarded-For $realip_remote_addr;
        proxy_set_header X-Forwarded-Proto https;

        proxy_connect_timeout 10s;
        proxy_send_timeout 30s;
        proxy_read_timeout 90s;

        proxy_redirect off;

        add_header X-Frame-Options "SAMEORIGIN" always;
        add_header X-XSS-Protection "1; mode=block" always;
        add_header X-Content-Type-Options "nosniff" always;
        add_header Referrer-Policy "no-referrer-when-downgrade" always;
        add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
    }
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name shlink-dash.your.domain;

    ssl_certificate /path/to/cert/shlink-dash.your.domain.pem;
    ssl_certificate_key /path/to/cert/shlink-dash.your.domain.key;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ecdh_curve X25519:P-256:P-384;
    ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-CHACHA20-POLY1305:ECDHE+AES128:RSA+AES128:ECDHE+AES256:RSA+AES256';
    ssl_prefer_server_ciphers off;
    ssl_session_timeout 1d;
    ssl_session_cache shared:MozSSL:10m;

    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    access_log /var/log/nginx/shlink-dash.your.domain.access.log;
    error_log /var/log/nginx/shlink-dash.your.domain.error.log;

    location / {
        proxy_pass http://127.0.0.1:3005;
        proxy_http_version 1.1;

        proxy_set_header Host $http_host;
        proxy_set_header X-Forwarded-For $realip_remote_addr;
        proxy_set_header X-Forwarded-Proto https;

        proxy_connect_timeout 10s;
        proxy_send_timeout 30s;
        proxy_read_timeout 60s;

        proxy_redirect off;

        add_header X-Frame-Options "SAMEORIGIN" always;
        add_header X-XSS-Protection "1; mode=block" always;
        add_header X-Content-Type-Options "nosniff" always;
        add_header Referrer-Policy "no-referrer-when-downgrade" always;
        add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
    }
}

检查 Nginx 配置

sudo nginx -t

重载 Nginx 配置

sudo nginx -s reload

使用服务

登录管理后台

访问 shlink-dash.your.domain 面板,默认用户名和密码都是 admin,登录后会要求修改密码。

login.webp

添加服务器

重置密码后就进入管理后台了,点击添加服务器

add-server.webp

输入服务器信息和访问凭据即可

add-new-server.webp

现在就可以愉快的使用了。

enjoy.webp

批量导入短链

最近需要将短链域名由 via.moe 改为 zsh.moe,从后台导出了 short_urls.csv

需要转换 CSV 格式:

cat << 'EOF' > convert.py
import csv

input_file = 'short_urls.csv'
output_file = 'import.csv'

# 定义 Shlink 标准列名
# 映射关系: longUrl -> Long URL, shortCode -> Short code, title -> Title, domain -> Domain, tags -> Tags
field_map = {
    'longUrl': 'Long URL',
    'shortCode': 'Short code',
    'title': 'Title',
    'domain': 'Domain',
    'tags': 'Tags'
}

with open(input_file, mode='r', encoding='utf-8') as f_in:
    reader = csv.DictReader(f_in)
    
    with open(output_file, mode='w', encoding='utf-8', newline='') as f_out:
        writer = csv.DictWriter(f_out, fieldnames=field_map.values())
        writer.writeheader()
        
        for row in reader:
            # 提取并清理数据
            new_row = {
                'Long URL': row.get('longUrl', ''),
                'Short code': row.get('shortCode', ''),
                'Title': row.get('title', ''),
                'Domain': row.get('domain', ''),
                'Tags': row.get('tags', '').replace(',', '|') # 确保标签用 | 分隔
            }
            writer.writerow(new_row)

print(f"转换完成!已生成 {output_file}")
EOF

开始转换

python3 convert.py

设置权限

chmod 644 import.csv

编辑 compose.yml,挂载文件到容器

services:
  db:
    #...

  shlink-backend:
    image: shlinkio/shlink:stable
	# ...
	# 添加挂载映射
    volumes:
      - ./import.csv:/etc/shlink/import.csv:ro
	#...

  shlink-web:
    #...

重新创建容器

sudo docker compose up -d

开始导入

sudo docker exec -it shlink-backend shlink short-url:import

开始导入

 What is the source you want to import from:
 > csv


 What's the path for the CSV file you want to import:
 > ./import.csv

 What's the delimiter used to separate values? [Comma]:
  [,] Comma
  [;] Semicolon
 > ,

Importing short URLs
====================

 https://squoosh.app/: Imported
 https://shottr.cc/: Imported

 [OK] Data properly imported!

完成。