ติดตั้ง URL shortener ส่วนตัวด้วย Shlink และ Docker
คู่มือติดตั้ง Shlink 5.1 บน VPS ด้วย Docker Compose ตั้งค่าโดเมนสั้น, HTTPS, Postgres, API key, web client, QR code และสถิติการคลิกครบถ้วน
สิ่งที่คุณกำลังสร้าง
บริการย่อ URL ที่โฮสต์เองคือเซิร์ฟเวอร์ขนาดเล็กที่เปลี่ยนลิงก์ยาวให้เป็นลิงก์สั้นที่คุณเป็นเจ้าของ และนับจำนวนการคลิกทุกครั้ง Shlink เป็นตัวเลือกที่เหมาะสม เพราะเป็นซอฟต์แวร์โอเพนซอร์ส มีการเผยแพร่เป็น Docker image และทำงานทั้งหมดได้ใน container เดียวพร้อมฐานข้อมูล คู่มือนี้จะแนะนำการติดตั้งบน VPS โดยใช้โดเมนสั้นจริง มี HTTPS, API key, QR codes และสถิติการคลิก
องค์ประกอบ 2 ส่วนทำให้ระบบนี้มีลักษณะเหมือนบริการย่อ URL เชิงพาณิชย์ API server ตอบการ redirect และจัดเก็บข้อมูล ส่วน web client เป็นแอปแบบ static แยกต่างหากที่ติดต่อกับ API จากเบราว์เซอร์ คุณสามารถใช้งานทั้ง 2 ส่วน หรือใช้เฉพาะ API แล้วควบคุมผ่าน command line
หมายเลขเวอร์ชันในคู่มือนี้เป็นเวอร์ชันปัจจุบัน ณ July 2026 ได้แก่ Shlink 5.1 และ shlink-web-client 4.8
ชี้โดเมนแบบสั้นไปยังเซิร์ฟเวอร์ก่อน
โดเมนคือส่วนที่ผู้ใช้เห็น s.example.com/abc123 เป็นลิงก์ที่ผู้ใช้เห็น ดังนั้นให้เลือกชื่อที่สั้นและกำหนดโดเมนก่อนติดตั้งสิ่งใด Shlink จะจัดเก็บโดเมนนี้ไว้กับ short URL ทุกรายการ และหากเปลี่ยนภายหลัง ลิงก์ทั้งหมดที่แจกไปแล้วจะไม่ทำงาน
สร้างระเบียน DNS A 1 รายการสำหรับโดเมนแบบสั้น โดยชี้ไปยังที่อยู่ public IPv4 ของ VPS เพิ่มระเบียน AAAA ด้วย หากเซิร์ฟเวอร์มี IPv6 จากนั้นตรวจสอบว่าระเบียนแปลงชื่อได้ก่อนดำเนินการต่อ
dig +short s.example.com Aผลลัพธ์ต้องเป็นที่อยู่ของเซิร์ฟเวอร์ หากไม่มีผลลัพธ์ แสดงว่าระเบียนยังเผยแพร่ไม่ครบ และขั้นตอนถัดไปทั้งหมดจะล้มเหลวโดยตรวจสอบสาเหตุได้ยาก เนื่องจากไม่สามารถออกใบรับรอง TLS (transport layer security) ให้กับชื่อที่แปลงชื่อไม่ได้
ไฟล์ compose
Shlink ต้องใช้ฐานข้อมูล SQLite ใช้ทดสอบได้ แต่ Postgres เป็นตัวเลือกที่เหมาะสมสำหรับข้อมูลที่ต้องการเก็บไว้ เพราะแถวข้อมูลการเข้าชมจะเพิ่มขึ้นเรื่อย ๆ และ Postgres จัดการดัชนีและการเขียนพร้อมกันได้ดีกว่า ใส่เนื้อหานี้ใน /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:พอร์ตที่เผยแพร่ทั้งสองพอร์ตจะ bind กับ 127.0.0.1 ดังนั้นจะยังไม่มีสิ่งใดเข้าถึงได้จากอินเทอร์เน็ตจนกว่าจะตั้งค่า reverse proxy ในส่วนถัดไป Docker จะเขียนกฎการส่งต่อของตัวเองไว้ก่อนกฎของไฟร์วอลล์บนโฮสต์ ซึ่งหมายความว่าการใช้บรรทัด 8080:8080 แบบตรง ๆ จะเปิดให้เข้าถึงแอปได้ แม้ไฟร์วอลล์ของเครื่องจะดูเหมือนปิดอยู่ก็ตาม การ bind กับ loopback address จะป้องกันปัญหานี้ รูปแบบเดียวกันนี้ใช้ได้กับทุกแอปที่เรียกใช้ด้วยวิธีนี้ และมีรายละเอียดเพิ่มเติมใน คู่มือ Docker Compose บน VPS
รหัสผ่านฐานข้อมูลมาจากไฟล์ .env ที่อยู่ถัดจากไฟล์ compose ดังนั้นรหัสผ่านจะไม่ถูกบันทึกไว้ใน 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เริ่มการทำงาน แล้วตรวจสอบจนกว่า API จะพร้อมใช้งาน
cd /opt/shlink
sudo docker compose up -d
sudo docker compose logs -f shlinkการเริ่มครั้งแรกจะเรียกใช้ database migrations จึงใช้เวลานานกว่าครั้งต่อ ๆ ไป เมื่อการทำงานคงที่แล้ว ให้ตรวจสอบว่าบริการตอบสนองภายในเครื่อง
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/rest/healthผลลัพธ์ 200 หมายความว่า API ทำงานอยู่และการเชื่อมต่อฐานข้อมูลใช้งานได้ ผลลัพธ์ 500 ในจุดนี้มักเกิดจากฐานข้อมูล: ค่า DB_PASSWORD ใน .env ไม่ตรงกับค่าที่ใช้สร้าง Postgres เนื่องจากอิมเมจ Postgres จะอ่าน POSTGRES_PASSWORD เฉพาะเมื่อเริ่มต้นไดเรกทอรีข้อมูลที่ว่างเปล่า การแก้ไขรหัสผ่านภายหลังจะไม่มีผลจนกว่าจะลบ volume แล้วเริ่มการทำงานอีกครั้ง
ทำ HTTPS termination ด้านหน้า
Shlink ให้บริการ HTTP แบบไม่เข้ารหัสที่พอร์ต 8080 การจัดการ TLS ควรทำใน reverse proxy และการตั้งค่าที่สำคัญที่สุดคือการส่งชื่อโฮสต์เดิมต่อไป Shlink จะพิจารณาว่า short code เป็นของโดเมนใดโดยอ่านส่วนหัว Host ดังนั้น proxy ที่เขียนค่าส่วนหัวนี้ใหม่จะทำให้ลิงก์ที่มีอยู่แล้วตอบกลับด้วย 404 และทำให้สถิติการเข้าชมถูกผูกกับโดเมนที่ไม่ถูกต้อง
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;
}
}จากนั้นจึงออกใบรับรอง ขั้นตอนโดยละเอียด รวมถึง timer สำหรับการต่ออายุ อยู่ใน คู่มือ Certbot สำหรับ nginx บน Ubuntu 24.04
sudo certbot --nginx -d s.example.comIS_HTTPS_ENABLED: "true" ในไฟล์ compose ทำให้ Shlink แสดง https:// ใน short URL ที่ส่งกลับ การตั้งค่านี้ไม่ได้เปิดใช้ TLS ด้วยตัวเอง ให้คงค่า false ไว้หลัง HTTPS proxy แล้วลิงก์ทุกลิงก์ที่ API ส่งกลับจะเป็นลิงก์ http:// ซึ่งจะ redirect ต่อไป การทำเช่นนี้เพิ่มการรับส่งข้อมูลอีกหนึ่งรอบและทำให้ลิงก์ดูไม่ถูกต้องใน web client
สร้าง API key
ไม่สามารถเรียกใช้ API ได้หากไม่มี key ให้สร้าง key ผ่าน CLI ภายใน container
sudo docker compose exec shlink shlink api-key:generate --name "web client"คำสั่งจะแสดง key เพียงครั้งเดียว ให้คัดลอกทันที เนื่องจากระบบจัดเก็บ key ในรูปแบบแฮชและไม่สามารถแสดงอีกครั้งได้ shlink api-key:list แสดงชื่อและสถานะว่า key แต่ละรายการเปิดใช้งานอยู่หรือไม่ แต่จะไม่แสดงตัว key เอง หากต้องการเพิกถอน key ให้ใช้ shlink api-key:disable ตามด้วยชื่อ
ทุก REST call จะส่ง key ใน header X-Api-Key
curl -H "X-Api-Key: YOUR_KEY" https://s.example.com/rest/v3/short-urlsออบเจ็กต์ JSON ที่มี key shortUrls หมายความว่า key ใช้งานได้ ส่วน 401 ที่มีค่า INVALID_API_KEY หมายความว่า key ไม่ถูกต้อง ถูกปิดใช้งาน หรือหมดอายุแล้ว
สร้างลิงก์แบบสั้นจากบรรทัดคำสั่ง
CLI เป็นวิธีที่รวดเร็วที่สุดในการสร้างลิงก์ และเหมาะสำหรับใช้ในสคริปต์
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 ช่วยให้คุณได้ลิงก์ที่อ่านเข้าใจได้ แทนโค้ดที่ระบบสร้างขึ้น Slug จะไม่ซ้ำกันภายในแต่ละโดเมน ดังนั้นการลองใช้ slug ที่มีอยู่แล้วเป็นครั้งที่สองจะล้มเหลว แทนที่จะเขียนทับลิงก์แรกโดยไม่แจ้งเตือน --tag สามารถระบุซ้ำได้ และแท็กใช้สำหรับจัดกลุ่มลิงก์ที่คุณต้องการดูสถิติรวมในภายหลัง
แสดงรายการที่มีอยู่ แล้วดูปริมาณการเข้าชมของลิงก์รายการหนึ่ง
sudo docker compose exec shlink shlink short-url:list
sudo docker compose exec shlink shlink short-url:visits docsshort-url:visits แสดง 1 แถวต่อการคลิก 1 ครั้ง พร้อมวันที่ แหล่งอ้างอิง และ user agent คอลัมน์ประเทศและเมืองจะว่างอยู่จนกว่าคุณจะกำหนดตัวแปรสภาพแวดล้อม GEOLITE_LICENSE_KEY ซึ่งเป็นคีย์ MaxMind ที่ไม่มีค่าใช้จ่าย และ Shlink ใช้สำหรับดาวน์โหลดฐานข้อมูล GeoLite2 หากไม่มีตัวแปรนี้ ระบบยังคงบันทึกการเข้าชม แต่จะไม่ระบุตำแหน่งของการเข้าชมเหล่านั้น
เว็บไคลเอ็นต์และ QR codes
ขณะนี้เว็บไคลเอ็นต์อยู่ที่ 127.0.0.1:8081 และต้องมีรายการพร็อกซีของตัวเอง หรือใช้ SSH tunnel หากไม่ต้องการเผยแพร่เว็บไคลเอ็นต์ เว็บไคลเอ็นต์จะขอ server URL และ API key เมื่อโหลดครั้งแรก ให้ป้อน https://s.example.com และคีย์ที่สร้างไว้ เว็บไคลเอ็นต์จัดเก็บข้อมูลทั้งสองรายการไว้ใน browser storage และเรียกใช้ API ของคุณโดยตรง ดังนั้นจะไม่มีข้อมูลผ่านบุคคลอื่น
QR codes ไม่ต้องกำหนดค่าใดๆ เพียงเพิ่ม /qr-code ต่อท้าย short URL ใดๆ แล้ว API จะส่งคืนรูปภาพ
https://s.example.com/docs/qr-code?size=500&format=svg&margin=20size คือความกว้างเป็นพิกเซล และรับค่าได้ตั้งแต่ 50 ถึง 1000 โดยมีค่าเริ่มต้นเป็น 300 format คือ png หรือ svg margin คือพื้นที่ว่างรอบโค้ดในหน่วยพิกเซล และรูปภาพที่เสร็จแล้วจะมีขนาดเท่ากับขนาดที่กำหนดบวกสองเท่าของระยะขอบ เพิ่ม errorCorrection=Q เพื่อให้โค้ดยังคงสแกนได้เมื่อพิมพ์ในขนาดเล็กหรือมีบางส่วนถูกปิดบัง
ให้บริการทำงานต่อเนื่อง
บริการย่อลิงก์อาจล้มเหลวโดยไม่มีการแจ้งเตือน ลิงก์จะหยุดเปลี่ยนเส้นทาง และไม่มีใครแจ้งให้คุณทราบ เพราะผู้ที่คลิกลิงก์คิดว่าลิงก์ใช้งานไม่ได้แล้ว กำหนดการตรวจสอบสถานะการทำงานให้ตรวจสอบ short URL ที่ใช้งานจริง แทนการตรวจสอบหน้าแรก และแจ้งเตือนเมื่อผลลัพธ์ไม่ใช่การเปลี่ยนเส้นทาง อินสแตนซ์ Uptime Kuma ที่โฮสต์เอง ทำงานนี้ได้ดี และสามารถตรวจสอบ status code ที่ระบุได้
สำรองฐานข้อมูล ไม่ใช่ container ใช้คำสั่งเดียวเพื่อ dump ฐานข้อมูล
sudo docker compose exec -T database pg_dump -U shlink shlink | gzip > shlink-$(date +%F).sql.gzไฟล์ดังกล่าวรวมกับไฟล์ compose จะสร้างบริการทั้งหมดขึ้นใหม่บนเซิร์ฟเวอร์เครื่องใหม่ การอัปเกรดคือ sudo docker compose pull ตามด้วย sudo docker compose up -d และ Shlink จะเรียกใช้ migration ใหม่เมื่อเริ่มทำงาน ให้สร้าง dump ก่อน pull เนื่องจากไม่สามารถย้อนกลับ migration ได้
FAQ
เหตุใดลิงก์แบบย่อจึงแสดง 404 หลังจากเพิ่ม reverse proxy?
Shlink จะจับคู่รหัสย่อกับโดเมนในส่วนหัว Host หาก proxy ส่งชื่อของตัวเองหรือที่อยู่ภายในมา Shlink จะค้นหารหัสดังกล่าวภายใต้โดเมนที่ไม่มีลิงก์ จึงตอบกลับเป็น 404 ให้ตั้งค่า proxy_set_header Host $host; ในบล็อก location ของ nginx แล้ว reload proxy ลิงก์จะกลับมาใช้งานได้ทันที โดยไม่ต้อง restart container
จำเป็นต้องใช้ Postgres หรือ SQLite เพียงพอแล้ว?
SQLite เหมาะสำหรับทดลองใช้ Shlink และไม่ต้องมี container ที่สอง แต่ควรเปลี่ยนไปใช้ Postgres ก่อนเผยแพร่ลิงก์ที่สำคัญ เนื่องจากแถวข้อมูลการเข้าชมจะเพิ่มขึ้นทุกครั้งที่มีการคลิก และ SQLite จะทำให้การเขียนข้อมูลทำงานแบบอนุกรม หากเปลี่ยนภายหลัง คุณต้อง export และ import ลิงก์ใหม่ ดังนั้นการเลือก Postgres ตั้งแต่เริ่มต้นจะช่วยหลีกเลี่ยงการย้ายข้อมูลดังกล่าว
กู้คืน API key ที่ลืมคัดลอกได้หรือไม่?
ไม่ได้ Shlink จัดเก็บค่า hash ของ key ดังนั้น api-key:list จะแสดงชื่อและสถานะ แต่จะไม่แสดงค่า key ให้สร้าง key ใหม่ด้วย shlink api-key:generate แล้ววางลงใน web client จากนั้น disable key เดิมด้วย shlink api-key:disable เพื่อหยุดการใช้งาน
เหตุใดคอลัมน์ประเทศในสถิติการเข้าชมจึงว่างเปล่า?
การระบุตำแหน่งทางภูมิศาสตร์ต้องใช้ฐานข้อมูล GeoLite2 ซึ่ง Shlink จะดาวน์โหลดก็ต่อเมื่อคุณกำหนด GEOLITE_LICENSE_KEY โดย key นี้ขอรับได้ฟรีจาก MaxMind ให้เพิ่ม key ในส่วน environment แล้ว recreate container การเข้าชมรายการใหม่จะได้รับการระบุตำแหน่ง ส่วนการเข้าชมที่บันทึกไว้ก่อนหน้านั้นจะยังว่างอยู่จนกว่าจะเรียกใช้ shlink visit:locate
ย้าย Shlink ไปยังเซิร์ฟเวอร์อื่นได้อย่างไร?
คงโดเมนเดิมไว้และย้ายข้อมูล ให้ dump ฐานข้อมูลด้วย pg_dump จากนั้นคัดลอก dump และ compose file ไปยังเซิร์ฟเวอร์ใหม่ start stack แล้ว restore dump ลงในฐานข้อมูลว่างก่อนที่ traffic จริงจะเข้ามา เปลี่ยนระเบียน DNS เป็นขั้นตอนสุดท้าย รหัสย่อและประวัติการเข้าชมจะยังอยู่ เนื่องจากข้อมูลทั้งหมดจัดเก็บในฐานข้อมูล