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

Short links on your own domain: Shlink. Custom slugs, click stats by country and referrer, QR codes for leaflets — and the links are yours forever.

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

TaskWhat
Folder~/docker/shlink/compose.yaml (shlink + shlink-web-client + PostgreSQL)
Short domaingo.example.com (or a short second domain) → the API container, port 8080
Web clienthttps://links.example.com → connects to the API with an API key
API keydocker compose exec shlink shlink api-key:generate
Createweb client → New short URL → long URL, custom slug, tags, expiry
QReach 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

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.

Call us