Shlink: Self-Hosted URL Shortener With Analytics That Respect Privacy
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 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
- A Linux server with Docker and Docker Compose installed
- A short domain pointed at your server (e.g.,
s.yourdomain.comor a dedicated short domain) - Roughly 256 MB of RAM available
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:
- Custom slug: Instead of a random string like
abc12, use something meaningful likemy-projector2026-launch. Custom slugs make links readable and memorable. - Title: A descriptive name for your own reference.
- Tags: Categorize links for filtering and reporting.
- Valid date range: Set a start and end date. The link only works within that window, which is useful for time-limited campaigns or promotions.
- Maximum visits: Set a limit on how many times the link can be visited. After that, it returns a 404.
- Crawlable: Choose whether search engines should follow the redirect. Defaults to not crawlable.
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:
- Timestamp: When the visit occurred
- Referrer: Where the visitor came from
- User agent: Browser and OS information
- Location: Country, city, and region (when GeoIP is configured)
- Bot detection: Whether the visit was from a known bot or crawler
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:
s.company.comfor corporate linksgo.project.orgfor open source project linkslinks.personal.mefor personal 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:
- Create a free account at maxmind.com
- Generate a license key under Account > Manage License Keys
- Set
GEOLITE_LICENSE_KEYin 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:
- Webhook notifications: Configure Shlink to POST to a URL whenever a short URL receives a visit. Use this to trigger Slack notifications, update a dashboard, or feed data into your analytics pipeline.
- Health check endpoint:
GET /rest/healthreturns the server status, useful for monitoring. - Mercure integration: Shlink supports Mercure for real-time, server-sent event updates on visit activity.
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:
- Subdomain of your main domain:
go.yourdomain.comorl.yourdomain.com. Free, easy to set up, clearly tied to your brand. - Dedicated short domain: Something like
yrco.linkoracme.to. More professional for external-facing links, but costs a domain registration fee. - Keep it short: The whole point is brevity.
shortlinks.yourbusinessname.comdefeats the purpose.
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
- Shlink documentation
- Shlink GitHub repository
- Shlink web client
- Shlink REST API docs
- Shlink Docker Hub
- MaxMind GeoLite2 signup -- for GeoIP analytics
- YOURLS -- simpler alternative with built-in UI
- Kutt -- Node.js alternative
- Dub -- full-featured alternative with built-in UI
