← All articles
a close up of a computer motherboard with wires and connectors

Shlink: Self-Hosted URL Shortener With Analytics That Respect Privacy

Productivity 2026-02-13 · 13 min read shlink url-shortener links analytics self-hosted privacy
By Selfhosted Guides Editorial TeamSelf-hosting practitioners covering open source software, home lab infrastructure, and data sovereignty.

Every shortened link you create on Bitly, TinyURL, or Rebrandly is a small bet that their service will stay online, stay free, and never decide to monetize your click data. You hand them your audience's behavior -- which links get clicked, when, from where, on what devices -- and in return you get a short URL that you don't truly control.

Photo by Vishnu Mohanan on Unsplash

Self-hosting a URL shortener flips that arrangement. You own the domain, so shortened links carry your brand. You own the analytics, so click data never leaves your server. And you own the infrastructure, so your links don't break when a SaaS company pivots or shuts down.

Shlink is the strongest option for self-hosted URL shortening. It's an API-first, open source short URL generator with visit analytics, QR code generation, custom slugs, multi-domain support, and bot detection. It runs as a single Docker container backed by a standard database, and it's been actively maintained since 2016.

Shlink URL shortener logo

Shlink vs YOURLS vs Kutt vs Dub

Four serious self-hosted URL shorteners exist. They have different philosophies and different strengths.

Feature Shlink YOURLS Kutt Dub
License MIT MIT MIT AGPL
Language PHP (Swoole) PHP Node.js Next.js (TypeScript)
API-first design Yes Plugin-based Yes Yes
Built-in web UI No (separate client) Yes Yes Yes
QR code generation Built-in Plugin No Yes
Multi-domain support Yes No No Yes
Custom slugs Yes Yes Yes Yes
Visit analytics Yes (detailed) Basic Basic Yes (detailed)
GeoIP tracking Yes (GeoLite2) Plugin Limited Yes
Bot detection Yes No No Yes
Tag system Yes Keyword-based No Tags + folders
Import from Bitly Yes Manual No Yes
REST API Full REST + OpenAPI Basic API REST Full REST
Docker support Official image Community Official Official
Database PostgreSQL, MySQL, SQLite, MS SQL MySQL PostgreSQL PostgreSQL
Minimum RAM ~128 MB ~64 MB ~256 MB ~512 MB
Self-host complexity Low Low Low Moderate
Cloud/SaaS option No No Yes (kutt.it) Yes (dub.co)

When to choose Shlink

You want a proper API-first URL shortener that does one thing well. You're comfortable running a separate web client (or using the API directly). You need multi-domain support or plan to manage thousands of short URLs. You want detailed analytics with GeoIP and bot filtering built in rather than bolted on.

When to choose YOURLS

You want the simplest possible setup and a built-in admin panel. You don't need an API for automation. You're already running a PHP/MySQL stack and want something that slots right in. YOURLS has been around since 2009 and has a huge plugin ecosystem -- if you need a specific feature, someone has probably written a plugin for it.

When to choose Kutt

You want a modern Node.js stack with a clean built-in UI. You don't need advanced analytics or multi-domain support. Kutt is lighter on features than Shlink but includes a usable web interface out of the box, which Shlink does not.

When to choose Dub

You want a polished, full-featured link management platform with a modern UI, workspace collaboration, and advanced analytics. You're willing to accept the heavier resource footprint and more complex setup. Dub started as a SaaS product (dub.co) and open-sourced later, so the self-hosted version is feature-rich but carries the weight of a full Next.js application.

Installation with Docker Compose

Shlink's recommended setup uses a single Shlink container plus a database. The example below uses PostgreSQL, which is the best choice for production use.

Prerequisites

Docker Compose file

Create a directory for your Shlink installation and add this docker-compose.yml:

services:
  shlink:
    image: shlinkio/shlink:stable
    container_name: shlink
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
    ports:
      - "8080:8080"
    environment:
      DEFAULT_DOMAIN: s.yourdomain.com
      IS_HTTPS_ENABLED: "true"
      GEOLITE_LICENSE_KEY: your-geolite-key
      DB_DRIVER: postgres
      DB_NAME: shlink
      DB_USER: shlink
      DB_PASSWORD: your-secure-password
      DB_HOST: db
      DB_PORT: 5432
      # Timezone for analytics
      TIMEZONE: America/New_York

  db:
    image: postgres:16-alpine
    container_name: shlink-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: shlink
      POSTGRES_USER: shlink
      POSTGRES_PASSWORD: your-secure-password
    volumes:
      - shlink-db:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U shlink"]
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  shlink-db:

Replace your-secure-password with a strong database password and s.yourdomain.com with your actual short domain.

MySQL alternative

If you prefer MySQL, change the database configuration:

services:
  shlink:
    image: shlinkio/shlink:stable
    environment:
      DB_DRIVER: mysql
      DB_NAME: shlink
      DB_USER: shlink
      DB_PASSWORD: your-secure-password
      DB_HOST: db
      DB_PORT: 3306
      # ... other env vars stay the same
    # ... rest stays the same

  db:
    image: mysql:8
    container_name: shlink-db
    restart: unless-stopped
    environment:
      MYSQL_DATABASE: shlink
      MYSQL_USER: shlink
      MYSQL_PASSWORD: your-secure-password
      MYSQL_ROOT_PASSWORD: your-root-password
    volumes:
      - shlink-db:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 5s
      timeout: 5s
      retries: 5

PostgreSQL is recommended. Shlink also supports SQLite for very small installations (no separate database container needed), but SQLite doesn't handle concurrent writes well, so it's only suitable for personal use with low traffic.

Start the services

docker compose up -d

Shlink is now running on port 8080. The first thing you need is an API key.

Generate your API key

docker compose exec shlink shlink api-key:generate

This prints an API key. Save it -- you'll need it for the web client and any API calls.

Verify it works

Create your first short URL from the command line:

docker compose exec shlink shlink short-url:create https://example.com

Shlink responds with a short URL like https://s.yourdomain.com/abc12. Visit it (or curl it) and confirm it redirects correctly.

Setting Up the Web Client

Shlink deliberately separates the API server from the web interface. The server handles redirects and analytics. The web client is a separate single-page application for managing your short URLs.

Docker Compose for shlink-web-client

Add the web client to your existing docker-compose.yml:

  shlink-web:
    image: shlinkio/shlink-web-client:stable
    container_name: shlink-web
    restart: unless-stopped
    ports:
      - "8081:8080"
    environment:
      SHLINK_SERVER_URL: https://s.yourdomain.com
      SHLINK_SERVER_API_KEY: your-api-key-from-above
      SHLINK_SERVER_NAME: My Short URLs

Now http://your-server:8081 serves the management dashboard. Put it behind a reverse proxy with authentication -- this is your admin panel and should not be publicly accessible.

Reverse proxy with Caddy

You'll typically run the Shlink API behind one domain and the web client behind another:

# Short URL domain - public, handles redirects
s.yourdomain.com {
    reverse_proxy localhost:8080
}

# Admin panel - restrict access
links-admin.yourdomain.com {
    reverse_proxy localhost:8081
}

The short URL domain must be public (that's the whole point). The admin panel should be restricted to your IP, behind a VPN, or protected with Cloudflare Access, Authelia, or similar.

Like what you're reading? Subscribe to Self-Hosted Weekly — free weekly guides in your inbox.

Core Features

Short URLs and custom slugs

The most basic operation is creating a short URL. Through the web client or API, you provide a long URL and optionally:

QR code generation

Every short URL in Shlink automatically gets a QR code endpoint. If your short URL is https://s.yourdomain.com/my-link, the QR code is available at:

https://s.yourdomain.com/my-link/qr-code

You can customize QR codes with query parameters:

# Set size (pixels)
https://s.yourdomain.com/my-link/qr-code?size=400

# Set margin (pixels)
https://s.yourdomain.com/my-link/qr-code?margin=20

# Set format (png or svg)
https://s.yourdomain.com/my-link/qr-code?format=svg

# Set error correction level (L, M, Q, H)
https://s.yourdomain.com/my-link/qr-code?errorCorrection=H

# Combine them
https://s.yourdomain.com/my-link/qr-code?size=400&format=svg&margin=10&errorCorrection=H

This is genuinely useful. No external QR code generator needed, and the QR code always points to a URL you control. If you need to change where the link goes, update the short URL and the existing QR codes (already printed on flyers, business cards, whatever) keep working.

Visit analytics

Every visit to a short URL is logged with:

Analytics are viewable per short URL in the web client, and you can filter by date range, location, and other dimensions.

Shlink's analytics are not a replacement for Google Analytics or Plausible. They track one thing: clicks on your short links. But for that one thing, they're thorough.

Multi-domain support

This is a feature that sets Shlink apart from most alternatives. You can serve short URLs from multiple domains through a single Shlink installation.

For example, you might use:

Configure additional domains through the CLI:

docker compose exec shlink shlink domain:list
docker compose exec shlink shlink short-url:create --domain=go.project.org https://example.com

Each domain resolves to the same Shlink server, but short URLs are scoped per domain -- the same slug can exist on different domains pointing to different destinations.

API-first design

Shlink is built API-first, which means everything you can do in the web client, you can do through the REST API. The API is documented with OpenAPI/Swagger, making it straightforward to integrate with other tools.

Common API operations:

# Create a short URL
curl -X POST https://s.yourdomain.com/rest/v3/short-urls \
  -H "X-Api-Key: your-api-key" \
  -H "Content-Type: application/json" \
  -d '{
    "longUrl": "https://example.com/very/long/path",
    "customSlug": "my-link",
    "tags": ["marketing", "2026"]
  }'

# List all short URLs
curl https://s.yourdomain.com/rest/v3/short-urls \
  -H "X-Api-Key: your-api-key"

# Get visit stats for a short URL
curl https://s.yourdomain.com/rest/v3/short-urls/my-link/visits \
  -H "X-Api-Key: your-api-key"

# Delete a short URL
curl -X DELETE https://s.yourdomain.com/rest/v3/short-urls/my-link \
  -H "X-Api-Key: your-api-key"

Advanced Features

GeoIP tracking with GeoLite2

Shlink uses MaxMind's GeoLite2 database for IP geolocation. To enable it, you need a free GeoLite2 license key:

  1. Create a free account at maxmind.com
  2. Generate a license key under Account > Manage License Keys
  3. Set GEOLITE_LICENSE_KEY in your Docker Compose environment

Shlink downloads and updates the GeoLite2 database automatically. With this configured, every visit is tagged with country, region, and city -- accurate enough for understanding your audience's geography, though not precise enough for street-level tracking (which you don't want anyway).

Without the GeoLite2 key, Shlink still works perfectly. You just won't have geographic data in your analytics.

Bot detection

Shlink identifies known bots and crawlers (Googlebot, social media link previews, monitoring tools, etc.) and separates them from human visits in your analytics. This happens automatically -- no configuration needed.

This matters more than you might think. When you share a link on Slack, Twitter, or Facebook, their servers fetch the URL to generate a preview. Without bot detection, those automated fetches inflate your visit counts. Shlink filters them out so your analytics reflect actual human clicks.

You can choose whether to include or exclude bot visits when querying analytics, giving you both the raw numbers and the filtered view.

Tag-based organization

Tags let you organize and filter short URLs by campaign, project, team, or any other dimension:

# Create a tagged short URL
docker compose exec shlink shlink short-url:create \
  --tags="q1-campaign,marketing,newsletter" \
  https://example.com/spring-sale

# List short URLs by tag
docker compose exec shlink shlink short-url:list --tags=marketing

# Get aggregate stats for a tag
curl https://s.yourdomain.com/rest/v3/tags/marketing/visits \
  -H "X-Api-Key: your-api-key"

Tags are searchable and filterable in the web client too. If you manage hundreds of short URLs, tags keep things navigable.

Importing from Bitly

If you're migrating from Bitly, Shlink can import your existing short URLs and their visit history:

docker compose exec shlink shlink short-url:import bitly

The importer prompts for your Bitly API token and pulls in your links, custom slugs, tags, and historical visit data. The imported short URLs won't have the same Bitly domain (obviously), but all metadata transfers over.

Shlink also supports importing from YOURLS and a generic CSV format, so you can migrate from most other services with some preparation.

REST API for automation

Beyond basic CRUD operations, the API supports webhooks and integrations that enable real automation:

A practical example -- post to a Slack webhook whenever a specific link gets clicked:

# In your automation tool (n8n, Make, custom script):
# 1. Configure Shlink to send webhooks on visits
# 2. Filter for the short URL you care about
# 3. Forward to Slack's webhook URL

Resource Requirements

Shlink is lightweight. Here's what to expect:

Component CPU RAM Disk
Shlink server Minimal (< 5% idle) 64-128 MB ~100 MB (image)
PostgreSQL Minimal 100-256 MB ~50 MB base + ~1 KB per short URL
shlink-web-client Negligible (static files) 16-32 MB ~30 MB (image)
GeoLite2 database N/A Loaded on demand ~70 MB
Total (light use) Single core 256-512 MB ~300 MB

For context, a Shlink installation handling 10,000 short URLs with 100,000 visits per month runs comfortably on a $5/month VPS. The Swoole runtime (a high-performance PHP extension) handles concurrent requests efficiently, so you won't hit performance issues until you're dealing with serious traffic.

Scaling beyond that is straightforward: Shlink is stateless (state lives in the database), so you can run multiple Shlink containers behind a load balancer if needed.

The Honest Limitations

No built-in web UI

This is the biggest friction point. Shlink ships as an API server. The web management interface (shlink-web-client) is a separate project, a separate container, and a separate deployment. For many users, this feels like an unnecessary complication.

The rationale is architectural purity -- the server handles redirects and analytics as fast as possible without serving a web application. The trade-off is a two-container setup where YOURLS or Kutt give you everything in one.

If you're comfortable with the CLI or plan to use the API, the lack of a built-in UI won't bother you. If you expect a single-container deploy with a dashboard, this is a genuine downside.

PHP-based

Shlink is written in PHP running on Swoole. If you're in a purely Node.js or Go environment and prefer not to run PHP, this is worth noting. In practice, the Docker image abstracts this away entirely -- you never interact with PHP directly -- but it affects things like contributing to the project or understanding the codebase if you need to.

Analytics are basic compared to commercial tools

Shlink tells you how many times a link was clicked, from where, on what device, and whether it was a bot. That's it. There are no conversion funnels, no A/B testing of different destinations, no revenue attribution, no heatmaps. If you need those, you're looking at pairing Shlink with a proper analytics tool on the destination pages.

For what Shlink tracks -- link click behavior -- the analytics are solid. Just don't expect it to replace your website analytics.

No link previews or social cards

When someone encounters a Shlink short URL, they see the redirect. Shlink doesn't inject Open Graph tags or generate social media preview cards for the short URL itself. The destination page's social cards show up after the redirect, but the short URL itself is opaque. Some competing tools (like Dub) generate preview metadata for the short URL.

GeoLite2 accuracy

The free GeoLite2 database is accurate at the country level but less reliable for city-level geolocation. If you need precise geographic analytics, you'd need MaxMind's paid GeoIP2 database, which Shlink also supports but costs money.

Practical Tips

Choose your short domain carefully

Your short domain is part of every link you create, so pick something short, memorable, and brandable. A few approaches:

Set up HTTPS from the start

Set IS_HTTPS_ENABLED=true in your Shlink configuration and handle TLS at your reverse proxy. Short URLs generated with HTTP look unprofessional and may trigger security warnings in some contexts.

Use the CLI for bulk operations

The Shlink CLI inside the container is powerful for batch work:

# List all short URLs
docker compose exec shlink shlink short-url:list

# List with filters
docker compose exec shlink shlink short-url:list --tags=marketing --startDate=2026-01-01

# Get visit stats
docker compose exec shlink shlink visit:list my-slug

# Delete orphan visits (visits to deleted short URLs)
docker compose exec shlink shlink visit:orphan

Back up your database

Your short URLs and visit history live in the database. If you lose it, all your links break. Set up regular database backups:

# PostgreSQL backup
docker compose exec db pg_dump -U shlink shlink | gzip > shlink-backup-$(date +%Y%m%d).sql.gz

Run this daily via cron. Short URLs are small, so even a large installation with millions of visits produces manageable backup files.

Monitor with the health endpoint

Shlink exposes a health check at /rest/health:

curl https://s.yourdomain.com/rest/health

Add this to your monitoring (Uptime Kuma, Healthchecks.io, etc.) to catch issues before your short URLs go down.

Keep the GeoLite2 database updated

Shlink auto-updates the GeoLite2 database, but you can trigger a manual update:

docker compose exec shlink shlink visit:download-db

MaxMind updates the database weekly. If you see geographic data becoming less accurate over time, check that the auto-update is working.

Resources

Get free weekly tips in your inbox. Subscribe to Self-Hosted Weekly