How to Self-Host a URL Shortener with Shlink (Short Links on Your Own Domain, with Click Stats and QR Codes)

A bit.ly link on a leaflet is a bet that bit.ly exists and stays free in five years. Shlink runs the shortener on a domain you own — jc.to/laptop or go.example.com/booking — with a web client for creating links, click statistics (when, where, which referrer, which device), QR codes ready for print, expiry dates and click limits, and an API so n8n (77) or a script can mint links. Two containers.
Quick reference
| Task | What |
|---|---|
| Folder | ~/docker/shlink/compose.yaml (shlink + shlink-web-client + PostgreSQL) |
| Short domain | go.example.com (or a short second domain) → the API container, port 8080 |
| Web client | https://links.example.com → connects to the API with an API key |
| API key | docker compose exec shlink shlink api-key:generate |
| Create | web client → New short URL → long URL, custom slug, tags, expiry |
| QR | each link → QR code (PNG/SVG, size, margin) |
1. The compose file
mkdir -p ~/docker/shlink && cd ~/docker/shlink
nano compose.yaml
services:
shlink:
image: shlinkio/shlink:stable
container_name: shlink
ports:
- "127.0.0.1:8097:8080"
environment:
DEFAULT_DOMAIN: go.example.com
IS_HTTPS_ENABLED: "true"
DB_DRIVER: postgres
DB_HOST: shlink-db
DB_NAME: shlink
DB_USER: shlink
DB_PASSWORD: ${DB_PASSWORD}
GEOLITE_LICENSE_KEY: ${GEOLITE_KEY}
TIMEZONE: Europe/London
depends_on: [shlink-db]
restart: unless-stopped
shlink-web:
image: shlinkio/shlink-web-client:stable
container_name: shlink-web
ports:
- "127.0.0.1:8098:8080"
restart: unless-stopped
shlink-db:
image: postgres:16-alpine
container_name: shlink-db
environment:
POSTGRES_USER: shlink
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: shlink
volumes:
- ./db:/var/lib/postgresql/data
restart: unless-stopped
GEOLITE_LICENSE_KEY is a free MaxMind key for country lookups (optional; stats work without the map).
docker compose up -d
docker compose exec shlink shlink api-key:generate
Save the API key.
2. Two proxy entries
- go.example.com → 127.0.0.1:8097 — the short domain, public, HTTPS (27 or 109). Every short link lives here.
- links.example.com → 127.0.0.1:8098 — the web client, LAN/VPN or behind Authelia (95); it's just a static app that talks to the API.
The short domain is the one to make genuinely short: a cheap two-letter .to/.ly/.uk domain pointed at the server, with its own certificate.
3. The web client
Open https://links.example.com → Add server: name, URL https://go.example.com, the API key. Then New short URL: paste the long URL, optional custom slug (booking), tags (leaflet, email), expiry or max visits, a title. Create. https://go.example.com/booking redirects, and the dashboard counts.
4. Stats
Click a link → Visits: a timeline, by country and city (with the GeoLite key), by referrer, by browser/OS, bot vs human. Which leaflet drop worked, whether the QR on the van gets scanned, which email campaign (194) people clicked. Visits are stored on your server; nothing goes to a third party, and you can disable visitor IP storage (ANONYMIZE_REMOTE_ADDR, default on) for privacy.
5. QR codes
Each link → QR code: size, margin, error correction, PNG or SVG — straight into a leaflet, a van sticker, a shop sign, a business card. Because it's your domain, the QR on a printed thing keeps working as long as you keep the server; change where it points later without reprinting.
6. From scripts and n8n
curl -s -X POST https://go.example.com/rest/v3/short-urls \
-H "X-Api-Key: $SHLINK_KEY" -H "Content-Type: application/json" \
-d '{"longUrl": "https://example.com/blog/very-long-post", "customSlug": "post42", "tags": ["blog"]}'
Returns the short URL. The Hugo deploy script (110) can mint one per post; n8n's Shlink node does it in a workflow; a browser bookmarklet creates one for the page you're on. The web client also has a bookmarklet under settings.
Behaviour settings
DEFAULT_SHORT_CODES_LENGTH (5 is plenty), REDIRECT_STATUS_CODE (302 default; 301 for permanent, cached by browsers), a default redirect for the bare short domain (go.example.com → your site), DEFAULT_INVALID_SHORT_URL_REDIRECT for typos, and MULTI_SEGMENT_SLUGS_ENABLED if you want go.example.com/leaflets/april.
Troubleshooting
Short links 404 through the proxy. DEFAULT_DOMAIN must equal the hostname you're using; the proxy must pass Host.
Web client can't reach the server. CORS: the API is on a different host — that's expected and allowed; check the API URL is https and the key is valid (shlink api-key:list).
No countries in stats. No GeoLite key, or the download hasn't run (shlink visit:download-db).
Links tracked twice. Bots and link previews (Slack, WhatsApp) fetch links; stats separate bots, or use DISABLE_TRACK_PARAM.
Wrapping up
Three containers, a short domain through the proxy, the web client behind your login, an API key. Links on a domain you own, with the stats and QR codes you'd pay for, minted from scripts and workflows — and printed material that outlives any shortener company.
